summaryrefslogtreecommitdiffstats
path: root/users
diff options
context:
space:
mode:
Diffstat (limited to 'users')
-rw-r--r--users/admin.py6
-rw-r--r--users/migrations/0005_invite.py32
-rw-r--r--users/models/__init__.py1
-rw-r--r--users/models/invite.py30
-rw-r--r--users/models/password_reset.py2
-rw-r--r--users/views/admin/__init__.py56
-rw-r--r--users/views/admin/domains.py (renamed from users/views/admin.py)107
-rw-r--r--users/views/admin/settings.py83
-rw-r--r--users/views/auth.py18
9 files changed, 232 insertions, 103 deletions
diff --git a/users/admin.py b/users/admin.py
index de07e5c..0901307 100644
--- a/users/admin.py
+++ b/users/admin.py
@@ -5,6 +5,7 @@ from users.models import (
Follow,
Identity,
InboxMessage,
+ Invite,
PasswordReset,
User,
UserEvent,
@@ -66,3 +67,8 @@ class InboxMessageAdmin(admin.ModelAdmin):
def reset_state(self, request, queryset):
for instance in queryset:
instance.transition_perform("received")
+
+
+@admin.register(Invite)
+class InviteAdmin(admin.ModelAdmin):
+ list_display = ["id", "created", "token", "note"]
diff --git a/users/migrations/0005_invite.py b/users/migrations/0005_invite.py
new file mode 100644
index 0000000..bb18841
--- /dev/null
+++ b/users/migrations/0005_invite.py
@@ -0,0 +1,32 @@
+# Generated by Django 4.1.3 on 2022-11-18 06:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("users", "0004_passwordreset"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Invite",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("token", models.CharField(max_length=500, unique=True)),
+ ("email", models.EmailField(blank=True, max_length=254, null=True)),
+ ("note", models.TextField(blank=True, null=True)),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("updated", models.DateTimeField(auto_now=True)),
+ ],
+ ),
+ ]
diff --git a/users/models/__init__.py b/users/models/__init__.py
index e46860e..fc0d402 100644
--- a/users/models/__init__.py
+++ b/users/models/__init__.py
@@ -3,6 +3,7 @@ from .domain import Domain # noqa
from .follow import Follow, FollowStates # noqa
from .identity import Identity, IdentityStates # noqa
from .inbox_message import InboxMessage, InboxMessageStates # noqa
+from .invite import Invite # noqa
from .password_reset import PasswordReset # noqa
from .user import User # noqa
from .user_event import UserEvent # noqa
diff --git a/users/models/invite.py b/users/models/invite.py
new file mode 100644
index 0000000..5d69b18
--- /dev/null
+++ b/users/models/invite.py
@@ -0,0 +1,30 @@
+import random
+
+from django.db import models
+
+
+class Invite(models.Model):
+ """
+ An invite token, good for one signup.
+ """
+
+ # Should always be lowercase
+ token = models.CharField(max_length=500, unique=True)
+
+ # Is it limited to a specific email?
+ email = models.EmailField(null=True, blank=True)
+
+ # Admin note about this code
+ note = models.TextField(null=True, blank=True)
+
+ created = models.DateTimeField(auto_now_add=True)
+ updated = models.DateTimeField(auto_now=True)
+
+ @classmethod
+ def create_random(cls, email=None):
+ return cls.objects.create(
+ token="".join(
+ random.choice("abcdefghkmnpqrstuvwxyz23456789") for i in range(20)
+ ),
+ email=email,
+ )
diff --git a/users/models/password_reset.py b/users/models/password_reset.py
index 90062d3..628efa6 100644
--- a/users/models/password_reset.py
+++ b/users/models/password_reset.py
@@ -27,7 +27,7 @@ class PasswordResetStates(StateGraph):
await sync_to_async(send_mail)(
subject=f"{Config.system.site_name}: Confirm new account",
message=render_to_string(
- "emails/new_account.txt",
+ "emails/account_new.txt",
{
"reset": reset,
"config": Config.system,
diff --git a/users/views/admin/__init__.py b/users/views/admin/__init__.py
new file mode 100644
index 0000000..231e027
--- /dev/null
+++ b/users/views/admin/__init__.py
@@ -0,0 +1,56 @@
+from django import forms
+from django.utils.decorators import method_decorator
+from django.views.generic import FormView, RedirectView, TemplateView
+
+from users.decorators import admin_required
+from users.models import Identity, User
+from users.views.admin.domains import ( # noqa
+ DomainCreate,
+ DomainDelete,
+ DomainEdit,
+ Domains,
+)
+from users.views.admin.settings import BasicSettings # noqa
+
+
+@method_decorator(admin_required, name="dispatch")
+class AdminRoot(RedirectView):
+ pattern_name = "admin_basic"
+
+
+@method_decorator(admin_required, name="dispatch")
+class Users(TemplateView):
+
+ template_name = "admin/users.html"
+
+ def get_context_data(self):
+ return {
+ "users": User.objects.order_by("email"),
+ "section": "users",
+ }
+
+
+@method_decorator(admin_required, name="dispatch")
+class Identities(TemplateView):
+
+ template_name = "admin/identities.html"
+
+ def get_context_data(self):
+ return {
+ "identities": Identity.objects.order_by("username"),
+ "section": "identities",
+ }
+
+
+@method_decorator(admin_required, name="dispatch")
+class Invites(FormView):
+
+ template_name = "admin/invites.html"
+ extra_context = {"section": "invites"}
+
+ class form_class(forms.Form):
+ note = forms.CharField()
+
+ def get_context_data(self, *args, **kwargs):
+ context = super().get_context_data(*args, **kwargs)
+ return context
diff --git a/users/views/admin.py b/users/views/admin/domains.py
index 93bf4ec..e1a011b 100644
--- a/users/views/admin.py
+++ b/users/views/admin/domains.py
@@ -4,85 +4,14 @@ from django import forms
from django.db import models
from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator
-from django.views.generic import FormView, RedirectView, TemplateView
+from django.views.generic import FormView, TemplateView
-from core.models import Config
from users.decorators import admin_required
-from users.models import Domain, Identity, User
-from users.views.settings import SettingsPage
+from users.models import Domain
@method_decorator(admin_required, name="dispatch")
-class AdminRoot(RedirectView):
- pattern_name = "admin_basic"
-
-
-@method_decorator(admin_required, name="dispatch")
-class AdminSettingsPage(SettingsPage):
- """
- Shows a settings page dynamically created from our settings layout
- at the bottom of the page. Don't add this to a URL directly - subclass!
- """
-
- options_class = Config.SystemOptions
-
- def load_config(self):
- return Config.load_system()
-
- def save_config(self, key, value):
- Config.set_system(key, value)
-
-
-class BasicPage(AdminSettingsPage):
-
- section = "basic"
-
- options = {
- "site_name": {
- "title": "Site Name",
- },
- "highlight_color": {
- "title": "Highlight Color",
- "help_text": "Used for logo background and other highlights",
- },
- "post_length": {
- "title": "Maximum Post Length",
- "help_text": "The maximum number of characters allowed per post",
- },
- "site_about": {
- "title": "About This Site",
- "help_text": "Displayed on the homepage and the about page",
- "display": "textarea",
- },
- "site_icon": {
- "title": "Site Icon",
- "help_text": "Minimum size 64x64px. Should be square.",
- },
- "site_banner": {
- "title": "Site Banner",
- "help_text": "Must be at least 650px wide. 3:1 ratio of width:height recommended.",
- },
- "identity_max_per_user": {
- "title": "Maximum Identities Per User",
- "help_text": "Non-admins will be blocked from creating more than this",
- },
- }
-
- layout = {
- "Branding": [
- "site_name",
- "site_about",
- "site_icon",
- "site_banner",
- "highlight_color",
- ],
- "Posts": ["post_length"],
- "Identities": ["identity_max_per_user"],
- }
-
-
-@method_decorator(admin_required, name="dispatch")
-class DomainsPage(TemplateView):
+class Domains(TemplateView):
template_name = "admin/domains.html"
@@ -94,7 +23,7 @@ class DomainsPage(TemplateView):
@method_decorator(admin_required, name="dispatch")
-class DomainCreatePage(FormView):
+class DomainCreate(FormView):
template_name = "admin/domain_create.html"
extra_context = {"section": "domains"}
@@ -154,7 +83,7 @@ class DomainCreatePage(FormView):
@method_decorator(admin_required, name="dispatch")
-class DomainEditPage(FormView):
+class DomainEdit(FormView):
template_name = "admin/domain_edit.html"
extra_context = {"section": "domains"}
@@ -200,7 +129,7 @@ class DomainEditPage(FormView):
@method_decorator(admin_required, name="dispatch")
-class DomainDeletePage(TemplateView):
+class DomainDelete(TemplateView):
template_name = "admin/domain_delete.html"
@@ -222,27 +151,3 @@ class DomainDeletePage(TemplateView):
raise ValueError("Tried to delete domain with identities!")
self.domain.delete()
return redirect("/settings/system/domains/")
-
-
-@method_decorator(admin_required, name="dispatch")
-class UsersPage(TemplateView):
-
- template_name = "admin/users.html"
-
- def get_context_data(self):
- return {
- "users": User.objects.order_by("email"),
- "section": "users",
- }
-
-
-@method_decorator(admin_required, name="dispatch")
-class IdentitiesPage(TemplateView):
-
- template_name = "admin/identities.html"
-
- def get_context_data(self):
- return {
- "identities": Identity.objects.order_by("username"),
- "section": "identities",
- }
diff --git a/users/views/admin/settings.py b/users/views/admin/settings.py
new file mode 100644
index 0000000..a528f93
--- /dev/null
+++ b/users/views/admin/settings.py
@@ -0,0 +1,83 @@
+from django.utils.decorators import method_decorator
+
+from core.models import Config
+from users.decorators import admin_required
+from users.views.settings import SettingsPage
+
+
+@method_decorator(admin_required, name="dispatch")
+class AdminSettingsPage(SettingsPage):
+ """
+ Shows a settings page dynamically created from our settings layout
+ at the bottom of the page. Don't add this to a URL directly - subclass!
+ """
+
+ options_class = Config.SystemOptions
+
+ def load_config(self):
+ return Config.load_system()
+
+ def save_config(self, key, value):
+ Config.set_system(key, value)
+
+
+class BasicSettings(AdminSettingsPage):
+
+ section = "basic"
+
+ options = {
+ "site_name": {
+ "title": "Site Name",
+ },
+ "highlight_color": {
+ "title": "Highlight Color",
+ "help_text": "Used for logo background and other highlights",
+ },
+ "post_length": {
+ "title": "Maximum Post Length",
+ "help_text": "The maximum number of characters allowed per post",
+ },
+ "site_about": {
+ "title": "About This Site",
+ "help_text": "Displayed on the homepage and the about page",
+ "display": "textarea",
+ },
+ "site_icon": {
+ "title": "Site Icon",
+ "help_text": "Minimum size 64x64px. Should be square.",
+ },
+ "site_banner": {
+ "title": "Site Banner",
+ "help_text": "Must be at least 650px wide. 3:1 ratio of width:height recommended.",
+ },
+ "identity_max_per_user": {
+ "title": "Maximum Identities Per User",
+ "help_text": "Non-admins will be blocked from creating more than this",
+ },
+ "signup_allowed": {
+ "title": "Signups Allowed",
+ "help_text": "If signups are allowed at all",
+ },
+ "signup_invite_only": {
+ "title": "Invite-Only",
+ "help_text": "If signups require an invite code",
+ },
+ "signup_text": {
+ "title": "Signup Page Text",
+ "help_text": "Shown above the signup form",
+ "display": "textarea",
+ },
+ }
+
+ layout = {
+ "Branding": [
+ "site_name",
+ "site_about",
+ "site_icon",
+ "site_banner",
+ "highlight_color",
+ ],
+ "Signups": ["signup_allowed", "signup_invite_only", "signup_text"],
+ "Posts": ["post_length"],
+ "Identities": ["identity_max_per_user"],
+ }
diff --git a/users/views/auth.py b/users/views/auth.py
index 7f51d45..a04b1b1 100644
--- a/users/views/auth.py
+++ b/users/views/auth.py
@@ -4,7 +4,8 @@ from django.contrib.auth.views import LoginView, LogoutView
from django.shortcuts import get_object_or_404, render
from django.views.generic import FormView
-from users.models import PasswordReset, User
+from core.models import Config
+from users.models import Invite, PasswordReset, User
class Login(LoginView):
@@ -26,6 +27,13 @@ class Signup(FormView):
help_text="We will send a link to this email to set your password and create your account",
)
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if Config.system.signup_invite_only:
+ self.fields["invite_code"] = forms.CharField(
+ help_text="Your invite code from one of our admins"
+ )
+
def clean_email(self):
email = self.cleaned_data.get("email").lower()
if not email:
@@ -34,9 +42,17 @@ class Signup(FormView):
raise forms.ValidationError("This email already has an account")
return email
+ def clean_invite_code(self):
+ invite_code = self.cleaned_data["invite_code"].lower().strip()
+ if not Invite.objects.filter(token=invite_code).exists():
+ raise forms.ValidationError("That is not a valid invite code")
+ return invite_code
+
def form_valid(self, form):
user = User.objects.create(email=form.cleaned_data["email"])
PasswordReset.create_for_user(user)
+ if "invite_code" in form.cleaned_data:
+ Invite.objects.filter(token=form.cleaned_data["invite_code"]).delete()
return render(
self.request,
"auth/signup_success.html",