From 1b44a253316a84f40070264ea8134c86d1223441 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Fri, 18 Nov 2022 00:09:04 -0700 Subject: [PATCH] Signup and invite tweaks --- README.md | 4 +- core/middleware.py | 17 +++ core/models/config.py | 4 +- takahe/settings/base.py | 1 + takahe/urls.py | 19 ++-- templates/activities/_menu.html | 8 +- templates/admin/invites.html | 9 ++ templates/auth/signup.html | 1 + .../{new_account.txt => account_new.txt} | 0 templates/settings/_menu.html | 3 + users/admin.py | 6 + users/migrations/0005_invite.py | 32 ++++++ users/models/__init__.py | 1 + users/models/invite.py | 30 +++++ users/models/password_reset.py | 2 +- users/views/admin/__init__.py | 56 +++++++++ users/views/{admin.py => admin/domains.py} | 107 +----------------- users/views/admin/settings.py | 83 ++++++++++++++ users/views/auth.py | 18 ++- 19 files changed, 285 insertions(+), 116 deletions(-) create mode 100644 templates/admin/invites.html rename templates/emails/{new_account.txt => account_new.txt} (100%) create mode 100644 users/migrations/0005_invite.py create mode 100644 users/models/invite.py create mode 100644 users/views/admin/__init__.py rename users/views/{admin.py => admin/domains.py} (65%) create mode 100644 users/views/admin/settings.py diff --git a/README.md b/README.md index 4236eb1..1b3bcb2 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ the less sure I am about it. - [x] Profile pages - [x] Settable icon and background image for profiles - [x] User search -- [ ] Following page -- [ ] Followers page +- [x] Following page +- [x] Followers page - [x] Multiple domain support - [x] Multiple identity support - [x] Serverless-friendly worker subsystem diff --git a/core/middleware.py b/core/middleware.py index 8e95f06..fdb08a8 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -1,3 +1,6 @@ +from core.models import Config + + class AlwaysSecureMiddleware: """ Locks the request object as always being secure, for when it's behind @@ -11,3 +14,17 @@ class AlwaysSecureMiddleware: request.__class__.scheme = "https" response = self.get_response(request) return response + + +class ConfigLoadingMiddleware: + """ + Caches the system config every request + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + Config.system = Config.load_system() + response = self.get_response(request) + return response diff --git a/core/models/config.py b/core/models/config.py index 4ba8375..2a27d2e 100644 --- a/core/models/config.py +++ b/core/models/config.py @@ -160,7 +160,9 @@ class Config(models.Model): site_icon: UploadedImage = static("img/icon-128.png") site_banner: UploadedImage = static("img/fjords-banner-600.jpg") - allow_signups: bool = False + signup_allowed: bool = False + signup_invite_only: bool = False + signup_text: str = "" post_length: int = 500 identity_max_per_user: int = 5 diff --git a/takahe/settings/base.py b/takahe/settings/base.py index 04a43dd..614bfd2 100644 --- a/takahe/settings/base.py +++ b/takahe/settings/base.py @@ -28,6 +28,7 @@ MIDDLEWARE = [ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "django_htmx.middleware.HtmxMiddleware", + "core.middleware.ConfigLoadingMiddleware", "users.middleware.IdentityMiddleware", ] diff --git a/takahe/urls.py b/takahe/urls.py index 044599a..8c01d64 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -48,37 +48,42 @@ urlpatterns = [ ), path( "admin/basic/", - admin.BasicPage.as_view(), + admin.BasicSettings.as_view(), name="admin_basic", ), path( "admin/domains/", - admin.DomainsPage.as_view(), + admin.Domains.as_view(), name="admin_domains", ), path( "admin/domains/create/", - admin.DomainCreatePage.as_view(), + admin.DomainCreate.as_view(), name="admin_domains_create", ), path( "admin/domains//", - admin.DomainEditPage.as_view(), + admin.DomainEdit.as_view(), ), path( "admin/domains//delete/", - admin.DomainDeletePage.as_view(), + admin.DomainDelete.as_view(), ), path( "admin/users/", - admin.UsersPage.as_view(), + admin.Users.as_view(), name="admin_users", ), path( "admin/identities/", - admin.IdentitiesPage.as_view(), + admin.Identities.as_view(), name="admin_identities", ), + path( + "admin/invites/", + admin.Invites.as_view(), + name="admin_invites", + ), # Identity views path("@/", identity.ViewIdentity.as_view()), path("@/actor/", activitypub.Actor.as_view()), diff --git a/templates/activities/_menu.html b/templates/activities/_menu.html index 6b40197..01f34cd 100644 --- a/templates/activities/_menu.html +++ b/templates/activities/_menu.html @@ -27,9 +27,11 @@ Local Posts

- - Create Account - + {% if config.signup_allowed %} + + Create Account + + {% endif %} {% endif %} diff --git a/templates/admin/invites.html b/templates/admin/invites.html new file mode 100644 index 0000000..14e05f3 --- /dev/null +++ b/templates/admin/invites.html @@ -0,0 +1,9 @@ +{% extends "settings/base.html" %} + +{% block subtitle %}Invites{% endblock %} + +{% block content %} +

+ Please use the Django Admin for now. +

+{% endblock %} diff --git a/templates/auth/signup.html b/templates/auth/signup.html index d519476..b1aaa50 100644 --- a/templates/auth/signup.html +++ b/templates/auth/signup.html @@ -7,6 +7,7 @@ {% csrf_token %}
Create An Account + {{ config.signup_text|safe|linebreaks }} {% for field in form %} {% include "forms/_field.html" %} {% endfor %} diff --git a/templates/emails/new_account.txt b/templates/emails/account_new.txt similarity index 100% rename from templates/emails/new_account.txt rename to templates/emails/account_new.txt diff --git a/templates/settings/_menu.html b/templates/settings/_menu.html index dd43912..21bc048 100644 --- a/templates/settings/_menu.html +++ b/templates/settings/_menu.html @@ -30,6 +30,9 @@ Identities + + Invites + Django Admin 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 similarity index 65% rename from users/views/admin.py rename to 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",