diff options
Diffstat (limited to 'users')
| -rw-r--r-- | users/admin.py | 6 | ||||
| -rw-r--r-- | users/migrations/0005_invite.py | 32 | ||||
| -rw-r--r-- | users/models/__init__.py | 1 | ||||
| -rw-r--r-- | users/models/invite.py | 30 | ||||
| -rw-r--r-- | users/models/password_reset.py | 2 | ||||
| -rw-r--r-- | users/views/admin/__init__.py | 56 | ||||
| -rw-r--r-- | users/views/admin/domains.py (renamed from users/views/admin.py) | 107 | ||||
| -rw-r--r-- | users/views/admin/settings.py | 83 | ||||
| -rw-r--r-- | users/views/auth.py | 18 | 
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",  | 
