From 9dded19172c10b0b83aae0a386e49a81ff81b143 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sun, 15 Jan 2023 14:48:17 -0700 Subject: [PATCH] Add admin notification for new identities --- activities/models/fan_out.py | 8 +++++ activities/models/timeline_event.py | 14 ++++++++ activities/views/timelines.py | 1 + api/views/notifications.py | 1 + core/models/config.py | 1 + templates/activities/_event.html | 7 ++++ templates/activities/notifications.html | 7 ++++ users/services/__init__.py | 1 + users/services/identity.py | 43 ++++++++++++++++++++++++- users/services/user.py | 33 +++++++++++++++++++ users/views/admin/settings.py | 5 +++ users/views/auth.py | 11 ++----- users/views/identity.py | 13 +++----- 13 files changed, 128 insertions(+), 17 deletions(-) create mode 100644 users/services/user.py diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py index 53cb4fb..a53f81e 100644 --- a/activities/models/fan_out.py +++ b/activities/models/fan_out.py @@ -241,6 +241,13 @@ class FanOutStates(StateGraph): case (FanOut.Types.identity_deleted, True): pass + # Created identities make a timeline event + case (FanOut.Types.identity_created, True): + await sync_to_async(TimelineEvent.add_identity_created)( + identity=fan_out.identity, + new_identity=fan_out.subject_identity, + ) + case _: raise ValueError( f"Cannot fan out with type {fan_out.type} local={fan_out.identity.local}" @@ -262,6 +269,7 @@ class FanOut(StatorModel): undo_interaction = "undo_interaction" identity_edited = "identity_edited" identity_deleted = "identity_deleted" + identity_created = "identity_created" state = StateField(FanOutStates) diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py index c51dec1..8f25fd7 100644 --- a/activities/models/timeline_event.py +++ b/activities/models/timeline_event.py @@ -18,6 +18,7 @@ class TimelineEvent(models.Model): followed = "followed" boosted = "boosted" # Someone boosting one of our posts announcement = "announcement" # Server announcement + identity_created = "identity_created" # New identity created # The user this event is for identity = models.ForeignKey( @@ -103,6 +104,17 @@ class TimelineEvent(models.Model): defaults={"published": post.published or post.created}, )[0] + @classmethod + def add_identity_created(cls, identity, new_identity): + """ + Adds a new identity item + """ + return cls.objects.get_or_create( + identity=identity, + type=cls.Types.identity_created, + subject_identity=new_identity, + )[0] + @classmethod def add_post_interaction(cls, identity, interaction): """ @@ -179,6 +191,8 @@ class TimelineEvent(models.Model): ) elif self.type == self.Types.followed: result["type"] = "follow" + elif self.type == self.Types.identity_created: + result["type"] = "admin.sign_up" else: raise ValueError(f"Cannot convert {self.type} to notification JSON") return result diff --git a/activities/views/timelines.py b/activities/views/timelines.py index 0f3e7c1..c748b76 100644 --- a/activities/views/timelines.py +++ b/activities/views/timelines.py @@ -129,6 +129,7 @@ class Notifications(ListView): "boosted": TimelineEvent.Types.boosted, "mentioned": TimelineEvent.Types.mentioned, "liked": TimelineEvent.Types.liked, + "identity_created": TimelineEvent.Types.identity_created, } def get_queryset(self): diff --git a/api/views/notifications.py b/api/views/notifications.py index 9a6a06f..98bee04 100644 --- a/api/views/notifications.py +++ b/api/views/notifications.py @@ -25,6 +25,7 @@ def notifications( "reblog": TimelineEvent.Types.boosted, "mention": TimelineEvent.Types.mentioned, "follow": TimelineEvent.Types.followed, + "admin.sign_up": TimelineEvent.Types.identity_created, } requested_types = set(request.GET.getlist("types[]")) excluded_types = set(request.GET.getlist("exclude_types[]")) diff --git a/core/models/config.py b/core/models/config.py index 51dd2df..b4641ae 100644 --- a/core/models/config.py +++ b/core/models/config.py @@ -214,6 +214,7 @@ class Config(models.Model): signup_allowed: bool = True signup_text: str = "" signup_max_users: int = 0 + signup_email_admins: bool = True content_warning_text: str = "Content Warning" post_length: int = 500 diff --git a/templates/activities/_event.html b/templates/activities/_event.html index 365d336..64da7ae 100644 --- a/templates/activities/_event.html +++ b/templates/activities/_event.html @@ -34,6 +34,13 @@ {% if not event.collapsed %} {% include "activities/_post.html" with post=event.subject_post event=event %} {% endif %} +{% elif event.type == "identity_created" %} +
+ + {{ event.subject_identity.html_name_or_handle }} + was just created +
+ {% include "activities/_identity.html" with identity=event.subject_identity created=event.created %} {% else %} Unknown event type {{ event.type }} {% endif %} diff --git a/templates/activities/notifications.html b/templates/activities/notifications.html index d194d4c..d4116dd 100644 --- a/templates/activities/notifications.html +++ b/templates/activities/notifications.html @@ -28,6 +28,13 @@ {% else %} Mentions {% endif %} + {% if request.user.admin %} + {% if notification_options.identity_created %} + New Identities + {% else %} + New Identities + {% endif %} + {% endif %} {% for event in events %} diff --git a/users/services/__init__.py b/users/services/__init__.py index 36775a6..aec7009 100644 --- a/users/services/__init__.py +++ b/users/services/__init__.py @@ -1,2 +1,3 @@ from .announcement import AnnouncementService # noqa from .identity import IdentityService # noqa +from .user import UserService # noqa diff --git a/users/services/identity.py b/users/services/identity.py index 8629941..a6e44b1 100644 --- a/users/services/identity.py +++ b/users/services/identity.py @@ -1,8 +1,17 @@ from django.db import models from django.template.defaultfilters import linebreaks_filter +from activities.models import FanOut from core.html import strip_html -from users.models import Block, BlockStates, Follow, FollowStates, Identity +from users.models import ( + Block, + BlockStates, + Domain, + Follow, + FollowStates, + Identity, + User, +) class IdentityService: @@ -13,6 +22,38 @@ class IdentityService: def __init__(self, identity: Identity): self.identity = identity + @classmethod + def create( + cls, + user: User, + username: str, + domain: Domain, + name: str, + discoverable: bool = True, + ) -> Identity: + identity = Identity.objects.create( + actor_uri=f"https://{domain.uri_domain}/@{username}@{domain.domain}/", + username=username, + domain=domain, + name=name, + local=True, + discoverable=discoverable, + ) + identity.users.add(user) + identity.generate_keypair() + # Send fanouts to all admin identities + for admin_identity in cls.admin_identities(): + FanOut.objects.create( + type=FanOut.Types.identity_created, + identity=admin_identity, + subject_identity=identity, + ) + return identity + + @classmethod + def admin_identities(cls) -> models.QuerySet[Identity]: + return Identity.objects.filter(users__admin=True).distinct() + def following(self) -> models.QuerySet[Identity]: return ( Identity.objects.filter(inbound_follows__source=self.identity) diff --git a/users/services/user.py b/users/services/user.py new file mode 100644 index 0000000..30f34e6 --- /dev/null +++ b/users/services/user.py @@ -0,0 +1,33 @@ +from django.conf import settings +from django.db import models + +from users.models import PasswordReset, User + + +class UserService: + """ + High-level user handling methods + """ + + @classmethod + def admins(cls) -> models.QuerySet[User]: + return User.objects.filter(admin=True) + + @classmethod + def moderators(cls) -> models.QuerySet[User]: + return User.objects.filter(models.Q(moderator=True) | models.Q(admin=True)) + + @classmethod + def create(cls, email: str) -> User: + """ + Creates a new user + """ + # Make the new user + user = User.objects.create(email=email) + # Auto-promote the user to admin if that setting is set + if settings.AUTO_ADMIN_EMAIL and user.email == settings.AUTO_ADMIN_EMAIL: + user.admin = True + user.save() + # Send them a password reset email + PasswordReset.create_for_user(user) + return user diff --git a/users/views/admin/settings.py b/users/views/admin/settings.py index f6ed3a2..240d029 100644 --- a/users/views/admin/settings.py +++ b/users/views/admin/settings.py @@ -80,6 +80,10 @@ class BasicSettings(AdminSettingsPage): "title": "Maximum User Limit", "help_text": "Signups will be auto-disabled if your server grows to this many users.\nUse 0 for unlimited.", }, + "signup_email_admins": { + "title": "Email admins on signup", + "help_text": "Send an email to all admins whenever a new user signs up", + }, "restricted_usernames": { "title": "Restricted Usernames", "help_text": "Usernames that only admins can register for identities. One per line.", @@ -120,6 +124,7 @@ class BasicSettings(AdminSettingsPage): "Signups": [ "signup_allowed", "signup_max_users", + # "signup_email_admins", "signup_text", ], "Posts": [ diff --git a/users/views/auth.py b/users/views/auth.py index c76ccf2..945bf42 100644 --- a/users/views/auth.py +++ b/users/views/auth.py @@ -1,6 +1,5 @@ import markdown_it from django import forms -from django.conf import settings from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.password_validation import validate_password from django.contrib.auth.views import LoginView, LogoutView @@ -12,6 +11,7 @@ from django.views.generic import FormView from core.models import Config from users.models import Invite, PasswordReset, User +from users.services import UserService class Login(LoginView): @@ -99,13 +99,8 @@ class Signup(FormView): # Don't allow anything if there's no invite and no signup allowed if (not Config.system.signup_allowed or self.at_max_users) and not self.invite: return self.render_to_response(self.get_context_data()) - # Make the new user - user = User.objects.create(email=form.cleaned_data["email"]) - # Auto-promote the user to admin if that setting is set - if settings.AUTO_ADMIN_EMAIL and user.email == settings.AUTO_ADMIN_EMAIL: - user.admin = True - user.save() - PasswordReset.create_for_user(user) + # Make the user + user = UserService.create(email=form.cleaned_data["email"]) # Drop invite uses down if it has them if self.invite and self.invite.uses is not None: self.invite.uses -= 1 diff --git a/users/views/identity.py b/users/views/identity.py index 7d94836..2cce29f 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -381,15 +381,12 @@ class CreateIdentity(FormView): username = form.cleaned_data["username"] domain = form.cleaned_data["domain"] domain_instance = Domain.get_domain(domain) - new_identity = Identity.objects.create( - actor_uri=f"https://{domain_instance.uri_domain}/@{username}@{domain}/", + identity = IdentityService.create( + user=self.request.user, username=username, - domain_id=domain, + domain=domain_instance, name=form.cleaned_data["name"], - local=True, discoverable=form.cleaned_data["discoverable"], ) - new_identity.users.add(self.request.user) - new_identity.generate_keypair() - self.request.session["identity_id"] = new_identity.id - return redirect(new_identity.urls.view) + self.request.session["identity_id"] = identity.id + return redirect(identity.urls.view)