Add admin notification for new identities

This commit is contained in:
Andrew Godwin 2023-01-15 14:48:17 -07:00
parent 34b4a6cc10
commit 9dded19172
13 changed files with 128 additions and 17 deletions

View File

@ -241,6 +241,13 @@ class FanOutStates(StateGraph):
case (FanOut.Types.identity_deleted, True): case (FanOut.Types.identity_deleted, True):
pass 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 _: case _:
raise ValueError( raise ValueError(
f"Cannot fan out with type {fan_out.type} local={fan_out.identity.local}" 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" undo_interaction = "undo_interaction"
identity_edited = "identity_edited" identity_edited = "identity_edited"
identity_deleted = "identity_deleted" identity_deleted = "identity_deleted"
identity_created = "identity_created"
state = StateField(FanOutStates) state = StateField(FanOutStates)

View File

@ -18,6 +18,7 @@ class TimelineEvent(models.Model):
followed = "followed" followed = "followed"
boosted = "boosted" # Someone boosting one of our posts boosted = "boosted" # Someone boosting one of our posts
announcement = "announcement" # Server announcement announcement = "announcement" # Server announcement
identity_created = "identity_created" # New identity created
# The user this event is for # The user this event is for
identity = models.ForeignKey( identity = models.ForeignKey(
@ -103,6 +104,17 @@ class TimelineEvent(models.Model):
defaults={"published": post.published or post.created}, defaults={"published": post.published or post.created},
)[0] )[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 @classmethod
def add_post_interaction(cls, identity, interaction): def add_post_interaction(cls, identity, interaction):
""" """
@ -179,6 +191,8 @@ class TimelineEvent(models.Model):
) )
elif self.type == self.Types.followed: elif self.type == self.Types.followed:
result["type"] = "follow" result["type"] = "follow"
elif self.type == self.Types.identity_created:
result["type"] = "admin.sign_up"
else: else:
raise ValueError(f"Cannot convert {self.type} to notification JSON") raise ValueError(f"Cannot convert {self.type} to notification JSON")
return result return result

View File

@ -129,6 +129,7 @@ class Notifications(ListView):
"boosted": TimelineEvent.Types.boosted, "boosted": TimelineEvent.Types.boosted,
"mentioned": TimelineEvent.Types.mentioned, "mentioned": TimelineEvent.Types.mentioned,
"liked": TimelineEvent.Types.liked, "liked": TimelineEvent.Types.liked,
"identity_created": TimelineEvent.Types.identity_created,
} }
def get_queryset(self): def get_queryset(self):

View File

@ -25,6 +25,7 @@ def notifications(
"reblog": TimelineEvent.Types.boosted, "reblog": TimelineEvent.Types.boosted,
"mention": TimelineEvent.Types.mentioned, "mention": TimelineEvent.Types.mentioned,
"follow": TimelineEvent.Types.followed, "follow": TimelineEvent.Types.followed,
"admin.sign_up": TimelineEvent.Types.identity_created,
} }
requested_types = set(request.GET.getlist("types[]")) requested_types = set(request.GET.getlist("types[]"))
excluded_types = set(request.GET.getlist("exclude_types[]")) excluded_types = set(request.GET.getlist("exclude_types[]"))

View File

@ -214,6 +214,7 @@ class Config(models.Model):
signup_allowed: bool = True signup_allowed: bool = True
signup_text: str = "" signup_text: str = ""
signup_max_users: int = 0 signup_max_users: int = 0
signup_email_admins: bool = True
content_warning_text: str = "Content Warning" content_warning_text: str = "Content Warning"
post_length: int = 500 post_length: int = 500

View File

@ -34,6 +34,13 @@
{% if not event.collapsed %} {% if not event.collapsed %}
{% include "activities/_post.html" with post=event.subject_post event=event %} {% include "activities/_post.html" with post=event.subject_post event=event %}
{% endif %} {% endif %}
{% elif event.type == "identity_created" %}
<div class="follow-banner">
<a href="{{ event.subject_identity.urls.view }}">
{{ event.subject_identity.html_name_or_handle }}
</a> was just created
</div>
{% include "activities/_identity.html" with identity=event.subject_identity created=event.created %}
{% else %} {% else %}
Unknown event type {{ event.type }} Unknown event type {{ event.type }}
{% endif %} {% endif %}

View File

@ -28,6 +28,13 @@
{% else %} {% else %}
<a href=".?mentioned=true"><i class="fa-solid fa-xmark"></i> Mentions</a> <a href=".?mentioned=true"><i class="fa-solid fa-xmark"></i> Mentions</a>
{% endif %} {% endif %}
{% if request.user.admin %}
{% if notification_options.identity_created %}
<a href=".?identity_created=false" class="selected"><i class="fa-solid fa-check"></i> New Identities</a>
{% else %}
<a href=".?identity_created=true"><i class="fa-solid fa-xmark"></i> New Identities</a>
{% endif %}
{% endif %}
</div> </div>
{% for event in events %} {% for event in events %}

View File

@ -1,2 +1,3 @@
from .announcement import AnnouncementService # noqa from .announcement import AnnouncementService # noqa
from .identity import IdentityService # noqa from .identity import IdentityService # noqa
from .user import UserService # noqa

View File

@ -1,8 +1,17 @@
from django.db import models from django.db import models
from django.template.defaultfilters import linebreaks_filter from django.template.defaultfilters import linebreaks_filter
from activities.models import FanOut
from core.html import strip_html 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: class IdentityService:
@ -13,6 +22,38 @@ class IdentityService:
def __init__(self, identity: Identity): def __init__(self, identity: Identity):
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]: def following(self) -> models.QuerySet[Identity]:
return ( return (
Identity.objects.filter(inbound_follows__source=self.identity) Identity.objects.filter(inbound_follows__source=self.identity)

33
users/services/user.py Normal file
View File

@ -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

View File

@ -80,6 +80,10 @@ class BasicSettings(AdminSettingsPage):
"title": "Maximum User Limit", "title": "Maximum User Limit",
"help_text": "Signups will be auto-disabled if your server grows to this many users.\nUse 0 for unlimited.", "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": { "restricted_usernames": {
"title": "Restricted Usernames", "title": "Restricted Usernames",
"help_text": "Usernames that only admins can register for identities. One per line.", "help_text": "Usernames that only admins can register for identities. One per line.",
@ -120,6 +124,7 @@ class BasicSettings(AdminSettingsPage):
"Signups": [ "Signups": [
"signup_allowed", "signup_allowed",
"signup_max_users", "signup_max_users",
# "signup_email_admins",
"signup_text", "signup_text",
], ],
"Posts": [ "Posts": [

View File

@ -1,6 +1,5 @@
import markdown_it import markdown_it
from django import forms from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.password_validation import validate_password from django.contrib.auth.password_validation import validate_password
from django.contrib.auth.views import LoginView, LogoutView from django.contrib.auth.views import LoginView, LogoutView
@ -12,6 +11,7 @@ from django.views.generic import FormView
from core.models import Config from core.models import Config
from users.models import Invite, PasswordReset, User from users.models import Invite, PasswordReset, User
from users.services import UserService
class Login(LoginView): class Login(LoginView):
@ -99,13 +99,8 @@ class Signup(FormView):
# Don't allow anything if there's no invite and no signup allowed # 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: if (not Config.system.signup_allowed or self.at_max_users) and not self.invite:
return self.render_to_response(self.get_context_data()) return self.render_to_response(self.get_context_data())
# Make the new user # Make the user
user = User.objects.create(email=form.cleaned_data["email"]) user = UserService.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)
# Drop invite uses down if it has them # Drop invite uses down if it has them
if self.invite and self.invite.uses is not None: if self.invite and self.invite.uses is not None:
self.invite.uses -= 1 self.invite.uses -= 1

View File

@ -381,15 +381,12 @@ class CreateIdentity(FormView):
username = form.cleaned_data["username"] username = form.cleaned_data["username"]
domain = form.cleaned_data["domain"] domain = form.cleaned_data["domain"]
domain_instance = Domain.get_domain(domain) domain_instance = Domain.get_domain(domain)
new_identity = Identity.objects.create( identity = IdentityService.create(
actor_uri=f"https://{domain_instance.uri_domain}/@{username}@{domain}/", user=self.request.user,
username=username, username=username,
domain_id=domain, domain=domain_instance,
name=form.cleaned_data["name"], name=form.cleaned_data["name"],
local=True,
discoverable=form.cleaned_data["discoverable"], discoverable=form.cleaned_data["discoverable"],
) )
new_identity.users.add(self.request.user) self.request.session["identity_id"] = identity.id
new_identity.generate_keypair() return redirect(identity.urls.view)
self.request.session["identity_id"] = new_identity.id
return redirect(new_identity.urls.view)