From 5b34ea46c3f458a174c5443714ade43c21defdac Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Wed, 16 Nov 2022 21:42:25 -0700 Subject: Call it admin rather than system settings --- activities/migrations/0006_alter_post_hashtags.py | 18 ++ activities/models/post.py | 52 ++-- activities/models/post_interaction.py | 48 ++-- activities/views/posts.py | 1 - core/context.py | 1 + static/css/style.css | 4 + takahe/urls.py | 61 ++++- templates/admin/_menu.html | 6 + templates/admin/domain_create.html | 39 +++ templates/admin/domain_delete.html | 33 +++ templates/admin/domain_edit.html | 19 ++ templates/admin/domains.html | 28 +++ templates/admin/identities.html | 14 ++ templates/admin/settings.html | 18 ++ templates/admin/users.html | 14 ++ templates/base.html | 4 +- templates/settings/_menu.html | 5 + templates/settings/_settings_identity_menu.html | 5 - templates/settings/_settings_system_menu.html | 5 - templates/settings/settings.html | 18 ++ templates/settings/settings_identity.html | 7 - templates/settings/settings_system.html | 18 -- .../settings/settings_system_domain_create.html | 39 --- .../settings/settings_system_domain_delete.html | 33 --- .../settings/settings_system_domain_edit.html | 19 -- templates/settings/settings_system_domains.html | 28 --- users/admin.py | 3 +- users/middleware.py | 5 +- .../0003_user_last_seen_alter_identity_domain.py | 34 +++ users/models/domain.py | 8 +- users/models/user.py | 1 + users/views/admin.py | 265 +++++++++++++++++++++ users/views/settings.py | 39 +++ users/views/settings_identity.py | 41 ---- users/views/settings_system.py | 243 ------------------- 35 files changed, 669 insertions(+), 507 deletions(-) create mode 100644 activities/migrations/0006_alter_post_hashtags.py create mode 100644 templates/admin/_menu.html create mode 100644 templates/admin/domain_create.html create mode 100644 templates/admin/domain_delete.html create mode 100644 templates/admin/domain_edit.html create mode 100644 templates/admin/domains.html create mode 100644 templates/admin/identities.html create mode 100644 templates/admin/settings.html create mode 100644 templates/admin/users.html create mode 100644 templates/settings/_menu.html delete mode 100644 templates/settings/_settings_identity_menu.html delete mode 100644 templates/settings/_settings_system_menu.html create mode 100644 templates/settings/settings.html delete mode 100644 templates/settings/settings_identity.html delete mode 100644 templates/settings/settings_system.html delete mode 100644 templates/settings/settings_system_domain_create.html delete mode 100644 templates/settings/settings_system_domain_delete.html delete mode 100644 templates/settings/settings_system_domain_edit.html delete mode 100644 templates/settings/settings_system_domains.html create mode 100644 users/migrations/0003_user_last_seen_alter_identity_domain.py create mode 100644 users/views/admin.py create mode 100644 users/views/settings.py delete mode 100644 users/views/settings_identity.py delete mode 100644 users/views/settings_system.py diff --git a/activities/migrations/0006_alter_post_hashtags.py b/activities/migrations/0006_alter_post_hashtags.py new file mode 100644 index 0000000..b6149ea --- /dev/null +++ b/activities/migrations/0006_alter_post_hashtags.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.3 on 2022-11-17 04:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("activities", "0005_post_hashtags_alter_fanout_type_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="post", + name="hashtags", + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/activities/models/post.py b/activities/models/post.py index 4896e58..3847b63 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -117,7 +117,7 @@ class Post(StatorModel): ) # Hashtags in the post - hashtags = models.JSONField(default=[]) + hashtags = models.JSONField(blank=True, null=True) # When the post was originally created (as opposed to when we received it) published = models.DateTimeField(default=timezone.now) @@ -296,36 +296,38 @@ class Post(StatorModel): """ Handles an incoming create request """ - # Ensure the Create actor is the Post's attributedTo - if data["actor"] != data["object"]["attributedTo"]: - raise ValueError("Create actor does not match its Post object", data) - # Create it - post = cls.by_ap(data["object"], create=True, update=True) - # Make timeline events for followers - for follow in Follow.objects.filter(target=post.author, source__local=True): - TimelineEvent.add_post(follow.source, post) - # Make timeline events for mentions if they're local - for mention in post.mentions.all(): - if mention.local: - TimelineEvent.add_mentioned(mention, post) - # Force it into fanned_out as it's not ours - post.transition_perform(PostStates.fanned_out) + with transaction.atomic(): + # Ensure the Create actor is the Post's attributedTo + if data["actor"] != data["object"]["attributedTo"]: + raise ValueError("Create actor does not match its Post object", data) + # Create it + post = cls.by_ap(data["object"], create=True, update=True) + # Make timeline events for followers + for follow in Follow.objects.filter(target=post.author, source__local=True): + TimelineEvent.add_post(follow.source, post) + # Make timeline events for mentions if they're local + for mention in post.mentions.all(): + if mention.local: + TimelineEvent.add_mentioned(mention, post) + # Force it into fanned_out as it's not ours + post.transition_perform(PostStates.fanned_out) @classmethod def handle_delete_ap(cls, data): """ Handles an incoming create request """ - # Find our post by ID if we have one - try: - post = cls.by_object_uri(data["object"]["id"]) - except cls.DoesNotExist: - # It's already been deleted - return - # Ensure the actor on the request authored the post - if not post.author.actor_uri == data["actor"]: - raise ValueError("Actor on delete does not match object") - post.delete() + with transaction.atomic(): + # Find our post by ID if we have one + try: + post = cls.by_object_uri(data["object"]["id"]) + except cls.DoesNotExist: + # It's already been deleted + return + # Ensure the actor on the request authored the post + if not post.author.actor_uri == data["actor"]: + raise ValueError("Actor on delete does not match object") + post.delete() def debug_fetch(self): """ diff --git a/activities/models/post_interaction.py b/activities/models/post_interaction.py index 4f1eb03..e9248e8 100644 --- a/activities/models/post_interaction.py +++ b/activities/models/post_interaction.py @@ -1,6 +1,6 @@ from typing import Dict -from django.db import models +from django.db import models, transaction from django.utils import timezone from activities.models.fan_out import FanOut @@ -272,31 +272,33 @@ class PostInteraction(StatorModel): """ Handles an incoming announce/like """ - # Create it - interaction = cls.by_ap(data, create=True) - # Boosts (announces) go to everyone who follows locally - if interaction.type == cls.Types.boost: - for follow in Follow.objects.filter( - target=interaction.identity, source__local=True - ): - TimelineEvent.add_post_interaction(follow.source, interaction) - # Likes go to just the author of the post - elif interaction.type == cls.Types.like: - TimelineEvent.add_post_interaction(interaction.post.author, interaction) - # Force it into fanned_out as it's not ours - interaction.transition_perform(PostInteractionStates.fanned_out) + with transaction.atomic(): + # Create it + interaction = cls.by_ap(data, create=True) + # Boosts (announces) go to everyone who follows locally + if interaction.type == cls.Types.boost: + for follow in Follow.objects.filter( + target=interaction.identity, source__local=True + ): + TimelineEvent.add_post_interaction(follow.source, interaction) + # Likes go to just the author of the post + elif interaction.type == cls.Types.like: + TimelineEvent.add_post_interaction(interaction.post.author, interaction) + # Force it into fanned_out as it's not ours + interaction.transition_perform(PostInteractionStates.fanned_out) @classmethod def handle_undo_ap(cls, data): """ Handles an incoming undo for a announce/like """ - # Find it - interaction = cls.by_ap(data["object"]) - # Verify the actor matches - if data["actor"] != interaction.identity.actor_uri: - raise ValueError("Actor mismatch on interaction undo") - # Delete all events that reference it - interaction.timeline_events.all().delete() - # Force it into undone_fanned_out as it's not ours - interaction.transition_perform(PostInteractionStates.undone_fanned_out) + with transaction.atomic(): + # Find it + interaction = cls.by_ap(data["object"]) + # Verify the actor matches + if data["actor"] != interaction.identity.actor_uri: + raise ValueError("Actor mismatch on interaction undo") + # Delete all events that reference it + interaction.timeline_events.all().delete() + # Force it into undone_fanned_out as it's not ours + interaction.transition_perform(PostInteractionStates.undone_fanned_out) diff --git a/activities/views/posts.py b/activities/views/posts.py index d0ad813..3ee35cc 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -108,7 +108,6 @@ class Boost(View): class Compose(FormView): template_name = "activities/compose.html" - extra_context = {"top_section": "compose"} class form_class(forms.Form): text = forms.CharField( diff --git a/core/context.py b/core/context.py index 4346cbb..a4aabf5 100644 --- a/core/context.py +++ b/core/context.py @@ -7,4 +7,5 @@ def config_context(request): "config_identity": ( Config.load_identity(request.identity) if request.identity else None ), + "top_section": request.path.strip("/").split("/")[0], } diff --git a/static/css/style.css b/static/css/style.css index 9c45eb3..f07d78c 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -58,6 +58,10 @@ a { text-decoration: none; } +p a { + text-decoration: underline; +} + @media (prefers-reduced-motion: reduce) { html:focus-within { scroll-behavior: auto; diff --git a/takahe/urls.py b/takahe/urls.py index 0643440..638dabd 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -1,10 +1,10 @@ -from django.contrib import admin +from django.contrib import admin as djadmin from django.urls import path from activities.views import posts, timelines from core import views as core from stator import views as stator -from users.views import activitypub, auth, identity, settings_identity, settings_system +from users.views import activitypub, admin, auth, identity, settings urlpatterns = [ path("", core.homepage), @@ -13,16 +13,53 @@ urlpatterns = [ path("notifications/", timelines.Notifications.as_view()), path("local/", timelines.Local.as_view()), path("federated/", timelines.Federated.as_view()), - path("settings/", settings_identity.IdentitySettingsRoot.as_view()), - path("settings/interface/", settings_identity.InterfacePage.as_view()), - path("settings/system/", settings_system.SystemSettingsRoot.as_view()), - path("settings/system/basic/", settings_system.BasicPage.as_view()), - path("settings/system/domains/", settings_system.DomainsPage.as_view()), - path("settings/system/domains/create/", settings_system.DomainCreatePage.as_view()), - path("settings/system/domains//", settings_system.DomainEditPage.as_view()), path( - "settings/system/domains//delete/", - settings_system.DomainDeletePage.as_view(), + "settings/", + settings.SettingsRoot.as_view(), + name="settings", + ), + path( + "settings/interface/", + settings.InterfacePage.as_view(), + name="settings_interface", + ), + path( + "admin/", + admin.AdminRoot.as_view(), + name="admin", + ), + path( + "admin/basic/", + admin.BasicPage.as_view(), + name="admin_basic", + ), + path( + "admin/domains/", + admin.DomainsPage.as_view(), + name="admin_domains", + ), + path( + "admin/domains/create/", + admin.DomainCreatePage.as_view(), + name="admin_domains_create", + ), + path( + "admin/domains//", + admin.DomainEditPage.as_view(), + ), + path( + "admin/domains//delete/", + admin.DomainDeletePage.as_view(), + ), + path( + "admin/users/", + admin.UsersPage.as_view(), + name="admin_users", + ), + path( + "admin/identities/", + admin.IdentitiesPage.as_view(), + name="admin_identities", ), # Identity views path("@/", identity.ViewIdentity.as_view()), @@ -49,5 +86,5 @@ urlpatterns = [ # Task runner path(".stator/runner/", stator.RequestRunner.as_view()), # Django admin - path("djadmin/", admin.site.urls), + path("djadmin/", djadmin.site.urls), ] diff --git a/templates/admin/_menu.html b/templates/admin/_menu.html new file mode 100644 index 0000000..8f0bc60 --- /dev/null +++ b/templates/admin/_menu.html @@ -0,0 +1,6 @@ + diff --git a/templates/admin/domain_create.html b/templates/admin/domain_create.html new file mode 100644 index 0000000..09dbc23 --- /dev/null +++ b/templates/admin/domain_create.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}Add Domain - Admin{% endblock %} + +{% block content %} + {% block menu %} + {% include "admin/_menu.html" %} + {% endblock %} +
+

Add A Domain

+

+ Use this form to add a domain that your users can create identities + on. +

+

+ Takahē supports multiple domains per server, but note that when + identities are created they are fixed to their chosen domain, + and you will not be able to delete a domain with identities on it. +

+

+ If you will be serving Takahē on the domain you choose, you can leave + the "service domain" field blank. If you would like to let users create + accounts on a domain serving something else, you must pick a unique + "service domain" that pairs up to your chosen domain name, make sure + Takahē is served on that, and add redirects + for /.well-known/webfinger, /.well-known/host-meta + and /.well-known/nodeinfo from the main domain to the + service domain. +

+ {% csrf_token %} + {% for field in form %} + {% include "forms/_field.html" %} + {% endfor %} +
+ Delete + +
+
+{% endblock %} diff --git a/templates/admin/domain_delete.html b/templates/admin/domain_delete.html new file mode 100644 index 0000000..d47a673 --- /dev/null +++ b/templates/admin/domain_delete.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title %}Delete {{ domain.domain }} - Admin{% endblock %} + +{% block content %} + {% block menu %} + {% include "admin/_menu.html" %} + {% endblock %} + +
+ {% csrf_token %} + +

Deleting {{ domain.domain }}

+ + {% if num_identities %} +

+ You cannot delete this domain as it has {{ num_identities }} + identit{{ num_identities|pluralize:"y,ies" }} registered on it. +

+

+ You will need to manually remove all identities from this domain in + order to delete it. +

+ {% else %} +

Please confirm deletion of this domain - there are no identities registed on it.

+
+ Cancel + +
+ {% endif %} +
+ +{% endblock %} diff --git a/templates/admin/domain_edit.html b/templates/admin/domain_edit.html new file mode 100644 index 0000000..64e195c --- /dev/null +++ b/templates/admin/domain_edit.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block title %}{{ domain.domain }} - Admin{% endblock %} + +{% block content %} + {% block menu %} + {% include "admin/_menu.html" %} + {% endblock %} +
+ {% csrf_token %} + {% for field in form %} + {% include "forms/_field.html" %} + {% endfor %} +
+ Delete + +
+
+{% endblock %} diff --git a/templates/admin/domains.html b/templates/admin/domains.html new file mode 100644 index 0000000..b7925da --- /dev/null +++ b/templates/admin/domains.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block title %}{{ section.title }} - Admin{% endblock %} + +{% block content %} + {% block menu %} + {% include "admin/_menu.html" %} + {% endblock %} +
+ {% for domain in domains %} + + + + {{ domain.domain }} + + {% if domain.public %}Public{% else %}Private{% endif %} + {% if domain.service_domain %}({{ domain.service_domain }}){% endif %} + + + + {% empty %} +

You have no domains set up.

+ {% endfor %} + + Add a domain + +
+{% endblock %} diff --git a/templates/admin/identities.html b/templates/admin/identities.html new file mode 100644 index 0000000..86e70db --- /dev/null +++ b/templates/admin/identities.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}Identities - Admin{% endblock %} + +{% block content %} + {% block menu %} + {% include "admin/_menu.html" %} + {% endblock %} +
+

+ Please use the Django Admin for now. +

+
+{% endblock %} diff --git a/templates/admin/settings.html b/templates/admin/settings.html new file mode 100644 index 0000000..e031347 --- /dev/null +++ b/templates/admin/settings.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block title %}{{ section.title }} - Admin{% endblock %} + +{% block content %} + {% block menu %} + {% include "admin/_menu.html" %} + {% endblock %} +
+ {% csrf_token %} + {% for field in form %} + {% include "forms/_field.html" %} + {% endfor %} +
+ +
+
+{% endblock %} diff --git a/templates/admin/users.html b/templates/admin/users.html new file mode 100644 index 0000000..0b75b88 --- /dev/null +++ b/templates/admin/users.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}Users - Admin{% endblock %} + +{% block content %} + {% block menu %} + {% include "admin/_menu.html" %} + {% endblock %} +
+

+ Please use the Django Admin for now. +

+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html index 402dcd3..bce5e1b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -31,11 +31,11 @@ Compose - + Settings {% if request.user.admin %} - + Admin {% endif %} diff --git a/templates/settings/_menu.html b/templates/settings/_menu.html new file mode 100644 index 0000000..bdae143 --- /dev/null +++ b/templates/settings/_menu.html @@ -0,0 +1,5 @@ + diff --git a/templates/settings/_settings_identity_menu.html b/templates/settings/_settings_identity_menu.html deleted file mode 100644 index bdae143..0000000 --- a/templates/settings/_settings_identity_menu.html +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/templates/settings/_settings_system_menu.html b/templates/settings/_settings_system_menu.html deleted file mode 100644 index 9206045..0000000 --- a/templates/settings/_settings_system_menu.html +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/templates/settings/settings.html b/templates/settings/settings.html new file mode 100644 index 0000000..016eebb --- /dev/null +++ b/templates/settings/settings.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block title %}{{ section.title }} - Settings{% endblock %} + +{% block content %} + {% block menu %} + {% include "settings/_menu.html" %} + {% endblock %} +
+ {% csrf_token %} + {% for field in form %} + {% include "forms/_field.html" %} + {% endfor %} +
+ +
+
+{% endblock %} diff --git a/templates/settings/settings_identity.html b/templates/settings/settings_identity.html deleted file mode 100644 index cdbf197..0000000 --- a/templates/settings/settings_identity.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "settings/settings_system.html" %} - -{% block title %}{{ section.title }} - Settings{% endblock %} - -{% block menu %} - {% include "settings/_settings_identity_menu.html" %} -{% endblock %} diff --git a/templates/settings/settings_system.html b/templates/settings/settings_system.html deleted file mode 100644 index c10964f..0000000 --- a/templates/settings/settings_system.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ section.title }} - System Settings{% endblock %} - -{% block content %} - {% block menu %} - {% include "settings/_settings_system_menu.html" %} - {% endblock %} -
- {% csrf_token %} - {% for field in form %} - {% include "forms/_field.html" %} - {% endfor %} -
- -
-
-{% endblock %} diff --git a/templates/settings/settings_system_domain_create.html b/templates/settings/settings_system_domain_create.html deleted file mode 100644 index 54d3640..0000000 --- a/templates/settings/settings_system_domain_create.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Add Domain - System Settings{% endblock %} - -{% block content %} - {% block menu %} - {% include "settings/_settings_system_menu.html" %} - {% endblock %} -
-

Add A Domain

-

- Use this form to add a domain that your users can create identities - on. -

-

- Takahē supports multiple domains per server, but note that when - identities are created they are fixed to their chosen domain, - and you will not be able to delete a domain with identities on it. -

-

- If you will be serving Takahē on the domain you choose, you can leave - the "service domain" field blank. If you would like to let users create - accounts on a domain serving something else, you must pick a unique - "service domain" that pairs up to your chosen domain name, make sure - Takahē is served on that, and add redirects - for /.well-known/webfinger, /.well-known/host-meta - and /.well-known/nodeinfo from the main domain to the - service domain. -

- {% csrf_token %} - {% for field in form %} - {% include "forms/_field.html" %} - {% endfor %} -
- Delete - -
-
-{% endblock %} diff --git a/templates/settings/settings_system_domain_delete.html b/templates/settings/settings_system_domain_delete.html deleted file mode 100644 index 220bbb9..0000000 --- a/templates/settings/settings_system_domain_delete.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Delete {{ domain.domain }} - System Settings{% endblock %} - -{% block content %} - {% block menu %} - {% include "settings/_settings_system_menu.html" %} - {% endblock %} - -
- {% csrf_token %} - -

Deleting {{ domain.domain }}

- - {% if num_identities %} -

- You cannot delete this domain as it has {{ num_identities }} - identit{{ num_identities|pluralize:"y,ies" }} registered on it. -

-

- You will need to manually remove all identities from this domain in - order to delete it. -

- {% else %} -

Please confirm deletion of this domain - there are no identities registed on it.

-
- Cancel - -
- {% endif %} -
- -{% endblock %} diff --git a/templates/settings/settings_system_domain_edit.html b/templates/settings/settings_system_domain_edit.html deleted file mode 100644 index c05d5d5..0000000 --- a/templates/settings/settings_system_domain_edit.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ domain.domain }} - System Settings{% endblock %} - -{% block content %} - {% block menu %} - {% include "settings/_settings_system_menu.html" %} - {% endblock %} -
- {% csrf_token %} - {% for field in form %} - {% include "forms/_field.html" %} - {% endfor %} -
- Delete - -
-
-{% endblock %} diff --git a/templates/settings/settings_system_domains.html b/templates/settings/settings_system_domains.html deleted file mode 100644 index dccde65..0000000 --- a/templates/settings/settings_system_domains.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ section.title }} - System Settings{% endblock %} - -{% block content %} - {% block menu %} - {% include "settings/_settings_system_menu.html" %} - {% endblock %} -
- {% for domain in domains %} - - - - {{ domain.domain }} - - {% if domain.public %}Public{% else %}Private{% endif %} - {% if domain.service_domain %}({{ domain.service_domain }}){% endif %} - - - - {% empty %} -

You have no domains set up.

- {% endfor %} - - Add a domain - -
-{% endblock %} diff --git a/users/admin.py b/users/admin.py index 5364880..7c3750d 100644 --- a/users/admin.py +++ b/users/admin.py @@ -10,7 +10,7 @@ class DomainAdmin(admin.ModelAdmin): @admin.register(User) class UserAdmin(admin.ModelAdmin): - pass + list_display = ["email", "created", "last_seen", "admin", "moderator", "banned"] @admin.register(UserEvent) @@ -21,6 +21,7 @@ class UserEventAdmin(admin.ModelAdmin): @admin.register(Identity) class IdentityAdmin(admin.ModelAdmin): list_display = ["id", "handle", "actor_uri", "state", "local"] + list_filter = ["local"] raw_id_fields = ["users"] actions = ["force_update"] readonly_fields = ["actor_json"] diff --git a/users/middleware.py b/users/middleware.py index aa22178..e6d4036 100644 --- a/users/middleware.py +++ b/users/middleware.py @@ -1,4 +1,6 @@ -from users.models import Identity +from django.utils import timezone + +from users.models import Identity, User class IdentityMiddleware: @@ -17,6 +19,7 @@ class IdentityMiddleware: else: try: request.identity = Identity.objects.get(id=identity_id) + User.objects.filter(pk=request.user.pk).update(last_seen=timezone.now()) except Identity.DoesNotExist: request.identity = None diff --git a/users/migrations/0003_user_last_seen_alter_identity_domain.py b/users/migrations/0003_user_last_seen_alter_identity_domain.py new file mode 100644 index 0000000..b6c49d1 --- /dev/null +++ b/users/migrations/0003_user_last_seen_alter_identity_domain.py @@ -0,0 +1,34 @@ +# Generated by Django 4.1.3 on 2022-11-17 04:18 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0002_identity_public_key_id"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="last_seen", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="identity", + name="domain", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="identities", + to="users.domain", + ), + ), + ] diff --git a/users/models/domain.py b/users/models/domain.py index af0bbab..4743503 100644 --- a/users/models/domain.py +++ b/users/models/domain.py @@ -49,10 +49,10 @@ class Domain(models.Model): updated = models.DateTimeField(auto_now=True) class urls(urlman.Urls): - root = "/settings/system/domains/" - create = "/settings/system/domains/create/" - edit = "/settings/system/domains/{self.domain}/" - delete = "/settings/system/domains/{self.domain}/delete/" + root = "/admin/domains/" + create = "/admin/domains/create/" + edit = "/admin/domains/{self.domain}/" + delete = "/admin/domains/{self.domain}/delete/" @classmethod def get_remote_domain(cls, domain: str) -> "Domain": diff --git a/users/models/user.py b/users/models/user.py index 6435bf5..08a703e 100644 --- a/users/models/user.py +++ b/users/models/user.py @@ -38,6 +38,7 @@ class User(AbstractBaseUser): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + last_seen = models.DateTimeField(auto_now_add=True) USERNAME_FIELD = "email" EMAIL_FIELD = "email" diff --git a/users/views/admin.py b/users/views/admin.py new file mode 100644 index 0000000..c1210f1 --- /dev/null +++ b/users/views/admin.py @@ -0,0 +1,265 @@ +import re +from functools import partial +from typing import ClassVar, Dict + +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 core.models import Config +from users.decorators import admin_required +from users.models import Domain, Identity, User + + +@method_decorator(admin_required, name="dispatch") +class AdminRoot(RedirectView): + pattern_name = "admin_basic" + + +@method_decorator(admin_required, name="dispatch") +class AdminSettingsPage(FormView): + """ + 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! + """ + + template_name = "admin/settings.html" + options_class = Config.SystemOptions + section: ClassVar[str] + options: Dict[str, Dict[str, str]] + + def get_form_class(self): + # Create the fields dict from the config object + fields = {} + for key, details in self.options.items(): + config_field = self.options_class.__fields__[key] + if config_field.type_ is bool: + form_field = partial( + forms.BooleanField, + widget=forms.Select( + choices=[(True, "Enabled"), (False, "Disabled")] + ), + ) + elif config_field.type_ is str: + form_field = forms.CharField + else: + raise ValueError(f"Cannot render settings type {config_field.type_}") + fields[key] = form_field( + label=details["title"], + help_text=details.get("help_text", ""), + required=details.get("required", False), + ) + # Create a form class dynamically (yeah, right?) and return that + return type("SettingsForm", (forms.Form,), fields) + + def load_config(self): + return Config.load_system() + + def save_config(self, key, value): + Config.set_system(key, value) + + def get_initial(self): + config = self.load_config() + initial = {} + for key in self.options.keys(): + initial[key] = getattr(config, key) + return initial + + def get_context_data(self): + context = super().get_context_data() + context["section"] = self.section + return context + + def form_valid(self, form): + # Save each key + for field in form: + self.save_config( + field.name, + form.cleaned_data[field.name], + ) + return redirect(".") + + +class BasicPage(AdminSettingsPage): + + section = "basic" + + options = { + "site_name": { + "title": "Site Name", + "help_text": "Shown in the top-left of the page, and titles", + }, + "highlight_color": { + "title": "Highlight Color", + "help_text": "Used for logo background and other highlights", + }, + } + + +@method_decorator(admin_required, name="dispatch") +class DomainsPage(TemplateView): + + template_name = "admin/domains.html" + + def get_context_data(self): + return { + "domains": Domain.objects.filter(local=True).order_by("domain"), + "section": "domains", + } + + +@method_decorator(admin_required, name="dispatch") +class DomainCreatePage(FormView): + + template_name = "admin/domain_create.html" + extra_context = {"section": "domains"} + + class form_class(forms.Form): + domain = forms.CharField( + help_text="The domain displayed as part of a user's identity.\nCannot be changed after the domain has been created.", + ) + service_domain = forms.CharField( + help_text="Optional - a domain that serves Takahē if it is not running on the main domain.\nCannot be changed after the domain has been created.", + required=False, + ) + public = forms.BooleanField( + help_text="If any user on this server can create identities here", + widget=forms.Select(choices=[(True, "Public"), (False, "Private")]), + required=False, + ) + + domain_regex = re.compile( + r"^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9\-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$" + ) + + def clean_domain(self): + if not self.domain_regex.match(self.cleaned_data["domain"]): + raise forms.ValidationError("This does not look like a domain name") + if Domain.objects.filter( + models.Q(domain=self.cleaned_data["domain"]) + | models.Q(service_domain=self.cleaned_data["domain"]) + ): + raise forms.ValidationError("This domain name is already in use") + return self.cleaned_data["domain"] + + def clean_service_domain(self): + if not self.cleaned_data["service_domain"]: + return None + if not self.domain_regex.match(self.cleaned_data["service_domain"]): + raise forms.ValidationError("This does not look like a domain name") + if Domain.objects.filter( + models.Q(domain=self.cleaned_data["service_domain"]) + | models.Q(service_domain=self.cleaned_data["service_domain"]) + ): + raise forms.ValidationError("This domain name is already in use") + if self.cleaned_data.get("domain") == self.cleaned_data["service_domain"]: + raise forms.ValidationError( + "You cannot have the domain and service domain be the same (did you mean to leave service domain blank?)" + ) + return self.cleaned_data["service_domain"] + + def form_valid(self, form): + Domain.objects.create( + domain=form.cleaned_data["domain"], + service_domain=form.cleaned_data["service_domain"] or None, + public=form.cleaned_data["public"], + local=True, + ) + return redirect(Domain.urls.root) + + +@method_decorator(admin_required, name="dispatch") +class DomainEditPage(FormView): + + template_name = "admin/domain_edit.html" + extra_context = {"section": "domains"} + + class form_class(forms.Form): + domain = forms.CharField( + help_text="The domain displayed as part of a user's identity.\nCannot be changed after the domain has been created.", + disabled=True, + ) + service_domain = forms.CharField( + help_text="Optional - a domain that serves Takahē if it is not running on the main domain.\nCannot be changed after the domain has been created.", + disabled=True, + required=False, + ) + public = forms.BooleanField( + help_text="If any user on this server can create identities here", + widget=forms.Select(choices=[(True, "Public"), (False, "Private")]), + required=False, + ) + + def dispatch(self, request, domain): + self.domain = get_object_or_404( + Domain.objects.filter(local=True), domain=domain + ) + return super().dispatch(request) + + def get_context_data(self): + context = super().get_context_data() + context["domain"] = self.domain + return context + + def form_valid(self, form): + self.domain.public = form.cleaned_data["public"] + self.domain.save() + return redirect(Domain.urls.root) + + def get_initial(self): + return { + "domain": self.domain.domain, + "service_domain": self.domain.service_domain, + "public": self.domain.public, + } + + +@method_decorator(admin_required, name="dispatch") +class DomainDeletePage(TemplateView): + + template_name = "admin/domain_delete.html" + + def dispatch(self, request, domain): + self.domain = get_object_or_404( + Domain.objects.filter(public=True), domain=domain + ) + return super().dispatch(request) + + def get_context_data(self): + return { + "domain": self.domain, + "num_identities": self.domain.identities.count(), + "section": "domains", + } + + def post(self, request): + if self.domain.identities.exists(): + 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/settings.py b/users/views/settings.py new file mode 100644 index 0000000..877ad01 --- /dev/null +++ b/users/views/settings.py @@ -0,0 +1,39 @@ +from django.utils.decorators import method_decorator +from django.views.generic import RedirectView + +from core.models import Config +from users.decorators import identity_required +from users.views.admin import AdminSettingsPage + + +@method_decorator(identity_required, name="dispatch") +class SettingsRoot(RedirectView): + url = "/settings/interface/" + + +class SettingsPage(AdminSettingsPage): + """ + 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.IdentityOptions + template_name = "settings/settings.html" + + def load_config(self): + return Config.load_identity(self.request.identity) + + def save_config(self, key, value): + Config.set_identity(self.request.identity, key, value) + + +class InterfacePage(SettingsPage): + + section = "interface" + + options = { + "toot_mode": { + "title": "I Will Toot As I Please", + "help_text": "If enabled, changes all 'Post' buttons to 'Toot!'", + } + } diff --git a/users/views/settings_identity.py b/users/views/settings_identity.py deleted file mode 100644 index f35928a..0000000 --- a/users/views/settings_identity.py +++ /dev/null @@ -1,41 +0,0 @@ -from django.utils.decorators import method_decorator -from django.views.generic import RedirectView - -from core.models import Config -from users.decorators import identity_required -from users.views.settings_system import SystemSettingsPage - - -@method_decorator(identity_required, name="dispatch") -class IdentitySettingsRoot(RedirectView): - url = "/settings/interface/" - - -class IdentitySettingsPage(SystemSettingsPage): - """ - 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! - """ - - extra_context = {"top_section": "settings"} - - options_class = Config.IdentityOptions - template_name = "settings/settings_identity.html" - - def load_config(self): - return Config.load_identity(self.request.identity) - - def save_config(self, key, value): - Config.set_identity(self.request.identity, key, value) - - -class InterfacePage(IdentitySettingsPage): - - section = "interface" - - options = { - "toot_mode": { - "title": "I Will Toot As I Please", - "help_text": "If enabled, changes all 'Post' buttons to 'Toot!'", - } - } diff --git a/users/views/settings_system.py b/users/views/settings_system.py deleted file mode 100644 index e5e9e85..0000000 --- a/users/views/settings_system.py +++ /dev/null @@ -1,243 +0,0 @@ -import re -from functools import partial -from typing import ClassVar, Dict - -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 core.models import Config -from users.decorators import admin_required -from users.models import Domain - - -@method_decorator(admin_required, name="dispatch") -class SystemSettingsRoot(RedirectView): - url = "/settings/system/basic/" - - -@method_decorator(admin_required, name="dispatch") -class SystemSettingsPage(FormView): - """ - 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! - """ - - template_name = "settings/settings_system.html" - options_class = Config.SystemOptions - section: ClassVar[str] - options: Dict[str, Dict[str, str]] - - extra_context = {"top_section": "settings_system"} - - def get_form_class(self): - # Create the fields dict from the config object - fields = {} - for key, details in self.options.items(): - config_field = self.options_class.__fields__[key] - if config_field.type_ is bool: - form_field = partial( - forms.BooleanField, - widget=forms.Select( - choices=[(True, "Enabled"), (False, "Disabled")] - ), - ) - elif config_field.type_ is str: - form_field = forms.CharField - else: - raise ValueError(f"Cannot render settings type {config_field.type_}") - fields[key] = form_field( - label=details["title"], - help_text=details.get("help_text", ""), - required=details.get("required", False), - ) - # Create a form class dynamically (yeah, right?) and return that - return type("SettingsForm", (forms.Form,), fields) - - def load_config(self): - return Config.load_system() - - def save_config(self, key, value): - Config.set_system(key, value) - - def get_initial(self): - config = self.load_config() - initial = {} - for key in self.options.keys(): - initial[key] = getattr(config, key) - return initial - - def get_context_data(self): - context = super().get_context_data() - context["section"] = self.section - return context - - def form_valid(self, form): - # Save each key - for field in form: - self.save_config( - field.name, - form.cleaned_data[field.name], - ) - return redirect(".") - - -class BasicPage(SystemSettingsPage): - - section = "basic" - - options = { - "site_name": { - "title": "Site Name", - "help_text": "Shown in the top-left of the page, and titles", - }, - "highlight_color": { - "title": "Highlight Color", - "help_text": "Used for logo background and other highlights", - }, - } - - -@method_decorator(admin_required, name="dispatch") -class DomainsPage(TemplateView): - - template_name = "settings/settings_system_domains.html" - - def get_context_data(self): - return { - "domains": Domain.objects.filter(local=True).order_by("domain"), - "section": "domains", - } - - -@method_decorator(admin_required, name="dispatch") -class DomainCreatePage(FormView): - - template_name = "settings/settings_system_domain_create.html" - extra_context = {"section": "domains"} - - class form_class(forms.Form): - domain = forms.CharField( - help_text="The domain displayed as part of a user's identity.\nCannot be changed after the domain has been created.", - ) - service_domain = forms.CharField( - help_text="Optional - a domain that serves Takahē if it is not running on the main domain.\nCannot be changed after the domain has been created.", - required=False, - ) - public = forms.BooleanField( - help_text="If any user on this server can create identities here", - widget=forms.Select(choices=[(True, "Public"), (False, "Private")]), - required=False, - ) - - domain_regex = re.compile( - r"^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9\-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$" - ) - - def clean_domain(self): - if not self.domain_regex.match(self.cleaned_data["domain"]): - raise forms.ValidationError("This does not look like a domain name") - if Domain.objects.filter( - models.Q(domain=self.cleaned_data["domain"]) - | models.Q(service_domain=self.cleaned_data["domain"]) - ): - raise forms.ValidationError("This domain name is already in use") - return self.cleaned_data["domain"] - - def clean_service_domain(self): - if not self.cleaned_data["service_domain"]: - return None - if not self.domain_regex.match(self.cleaned_data["service_domain"]): - raise forms.ValidationError("This does not look like a domain name") - if Domain.objects.filter( - models.Q(domain=self.cleaned_data["service_domain"]) - | models.Q(service_domain=self.cleaned_data["service_domain"]) - ): - raise forms.ValidationError("This domain name is already in use") - if self.cleaned_data.get("domain") == self.cleaned_data["service_domain"]: - raise forms.ValidationError( - "You cannot have the domain and service domain be the same (did you mean to leave service domain blank?)" - ) - return self.cleaned_data["service_domain"] - - def form_valid(self, form): - Domain.objects.create( - domain=form.cleaned_data["domain"], - service_domain=form.cleaned_data["service_domain"] or None, - public=form.cleaned_data["public"], - local=True, - ) - return redirect(Domain.urls.root) - - -@method_decorator(admin_required, name="dispatch") -class DomainEditPage(FormView): - - template_name = "settings/settings_system_domain_edit.html" - extra_context = {"section": "domains"} - - class form_class(forms.Form): - domain = forms.CharField( - help_text="The domain displayed as part of a user's identity.\nCannot be changed after the domain has been created.", - disabled=True, - ) - service_domain = forms.CharField( - help_text="Optional - a domain that serves Takahē if it is not running on the main domain.\nCannot be changed after the domain has been created.", - disabled=True, - required=False, - ) - public = forms.BooleanField( - help_text="If any user on this server can create identities here", - widget=forms.Select(choices=[(True, "Public"), (False, "Private")]), - required=False, - ) - - def dispatch(self, request, domain): - self.domain = get_object_or_404( - Domain.objects.filter(local=True), domain=domain - ) - return super().dispatch(request) - - def get_context_data(self): - context = super().get_context_data() - context["domain"] = self.domain - return context - - def form_valid(self, form): - self.domain.public = form.cleaned_data["public"] - self.domain.save() - return redirect(Domain.urls.root) - - def get_initial(self): - return { - "domain": self.domain.domain, - "service_domain": self.domain.service_domain, - "public": self.domain.public, - } - - -@method_decorator(admin_required, name="dispatch") -class DomainDeletePage(TemplateView): - - template_name = "settings/settings_system_domain_delete.html" - - def dispatch(self, request, domain): - self.domain = get_object_or_404( - Domain.objects.filter(public=True), domain=domain - ) - return super().dispatch(request) - - def get_context_data(self): - return { - "domain": self.domain, - "num_identities": self.domain.identities.count(), - "section": "domains", - } - - def post(self, request): - if self.domain.identities.exists(): - raise ValueError("Tried to delete domain with identities!") - self.domain.delete() - return redirect("/settings/system/domains/") -- cgit v1.2.3