Identity admin/moderation

This commit is contained in:
Andrew Godwin 2022-12-16 19:42:48 -07:00
parent c588567c86
commit 12567f6891
20 changed files with 489 additions and 35 deletions

View File

@ -1,6 +1,6 @@
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import models from django.db import models
from django.http import JsonResponse from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.vary import vary_on_headers from django.views.decorators.vary import vary_on_headers
@ -10,6 +10,7 @@ from activities.models import Post, PostInteraction, PostStates
from core.decorators import cache_page_by_ap_json from core.decorators import cache_page_by_ap_json
from core.ld import canonicalise from core.ld import canonicalise
from users.decorators import identity_required from users.decorators import identity_required
from users.models import Identity
from users.shortcuts import by_handle_or_404 from users.shortcuts import by_handle_or_404
@ -23,6 +24,8 @@ class Individual(TemplateView):
def get(self, request, handle, post_id): def get(self, request, handle, post_id):
self.identity = by_handle_or_404(self.request, handle, local=False) self.identity = by_handle_or_404(self.request, handle, local=False)
if self.identity.blocked:
raise Http404("Blocked user")
self.post_obj = get_object_or_404(self.identity.posts, pk=post_id) self.post_obj = get_object_or_404(self.identity.posts, pk=post_id)
# If they're coming in looking for JSON, they want the actor # If they're coming in looking for JSON, they want the actor
if request.ap_json: if request.ap_json:
@ -66,6 +69,7 @@ class Individual(TemplateView):
), ),
in_reply_to=self.post_obj.object_uri, in_reply_to=self.post_obj.object_uri,
) )
.exclude(author__restriction=Identity.Restriction.blocked)
.distinct() .distinct()
.select_related("author__domain") .select_related("author__domain")
.prefetch_related("emojis") .prefetch_related("emojis")

View File

@ -7,6 +7,7 @@ from django.views.generic import FormView, ListView
from activities.models import Hashtag, Post, PostInteraction, TimelineEvent from activities.models import Hashtag, Post, PostInteraction, TimelineEvent
from core.decorators import cache_page from core.decorators import cache_page
from users.decorators import identity_required from users.decorators import identity_required
from users.models import Identity
from .compose import Compose from .compose import Compose
@ -75,6 +76,7 @@ class Tag(ListView):
def get_queryset(self): def get_queryset(self):
return ( return (
Post.objects.public() Post.objects.public()
.filter(author__restriction=Identity.Restriction.none)
.tagged_with(self.hashtag) .tagged_with(self.hashtag)
.select_related("author") .select_related("author")
.prefetch_related("attachments", "mentions") .prefetch_related("attachments", "mentions")
@ -105,6 +107,7 @@ class Local(ListView):
def get_queryset(self): def get_queryset(self):
return ( return (
Post.objects.local_public() Post.objects.local_public()
.filter(author__restriction=Identity.Restriction.none)
.select_related("author", "author__domain") .select_related("author", "author__domain")
.prefetch_related("attachments", "mentions", "emojis") .prefetch_related("attachments", "mentions", "emojis")
.order_by("-created") .order_by("-created")
@ -133,6 +136,7 @@ class Federated(ListView):
Post.objects.filter( Post.objects.filter(
visibility=Post.Visibilities.public, in_reply_to__isnull=True visibility=Post.Visibilities.public, in_reply_to__isnull=True
) )
.filter(author__restriction=Identity.Restriction.none)
.select_related("author", "author__domain") .select_related("author", "author__domain")
.prefetch_related("attachments", "mentions", "emojis") .prefetch_related("attachments", "mentions", "emojis")
.order_by("-created") .order_by("-created")

View File

@ -48,7 +48,9 @@ def account_relationships(request):
@api_router.get("/v1/accounts/{id}", response=schemas.Account) @api_router.get("/v1/accounts/{id}", response=schemas.Account)
@identity_required @identity_required
def account(request, id: str): def account(request, id: str):
identity = get_object_or_404(Identity, pk=id) identity = get_object_or_404(
Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id
)
return identity.to_mastodon_json() return identity.to_mastodon_json()
@ -67,7 +69,9 @@ def account_statuses(
min_id: str | None = None, min_id: str | None = None,
limit: int = 20, limit: int = 20,
): ):
identity = get_object_or_404(Identity, pk=id) identity = get_object_or_404(
Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id
)
queryset = ( queryset = (
identity.posts.not_hidden() identity.posts.not_hidden()
.unlisted(include_replies=not exclude_replies) .unlisted(include_replies=not exclude_replies)

View File

@ -3,6 +3,7 @@ from api import schemas
from api.decorators import identity_required from api.decorators import identity_required
from api.pagination import MastodonPaginator from api.pagination import MastodonPaginator
from api.views.base import api_router from api.views.base import api_router
from users.models import Identity
@api_router.get("/v1/timelines/home", response=list[schemas.Status]) @api_router.get("/v1/timelines/home", response=list[schemas.Status])
@ -52,6 +53,7 @@ def public(
): ):
queryset = ( queryset = (
Post.objects.public() Post.objects.public()
.filter(author__restriction=Identity.Restriction.none)
.select_related("author") .select_related("author")
.prefetch_related("attachments") .prefetch_related("attachments")
.order_by("-created") .order_by("-created")
@ -90,6 +92,7 @@ def hashtag(
limit = 40 limit = 40
queryset = ( queryset = (
Post.objects.public() Post.objects.public()
.filter(author__restriction=Identity.Restriction.none)
.tagged_with(hashtag) .tagged_with(hashtag)
.select_related("author") .select_related("author")
.prefetch_related("attachments") .prefetch_related("attachments")

View File

@ -18,6 +18,7 @@ in alpha. For more information about Takahē, see
features features
contributing contributing
domains domains
moderation
stator stator
tuning tuning
releases/index releases/index

99
docs/moderation.rst Normal file
View File

@ -0,0 +1,99 @@
Moderation
==========
As a server admin, you have both identity-level and server-level moderation
options at your disposal.
Identities
----------
Identities, known as Accounts in Mastodon, have their own handle
(like ``@takahe@jointakahe.org``), and are generally what people think of as
"users".
Takahē distinguishes between the two - for us, a User is a set of login
credentials, while an Identity is the public-facing identity people use to
post. A user can have multiple identities, and an identity can be shared
across multiple users (for example, a brand account that five people can
post from).
You can moderate both local and remote identities, but bear in mind that any
moderation actions on *remote identities* are local to your server only;
they will not propagate over to other servers.
Identity moderation actions are available in the "Identities" admin area.
Limiting
~~~~~~~~
Limiting an identity prevents its posts from appearing in the Public and
Federated timelines; they will, however, still appear in the timelines of
people who follow them, be able to notify other people via mentions, and their
replies will appear in conversation threads.
You can limit both local and remote identities. Limiting is reversible,
and encouraged as a way to remove some visibility if you don't want a full block.
Blocking
~~~~~~~~
Blocking an identity erases its existence from your server. Its posts will
not appear anywhere, no mentions from it will come through, and Takahē will
actively discard all incoming information from it as soon as it is received.
If you block a local identity, you are freezing the account and erasing it
from the Fediverse. Takahē will still accept inbound notifications for it,
but if any servers ask if it exists, it will deny its existence. Users trying
to log into that identity will be denied access.
If you block a remote identity, you are almost erasing it from existence
from your server's users. Users will not be able to follow it or see posts
from it; they will, however, be able to mention it in outgoing posts.
Blocking is reversible; however, you will lose data intended for the account
for the duration it is blocked for. If you leave a local account blocked for
too long, other servers will decide it has totally vanished and stop their
users following it.
Servers
-------
If your problem is not with an individual identity/account but with an entire
server - be it very poorly run or actively malicious - you can instead
choose to block the entire server ("defederate").
This is accomplished via the "Federation" admin area. Search and select the
domain you want, and then set it to blocked.
While a domain is blocked, Takahē will actively drop all inbound messages
from it. Blocking is reversible, but you will lose all inbound data from the
server during the blocking period.
Defederating from Takahē
------------------------
Takahē is unusual in the Fediverse in that it's possible to have it claim to be
multiple different domains at once; this extends to the way it speaks to
other servers, and means you cannot easily block an entire Takahē installation at once.
If you wish to block a Takahē server, either from Takahē or any other Fediverse
server that supports defederation, you may choose to either block a single
domain as normal, or you may want to block the entire server.
Takahē sends all actor messages from identities based on the domain they are
part of, but uses a single System Actor for all GET requests to retrieve
identity and post information. To properly defederate a Takahē server, you
need to:
* Block all domains you know it has identities on
* Block the domain of the System Actor (visible at the ``/actor/`` URL)
If you are having trouble blocking a Takahē server due to this, we apologise;
this is the nature of the underlying protocol. If you find a server that breaks
our `Code of Conduct <https://jointakahe.org/conduct/>`_, please let us know
at conduct@jointakahe.org and we will do our best to not give them any support.

View File

@ -307,6 +307,14 @@ nav a i {
margin: 0 0 10px 0; margin: 0 0 10px 0;
} }
.left-column h1 small {
font-size: 60%;
color: var(--color-text-dull);
display: block;
margin: -10px 0 0 0;
padding: 0;
}
.left-column h2 { .left-column h2 {
margin: 10px 0 10px 0; margin: 10px 0 10px 0;
} }
@ -642,10 +650,15 @@ form .uploaded-image .buttons {
} }
form .buttons { form .buttons {
clear: both;
text-align: right; text-align: right;
margin: -20px 0 15px 0; margin: -20px 0 15px 0;
} }
form .buttons:nth-of-type(2) {
padding-top: 15px;
}
form p+.buttons, form p+.buttons,
form fieldset .buttons { form fieldset .buttons {
margin-top: 0; margin-top: 0;
@ -794,14 +807,15 @@ h1.identity small {
table.metadata { table.metadata {
margin: -10px 0 0 0; margin: -10px 0 0 0;
text-align: left;
} }
table.metadata td { table.metadata td {
padding: 0; padding: 0;
} }
table.metadata td.name { table.metadata th {
padding-right: 10px; padding: 0 10px 0 0;
font-weight: bold; font-weight: bold;
} }

View File

@ -106,9 +106,14 @@ urlpatterns = [
), ),
path( path(
"admin/identities/", "admin/identities/",
admin.Identities.as_view(), admin.IdentitiesRoot.as_view(),
name="admin_identities", name="admin_identities",
), ),
path(
"admin/identities/<id>/",
admin.IdentityEdit.as_view(),
name="admin_identity_edit",
),
path( path(
"admin/invites/", "admin/invites/",
admin.Invites.as_view(), admin.Invites.as_view(),

View File

@ -3,7 +3,50 @@
{% block subtitle %}Identities{% endblock %} {% block subtitle %}Identities{% endblock %}
{% block content %} {% block content %}
<p> <form action="." class="search">
Please use the <a href="/djadmin/users/identity/">Django Admin</a> for now. <input type="search" name="query" value="{{ query }}" placeholder="Search by name/username">
</p> {% if local_only %}
<input type="hidden" name="local_only" value="true">
{% endif %}
<button><i class="fa-solid fa-search"></i></button>
</form>
<div class="view-options">
{% if local_only %}
<a href=".?{% if query %}query={{ query }}{% endif %}" class="selected"><i class="fa-solid fa-check"></i> Local Only</a>
{% else %}
<a href=".?local_only=true{% if query %}&amp;query={{ query }}{% endif %}"><i class="fa-solid fa-xmark"></i> Local Only</a>
{% endif %}
</div>
<section class="icon-menu">
{% for identity in page_obj %}
<a class="option" href="{{ identity.urls.admin_edit }}">
<img src="{{ identity.local_icon_url.relative }}" class="icon" alt="Avatar for {{ identity.name_or_handle }}">
<span class="handle">
{{ identity.html_name_or_handle }}
<small>
{{ identity.handle }}
</small>
</span>
{% if identity.banned %}
<span class="pill bad">Banned</span>
{% endif %}
</a>
{% empty %}
<p class="option empty">
{% if query %}
No identities match your query.
{% else %}
There are no identities yet.
{% endif %}
</p>
{% endfor %}
<div class="load-more">
{% if page_obj.has_previous %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}{% if local_only %}&amp;local_only=true{% endif %}{% if query %}&amp;query={{ query }}{% endif %}">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}{% if local_only %}&amp;local_only=true{% endif %}{% if query %}&amp;query={{ query }}{% endif %}">Next Page</a>
{% endif %}
</div>
</section>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,123 @@
{% extends "settings/base.html" %}
{% block subtitle %}{{ identity.name_or_handle }}{% endblock %}
{% block content %}
<h1>{{ identity.html_name_or_handle }} <small>{{ identity.handle }}</small></h1>
<form action="." method="POST">
{% csrf_token %}
<fieldset>
<legend>Stats</legend>
<table class="metadata">
<tr>
<th>Status</td>
<td>
{% if identity.limited %}
Limited
{% elif identity.blocked %}
Blocked
{% else %}
Normal
{% endif %}
</td>
</tr>
{% if identity.local %}
<tr>
<th>Type</td>
<td>Local Identity</td>
</tr>
<tr>
<th>Followers</td>
<td>{{ identity.inbound_follows.count }}</td>
</tr>
<tr>
<th>Following</td>
<td>{{ identity.outbound_follows.count }}</td>
</tr>
{% else %}
<tr>
<th>Type</td>
<td>Remote Identity</td>
</tr>
<tr>
<th>Local Followers</td>
<td>{{ identity.inbound_follows.count }}</td>
</tr>
<tr>
<th>Following Locals</td>
<td>{{ identity.outbound_follows.count }}</td>
</tr>
{% endif %}
<tr>
<th>Posts</td>
<td>{{ identity.posts.count }}</td>
</tr>
<tr>
<th>First Seen</td>
<td>{{ identity.created|timesince }} ago</td>
</tr>
</table>
</fieldset>
{% if identity.local %}
<fieldset>
<legend>Users</legend>
<p>
{% for user in identity.users.all %}
<a href="{{ user.urls.admin_edit }}">{{ user.email }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
</p>
</fieldset>
{% endif %}
<fieldset>
<legend>Technical</legend>
<table class="metadata">
{% if not identity.local %}
<tr>
<th>Last Fetched</td>
<td>{{ identity.fetched|timesince }} ago</td>
</tr>
{% if identity.state == "outdated" %}
<tr>
<th>Attempting Fetch Since</td>
<td>{{ identity.state_changed|timesince }} ago</td>
</tr>
{% endif %}
{% endif %}
<tr>
<th>Actor URI</td>
<td>{{ identity.actor_uri }}</td>
</tr>
{% if not identity.local %}
<tr>
<th>Inbox URI</td>
<td>{{ identity.inbox_uri }}</td>
</tr>
{% endif %}
</table>
</fieldset>
<fieldset>
<legend>Admin Notes</legend>
{% include "forms/_field.html" with field=form.notes %}
</fieldset>
<div class="buttons">
{% if not identity.local %}
<button class="left" name="fetch">Force Fetch</a>
{% endif %}
{% if identity.limited %}
<button class="left delete" name="unlimit">Unlimit</a>
{% else %}
<button class="left delete" name="limit">Limit</a>
{% endif %}
{% if identity.blocked %}
<button class="left delete" name="unblock">Unblock</a>
{% else %}
<button class="left delete" name="block">Block</a>
{% endif %}
</div>
<div class="buttons">
<a href="{{ identity.urls.admin }}" class="button secondary left">Back</a>
<a href="{{ identity.urls.view }}" class="button secondary">View Profile</a>
<button>Save Notes</button>
</div>
</form>
{% endblock %}

View File

@ -6,8 +6,20 @@
{% for model, stats in model_stats.items %} {% for model, stats in model_stats.items %}
<fieldset> <fieldset>
<legend>{{ model }}</legend> <legend>{{ model }}</legend>
<p><b>Pending:</b> {{ stats.most_recent_queued }}</p> <table class="metadata">
<p><b>Processed today:</b> {{ stats.most_recent_handled.1 }}</p> <tr>
<th>Pending</td>
<td>{{ stats.most_recent_queued }}</td>
</tr>
<tr>
<th>Processed today</td>
<td>{{ stats.most_recent_handled.1 }}</td>
</tr>
<tr>
<th>This month</td>
<td>{{ stats.most_recent_handled.2 }}</td>
</tr>
</table>
</fieldset> </fieldset>
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}

View File

@ -1,6 +1,6 @@
{% extends "settings/base.html" %} {% extends "settings/base.html" %}
{% block subtitle %}{{ user.email }}{% endblock %} {% block subtitle %}{{ editing_user.email }}{% endblock %}
{% block content %} {% block content %}
<h1>{{ editing_user.email }}</h1> <h1>{{ editing_user.email }}</h1>

View File

@ -66,11 +66,11 @@
<table class="metadata"> <table class="metadata">
{% for entry in identity.safe_metadata %} {% for entry in identity.safe_metadata %}
<tr> <tr>
<td class="name">{{ entry.name }}</td> <th>{{ entry.name }}</td>
<td class="value">{{ entry.value }}</td> <td>{{ entry.value }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
{% endif %} {% endif %}
{% if identity.config_identity.visible_follows %} {% if identity.config_identity.visible_follows %}

View File

@ -0,0 +1,30 @@
# Generated by Django 4.1.4 on 2022-12-17 01:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0003_identity_followers_etc"),
]
operations = [
migrations.AddField(
model_name="identity",
name="admin_notes",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="identity",
name="restriction",
field=models.IntegerField(
choices=[(0, "None"), (1, "Limited"), (2, "Blocked")], default=0
),
),
migrations.AddField(
model_name="identity",
name="sensitive",
field=models.BooleanField(default=False),
),
]

View File

@ -55,6 +55,11 @@ class Identity(StatorModel):
Represents both local and remote Fediverse identities (actors) Represents both local and remote Fediverse identities (actors)
""" """
class Restriction(models.IntegerChoices):
none = 0
limited = 1
blocked = 2
# The Actor URI is essentially also a PK - we keep the default numeric # The Actor URI is essentially also a PK - we keep the default numeric
# one around as well for making nice URLs etc. # one around as well for making nice URLs etc.
actor_uri = models.CharField(max_length=500, unique=True) actor_uri = models.CharField(max_length=500, unique=True)
@ -105,6 +110,13 @@ class Identity(StatorModel):
# Should be a list of object URIs (we don't want a full M2M here) # Should be a list of object URIs (we don't want a full M2M here)
pinned = models.JSONField(blank=True, null=True) pinned = models.JSONField(blank=True, null=True)
# Admin-only moderation fields
sensitive = models.BooleanField(default=False)
restriction = models.IntegerField(
choices=Restriction.choices, default=Restriction.none
)
admin_notes = models.TextField(null=True, blank=True)
private_key = models.TextField(null=True, blank=True) private_key = models.TextField(null=True, blank=True)
public_key = models.TextField(null=True, blank=True) public_key = models.TextField(null=True, blank=True)
public_key_id = models.TextField(null=True, blank=True) public_key_id = models.TextField(null=True, blank=True)
@ -124,6 +136,8 @@ class Identity(StatorModel):
view = "/@{self.username}@{self.domain_id}/" view = "/@{self.username}@{self.domain_id}/"
action = "{view}action/" action = "{view}action/"
activate = "{view}activate/" activate = "{view}activate/"
admin = "/admin/identities/"
admin_edit = "{admin}{self.pk}/"
def get_scheme(self, url): def get_scheme(self, url):
return "https" return "https"
@ -197,9 +211,16 @@ class Identity(StatorModel):
domain = domain.lower() domain = domain.lower()
try: try:
if local: if local:
return cls.objects.get(username=username, domain_id=domain, local=True) return cls.objects.get(
username=username,
domain_id=domain,
local=True,
)
else: else:
return cls.objects.get(username=username, domain_id=domain) return cls.objects.get(
username=username,
domain_id=domain,
)
except cls.DoesNotExist: except cls.DoesNotExist:
if fetch and not local: if fetch and not local:
actor_uri, handle = async_to_sync(cls.fetch_webfinger)( actor_uri, handle = async_to_sync(cls.fetch_webfinger)(
@ -277,6 +298,14 @@ class Identity(StatorModel):
# TODO: Setting # TODO: Setting
return self.data_age > 60 * 24 * 24 return self.data_age > 60 * 24 * 24
@property
def blocked(self) -> bool:
return self.restriction == self.Restriction.blocked
@property
def limited(self) -> bool:
return self.restriction == self.Restriction.limited
### ActivityPub (outbound) ### ### ActivityPub (outbound) ###
def to_ap(self): def to_ap(self):

View File

@ -31,4 +31,6 @@ def by_handle_or_404(request, handle, local=True, fetch=False) -> Identity:
) )
if identity is None: if identity is None:
raise Http404(f"No identity for handle {handle}") raise Http404(f"No identity for handle {handle}")
if identity.blocked:
raise Http404("Blocked user")
return identity return identity

View File

@ -165,11 +165,12 @@ class Inbox(View):
f"Inbox error: cannot fetch actor {document['actor']}" f"Inbox error: cannot fetch actor {document['actor']}"
) )
return HttpResponseBadRequest("Cannot retrieve actor") return HttpResponseBadRequest("Cannot retrieve actor")
# See if it's from a blocked domain
if identity.domain.blocked: # See if it's from a blocked user or domain
if identity.blocked or identity.domain.blocked:
# I love to lie! Throw it away! # I love to lie! Throw it away!
exceptions.capture_message( exceptions.capture_message(
f"Inbox: Discarded message from {identity.domain}" f"Inbox: Discarded message from {identity.actor_uri}"
) )
return HttpResponse(status=202) return HttpResponse(status=202)
@ -185,6 +186,7 @@ class Inbox(View):
except VerificationError: except VerificationError:
exceptions.capture_message("Inbox error: Bad LD signature") exceptions.capture_message("Inbox error: Bad LD signature")
return HttpResponseUnauthorized("Bad signature") return HttpResponseUnauthorized("Bad signature")
# Otherwise, verify against the header (assuming it's the same actor) # Otherwise, verify against the header (assuming it's the same actor)
else: else:
try: try:
@ -200,6 +202,7 @@ class Inbox(View):
except VerificationError: except VerificationError:
exceptions.capture_message("Inbox error: Bad HTTP signature") exceptions.capture_message("Inbox error: Bad HTTP signature")
return HttpResponseUnauthorized("Bad signature") return HttpResponseUnauthorized("Bad signature")
# Hand off the item to be processed by the queue # Hand off the item to be processed by the queue
InboxMessage.objects.create(message=document) InboxMessage.objects.create(message=document)
return HttpResponse(status=202) return HttpResponse(status=202)

View File

@ -1,9 +1,8 @@
from django import forms from django import forms
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import FormView, RedirectView, TemplateView from django.views.generic import FormView, RedirectView
from users.decorators import admin_required from users.decorators import admin_required
from users.models import Identity
from users.views.admin.domains import ( # noqa from users.views.admin.domains import ( # noqa
DomainCreate, DomainCreate,
DomainDelete, DomainDelete,
@ -17,6 +16,7 @@ from users.views.admin.hashtags import ( # noqa
HashtagEdit, HashtagEdit,
Hashtags, Hashtags,
) )
from users.views.admin.identities import IdentitiesRoot, IdentityEdit # noqa
from users.views.admin.settings import ( # noqa from users.views.admin.settings import ( # noqa
BasicSettings, BasicSettings,
PoliciesSettings, PoliciesSettings,
@ -31,18 +31,6 @@ class AdminRoot(RedirectView):
pattern_name = "admin_basic" pattern_name = "admin_basic"
@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") @method_decorator(admin_required, name="dispatch")
class Invites(FormView): class Invites(FormView):

View File

@ -0,0 +1,90 @@
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, ListView
from users.decorators import admin_required
from users.models import Identity, IdentityStates
@method_decorator(admin_required, name="dispatch")
class IdentitiesRoot(ListView):
template_name = "admin/identities.html"
paginate_by = 30
def get(self, request, *args, **kwargs):
self.query = request.GET.get("query")
self.local_only = request.GET.get("local_only")
self.extra_context = {
"section": "identities",
"query": self.query or "",
"local_only": self.local_only,
}
return super().get(request, *args, **kwargs)
def get_queryset(self):
identities = Identity.objects.annotate(
num_users=models.Count("users")
).order_by("created")
if self.local_only:
identities = identities.filter(local=True)
if self.query:
query = self.query.lower().strip().lstrip("@")
if "@" in query:
username, domain = query.split("@", 1)
identities = identities.filter(
username=username,
domain__domain__istartswith=domain,
)
else:
identities = identities.filter(
models.Q(username__icontains=self.query)
| models.Q(name__icontains=self.query)
)
return identities
@method_decorator(admin_required, name="dispatch")
class IdentityEdit(FormView):
template_name = "admin/identity_edit.html"
extra_context = {
"section": "identities",
}
class form_class(forms.Form):
notes = forms.CharField(widget=forms.Textarea, required=False)
def dispatch(self, request, id, *args, **kwargs):
self.identity = get_object_or_404(Identity, id=id)
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
if "fetch" in request.POST:
self.identity.transition_perform(IdentityStates.outdated)
self.identity = Identity.objects.get(pk=self.identity.pk)
if "limit" in request.POST:
self.identity.restriction = Identity.Restriction.limited
self.identity.save()
if "block" in request.POST:
self.identity.restriction = Identity.Restriction.blocked
self.identity.save()
if "unlimit" in request.POST or "unblock" in request.POST:
self.identity.restriction = Identity.Restriction.none
self.identity.save()
return super().post(request, *args, **kwargs)
def get_initial(self):
return {"notes": self.identity.admin_notes}
def form_valid(self, form):
self.identity.admin_notes = form.cleaned_data["notes"]
self.identity.save()
return redirect(".")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["identity"] = self.identity
return context

View File

@ -12,7 +12,7 @@ from users.models import User
class UsersRoot(ListView): class UsersRoot(ListView):
template_name = "admin/users.html" template_name = "admin/users.html"
paginate_by = 50 paginate_by = 30
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.query = request.GET.get("query") self.query = request.GET.get("query")