From ef1b351ca09112e0e278081e006eb8f2e09b5df5 Mon Sep 17 00:00:00 2001 From: rafalp Date: Sat, 14 Sep 2024 22:52:02 +0200 Subject: [PATCH] Add new fields to attachments, WIP move attachments admin --- misago/admin/admin.py | 48 ++++++++ .../tests => admin/attachments}/__init__.py | 0 misago/admin/attachments/forms.py | 70 ++++++++++++ misago/admin/attachments/views.py | 106 ++++++++++++++++++ .../attachmenttypes}/__init__.py | 0 misago/admin/attachmenttypes/forms.py | 82 ++++++++++++++ misago/admin/attachmenttypes/views.py | 63 +++++++++++ misago/attachments/migrations/0001_initial.py | 21 ++-- .../attachments/migrations/0002_move_data.py | 20 +++- ...3_attachment_category_thread_is_deleted.py | 43 +++++++ ...004_attachment_populate_category_thread.py | 27 +++++ misago/attachments/models.py | 25 ++++- misago/permissions/models.py | 1 - .../threads/{admin => admin_old}/__init__.py | 0 misago/threads/{admin => admin_old}/forms.py | 0 misago/threads/admin_old/tests/__init__.py | 0 .../tests/test_attachment_types_views.py | 0 .../tests/test_attachments_views.py | 0 misago/threads/admin_old/views/__init__.py | 0 .../{admin => admin_old}/views/attachments.py | 0 .../views/attachmenttypes.py | 0 21 files changed, 489 insertions(+), 17 deletions(-) rename misago/{threads/admin/tests => admin/attachments}/__init__.py (100%) create mode 100644 misago/admin/attachments/forms.py create mode 100644 misago/admin/attachments/views.py rename misago/{threads/admin/views => admin/attachmenttypes}/__init__.py (100%) create mode 100644 misago/admin/attachmenttypes/forms.py create mode 100644 misago/admin/attachmenttypes/views.py create mode 100644 misago/attachments/migrations/0003_attachment_category_thread_is_deleted.py create mode 100644 misago/attachments/migrations/0004_attachment_populate_category_thread.py rename misago/threads/{admin => admin_old}/__init__.py (100%) rename misago/threads/{admin => admin_old}/forms.py (100%) create mode 100644 misago/threads/admin_old/tests/__init__.py rename misago/threads/{admin => admin_old}/tests/test_attachment_types_views.py (100%) rename misago/threads/{admin => admin_old}/tests/test_attachments_views.py (100%) create mode 100644 misago/threads/admin_old/views/__init__.py rename misago/threads/{admin => admin_old}/views/attachments.py (100%) rename misago/threads/{admin => admin_old}/views/attachmenttypes.py (100%) diff --git a/misago/admin/admin.py b/misago/admin/admin.py index 90b1c38514..342b0c5b89 100644 --- a/misago/admin/admin.py +++ b/misago/admin/admin.py @@ -1,6 +1,8 @@ from django.urls import path from django.utils.translation import pgettext_lazy +from .attachments import views as attachments +from .attachmenttypes import views as attachmenttypes from .categories import views as categories from .groups import views as groups from .moderators import views as moderators @@ -27,6 +29,22 @@ def register_navigation_nodes(self, site): after="categories:index", namespace="moderators", ) + site.add_node( + name=pgettext_lazy("admin node", "Attachments"), + icon="fas fa-paperclip", + after="permissions:index", + namespace="attachments", + ) + site.add_node( + name=pgettext_lazy("admin node", "Attachment types"), + description=pgettext_lazy( + "admin node", + "Specify what files may be uploaded as part of user posts.", + ), + parent="settings", + after="agreements:index", + namespace="attachment-types", + ) def register_urlpatterns(self, urlpatterns): urlpatterns.namespace("groups/", "groups") @@ -84,3 +102,33 @@ def register_urlpatterns(self, urlpatterns): path("edit//", moderators.EditView.as_view(), name="edit"), path("delete//", moderators.DeleteView.as_view(), name="delete"), ) + + urlpatterns.namespace("attachments/", "attachments") + urlpatterns.patterns( + "attachments", + path("", attachments.AttachmentsList.as_view(), name="index"), + path("/", attachments.AttachmentsList.as_view(), name="index"), + path( + "delete//", + attachments.DeleteAttachment.as_view(), + name="delete", + ), + ) + + # AttachmentType + urlpatterns.namespace("attachment-types/", "attachment-types", "settings") + urlpatterns.patterns( + "settings:attachment-types", + path("", attachmenttypes.AttachmentTypesList.as_view(), name="index"), + path("new/", attachmenttypes.NewAttachmentType.as_view(), name="new"), + path( + "edit//", + attachmenttypes.EditAttachmentType.as_view(), + name="edit", + ), + path( + "delete//", + attachmenttypes.DeleteAttachmentType.as_view(), + name="delete", + ), + ) diff --git a/misago/threads/admin/tests/__init__.py b/misago/admin/attachments/__init__.py similarity index 100% rename from misago/threads/admin/tests/__init__.py rename to misago/admin/attachments/__init__.py diff --git a/misago/admin/attachments/forms.py b/misago/admin/attachments/forms.py new file mode 100644 index 0000000000..538f4d929b --- /dev/null +++ b/misago/admin/attachments/forms.py @@ -0,0 +1,70 @@ +from django import forms +from django.utils.translation import pgettext_lazy + +from ...attachments.models import AttachmentType + + +def get_searchable_filetypes(): + choices = [(0, pgettext_lazy("admin attachments type filter choice", "All types"))] + choices += [(a.id, a.name) for a in AttachmentType.objects.order_by("name")] + return choices + + +class FilterAttachmentsForm(forms.Form): + uploader = forms.CharField( + label=pgettext_lazy("admin attachments filter form", "Uploader name contains"), + required=False, + ) + filename = forms.CharField( + label=pgettext_lazy("admin attachments filter form", "Filename contains"), + required=False, + ) + filetype = forms.TypedChoiceField( + label=pgettext_lazy("admin attachments filter form", "File type"), + coerce=int, + choices=get_searchable_filetypes, + empty_value=0, + required=False, + ) + is_orphan = forms.ChoiceField( + label=pgettext_lazy("admin attachments filter form", "State"), + required=False, + choices=[ + ( + "", + pgettext_lazy( + "admin attachments orphan filter choice", + "All", + ), + ), + ( + "yes", + pgettext_lazy( + "admin attachments orphan filter choice", + "Only orphaned", + ), + ), + ( + "no", + pgettext_lazy( + "admin attachments orphan filter choice", + "Not orphaned", + ), + ), + ], + ) + + def filter_queryset(self, criteria, queryset): + if criteria.get("uploader"): + queryset = queryset.filter( + uploader_slug__contains=criteria["uploader"].lower() + ) + if criteria.get("filename"): + queryset = queryset.filter(filename__icontains=criteria["filename"]) + if criteria.get("filetype"): + queryset = queryset.filter(filetype_id=criteria["filetype"]) + if criteria.get("is_orphan") == "yes": + queryset = queryset.filter(post__isnull=True) + elif criteria.get("is_orphan") == "no": + queryset = queryset.filter(post__isnull=False) + return queryset diff --git a/misago/admin/attachments/views.py b/misago/admin/attachments/views.py new file mode 100644 index 0000000000..fed84c592c --- /dev/null +++ b/misago/admin/attachments/views.py @@ -0,0 +1,106 @@ +from django.contrib import messages +from django.db import transaction +from django.utils.translation import pgettext, pgettext_lazy + +from ...attachments.models import Attachment +from ...threads.models import Post +from ..views import generic +from .forms import FilterAttachmentsForm + + +class AttachmentAdmin(generic.AdminBaseMixin): + root_link = "misago:admin:attachments:index" + model = Attachment + templates_dir = "misago/admin/attachments" + message_404 = pgettext_lazy( + "admin attachments", "Requested attachment does not exist." + ) + + def get_queryset(self): + qs = super().get_queryset() + return qs.select_related( + "filetype", "uploader", "post", "post__thread", "post__category" + ) + + +class AttachmentsList(AttachmentAdmin, generic.ListView): + items_per_page = 20 + ordering = [ + ("-id", pgettext_lazy("admin attachments ordering choice", "From newest")), + ("id", pgettext_lazy("admin attachments ordering choice", "From oldest")), + ("filename", pgettext_lazy("admin attachments ordering choice", "A to z")), + ("-filename", pgettext_lazy("admin attachments ordering choice", "Z to a")), + ("size", pgettext_lazy("admin attachments ordering choice", "Smallest files")), + ("-size", pgettext_lazy("admin attachments ordering choice", "Largest files")), + ] + selection_label = pgettext_lazy("admin attachments", "With attachments: 0") + empty_selection_label = pgettext_lazy("admin attachments", "Select attachments") + mass_actions = [ + { + "action": "delete", + "name": pgettext_lazy("admin attachments", "Delete attachments"), + "confirmation": pgettext_lazy( + "admin attachments", + "Are you sure you want to delete selected attachments?", + ), + "is_atomic": False, + } + ] + filter_form = FilterAttachmentsForm + + def action_delete(self, request, attachments): + deleted_attachments = [] + desynced_posts = [] + + for attachment in attachments: + if attachment.post: + deleted_attachments.append(attachment.pk) + desynced_posts.append(attachment.post_id) + + if desynced_posts: + with transaction.atomic(): + for post in Post.objects.filter(id__in=desynced_posts): + self.delete_from_cache(post, deleted_attachments) + + for attachment in attachments: + attachment.delete() + + message = pgettext( + "admin attachments", "Selected attachments have been deleted." + ) + messages.success(request, message) + + def delete_from_cache(self, post, attachments): + if not post.attachments_cache: + return # admin action may be taken due to desynced state + + clean_cache = [] + for a in post.attachments_cache: + if a["id"] not in attachments: + clean_cache.append(a) + + post.attachments_cache = clean_cache or None + post.save(update_fields=["attachments_cache"]) + + +class DeleteAttachment(AttachmentAdmin, generic.ButtonView): + def button_action(self, request, target): + if target.post: + self.delete_from_cache(target) + target.delete() + message = pgettext( + "admin attachments", 'Attachment "%(filename)s" has been deleted.' + ) + messages.success(request, message % {"filename": target.filename}) + + def delete_from_cache(self, attachment): + if not attachment.post.attachments_cache: + return # admin action may be taken due to desynced state + + clean_cache = [] + for a in attachment.post.attachments_cache: + if a["id"] != attachment.id: + clean_cache.append(a) + + attachment.post.attachments_cache = clean_cache or None + attachment.post.save(update_fields=["attachments_cache"]) diff --git a/misago/threads/admin/views/__init__.py b/misago/admin/attachmenttypes/__init__.py similarity index 100% rename from misago/threads/admin/views/__init__.py rename to misago/admin/attachmenttypes/__init__.py diff --git a/misago/admin/attachmenttypes/forms.py b/misago/admin/attachmenttypes/forms.py new file mode 100644 index 0000000000..7a05335e0a --- /dev/null +++ b/misago/admin/attachmenttypes/forms.py @@ -0,0 +1,82 @@ +from django import forms +from django.utils.translation import pgettext, pgettext_lazy + +from ...attachments.models import AttachmentType + + +class AttachmentTypeForm(forms.ModelForm): + class Meta: + model = AttachmentType + fields = [ + "name", + "extensions", + "mimetypes", + "size_limit", + "status", + "limit_uploads_to", + "limit_downloads_to", + ] + labels = { + "name": pgettext_lazy("admin attachment type form", "Type name"), + "extensions": pgettext_lazy( + "admin attachment type form", "File extensions" + ), + "mimetypes": pgettext_lazy("admin attachment type form", "Mimetypes"), + "size_limit": pgettext_lazy( + "admin attachment type form", "Maximum allowed uploaded file size" + ), + "status": pgettext_lazy("admin attachment type form", "Status"), + "limit_uploads_to": pgettext_lazy( + "admin attachment type form", "Limit uploads to" + ), + "limit_downloads_to": pgettext_lazy( + "admin attachment type form", "Limit downloads to" + ), + } + help_texts = { + "extensions": pgettext_lazy( + "admin attachment type form", + "List of comma separated file extensions associated with this attachment type.", + ), + "mimetypes": pgettext_lazy( + "admin attachment type form", + "Optional list of comma separated mime types associated with this attachment type.", + ), + "size_limit": pgettext_lazy( + "admin attachment type form", + "Maximum allowed uploaded file size for this type, in kb. This setting is deprecated and has no effect. It will be deleted in Misago 1.0.", + ), + "status": pgettext_lazy( + "admin attachment type form", + "Controls this attachment type availability on your site.", + ), + "limit_uploads_to": pgettext_lazy( + "admin attachment type form", + "If you wish to limit option to upload files of this type to users with specific roles, select them on this list. Otherwise don't select any roles to allow all users with permission to upload attachments to be able to upload attachments of this type.", + ), + "limit_downloads_to": pgettext_lazy( + "admin attachment type form", + "If you wish to limit option to download files of this type to users with specific roles, select them on this list. Otherwise don't select any roles to allow all users with permission to download attachments to be able to download attachments of this type.", + ), + } + widgets = { + "limit_uploads_to": forms.CheckboxSelectMultiple, + "limit_downloads_to": forms.CheckboxSelectMultiple, + } + + def clean_extensions(self): + data = self.clean_list(self.cleaned_data["extensions"]) + if not data: + raise forms.ValidationError( + pgettext("admin attachment type form", "This field is required.") + ) + return data + + def clean_mimetypes(self): + data = self.cleaned_data["mimetypes"] + if data: + return self.clean_list(data) + + def clean_list(self, value): + items = [v.lstrip(".") for v in value.lower().replace(" ", "").split(",")] + return ",".join(set(filter(bool, items))) diff --git a/misago/admin/attachmenttypes/views.py b/misago/admin/attachmenttypes/views.py new file mode 100644 index 0000000000..1f456c7f44 --- /dev/null +++ b/misago/admin/attachmenttypes/views.py @@ -0,0 +1,63 @@ +from django.contrib import messages +from django.db.models import Count +from django.utils.translation import pgettext, pgettext_lazy + +from ...attachments.models import AttachmentType +from ..views import generic +from .forms import AttachmentTypeForm + + +class AttachmentTypeAdmin(generic.AdminBaseMixin): + root_link = "misago:admin:settings:attachment-types:index" + model = AttachmentType + form_class = AttachmentTypeForm + templates_dir = "misago/admin/attachmenttypes" + message_404 = pgettext_lazy( + "admin attachments types", "Requested attachment type does not exist." + ) + + def update_roles(self, target, roles): + target.roles.clear() + if roles: + target.roles.add(*roles) + + def handle_form(self, form, request, target): + super().handle_form(form, request, target) + form.save() + + +class AttachmentTypesList(AttachmentTypeAdmin, generic.ListView): + ordering = (("name", None),) + + def get_queryset(self): + queryset = super().get_queryset() + return queryset.annotate(num_files=Count("attachment")) + + +class NewAttachmentType(AttachmentTypeAdmin, generic.ModelFormView): + message_submit = pgettext_lazy( + "admin attachments types", 'New type "%(name)s" has been saved.' + ) + + +class EditAttachmentType(AttachmentTypeAdmin, generic.ModelFormView): + message_submit = pgettext_lazy( + "admin attachments types", 'Attachment type "%(name)s" has been edited.' + ) + + +class DeleteAttachmentType(AttachmentTypeAdmin, generic.ButtonView): + def check_permissions(self, request, target): + if target.attachment_set.exists(): + message = pgettext( + "admin attachments types", + 'Attachment type "%(name)s" has associated attachments and can\'t be deleted.', + ) + return message % {"name": target.name} + + def button_action(self, request, target): + target.delete() + message = pgettext( + "admin attachments types", 'Attachment type "%(name)s" has been deleted.' + ) + messages.success(request, message % {"name": target.name}) diff --git a/misago/attachments/migrations/0001_initial.py b/misago/attachments/migrations/0001_initial.py index d7f54e561e..c3cef7f35c 100644 --- a/misago/attachments/migrations/0001_initial.py +++ b/misago/attachments/migrations/0001_initial.py @@ -1,6 +1,7 @@ # Generated by Django 4.2.10 on 2024-09-07 10:58 from django.conf import settings +import django.contrib.postgres.fields import django.contrib.postgres.indexes from django.db import migrations, models import django.db.models.deletion @@ -48,14 +49,18 @@ class Migration(migrations.Migration): ), ( "limit_downloads_to", - models.ManyToManyField( - blank=True, related_name="+", to="misago_acl.role" + django.contrib.postgres.fields.ArrayField( + base_field=models.PositiveIntegerField(), + default=list, + size=None, ), ), ( "limit_uploads_to", - models.ManyToManyField( - blank=True, related_name="+", to="misago_acl.role" + django.contrib.postgres.fields.ArrayField( + base_field=models.PositiveIntegerField(), + default=list, + size=None, ), ), ("plugin_data", models.JSONField(default=dict)), @@ -148,15 +153,15 @@ class Migration(migrations.Migration): }, ), migrations.AddIndex( - model_name="attachmenttype", + model_name="attachment", index=django.contrib.postgres.indexes.GinIndex( - fields=["plugin_data"], name="misago_atta_plugin__d2f0d1_gin" + fields=["plugin_data"], name="misago_atta_plugin__305a3d_gin" ), ), migrations.AddIndex( - model_name="attachment", + model_name="attachmenttype", index=django.contrib.postgres.indexes.GinIndex( - fields=["plugin_data"], name="misago_atta_plugin__305a3d_gin" + fields=["plugin_data"], name="misago_atta_plugin__d2f0d1_gin" ), ), ] diff --git a/misago/attachments/migrations/0002_move_data.py b/misago/attachments/migrations/0002_move_data.py index 73bc103446..c58a4a1c60 100644 --- a/misago/attachments/migrations/0002_move_data.py +++ b/misago/attachments/migrations/0002_move_data.py @@ -13,10 +13,26 @@ class Migration(migrations.Migration): migrations.RunSQL( """ INSERT INTO misago_attachments_attachmenttype ( - id, name, extensions, mimetypes, size_limit, status, plugin_data + id, + name, + extensions, + mimetypes, + size_limit, + status, + limit_uploads_to, + limit_downloads_to, + plugin_data ) SELECT - id, name, extensions, mimetypes, size_limit, status, plugin_data::jsonb + id, + name, + extensions, + mimetypes, + size_limit, + status, + array[]::int4[], + array[]::int4[], + plugin_data::jsonb FROM misago_threads_attachmenttype; """, migrations.RunSQL.noop, diff --git a/misago/attachments/migrations/0003_attachment_category_thread_is_deleted.py b/misago/attachments/migrations/0003_attachment_category_thread_is_deleted.py new file mode 100644 index 0000000000..6ea9a5c49e --- /dev/null +++ b/misago/attachments/migrations/0003_attachment_category_thread_is_deleted.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.10 on 2024-09-10 15:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("misago_threads", "0014_plugin_data"), + ("misago_categories", "0013_new_behaviors"), + ("misago_attachments", "0002_move_data"), + ] + + operations = [ + migrations.AddField( + model_name="attachment", + name="category", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="misago_categories.category", + ), + ), + migrations.AddField( + model_name="attachment", + name="is_deleted", + field=models.BooleanField(db_index=True, default=False), + ), + migrations.AddField( + model_name="attachment", + name="thread", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="misago_threads.thread", + ), + ), + ] diff --git a/misago/attachments/migrations/0004_attachment_populate_category_thread.py b/misago/attachments/migrations/0004_attachment_populate_category_thread.py new file mode 100644 index 0000000000..96b5bb35eb --- /dev/null +++ b/misago/attachments/migrations/0004_attachment_populate_category_thread.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.10 on 2024-09-10 15:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("misago_attachments", "0003_attachment_category_thread_is_deleted"), + ] + + operations = [ + migrations.RunSQL( + """ + UPDATE + misago_attachments_attachment a + SET + category_id = p.category_id, + thread_id = p.thread_id + FROM + misago_threads_post p + WHERE + a.post_id = p.id; + """, + migrations.RunSQL.noop, + ), + ] diff --git a/misago/attachments/models.py b/misago/attachments/models.py index daeaa3f87f..7105afb639 100644 --- a/misago/attachments/models.py +++ b/misago/attachments/models.py @@ -3,6 +3,7 @@ from io import BytesIO from PIL import Image +from django.contrib.postgres.fields import ArrayField from django.core.files import File from django.core.files.base import ContentFile from django.db import models @@ -36,6 +37,20 @@ def upload_to(instance, filename): class Attachment(PluginDataModel): + category = models.ForeignKey( + "misago_categories.Category", + blank=True, + null=True, + related_name="+", + on_delete=models.SET_NULL, + ) + thread = models.ForeignKey( + "misago_threads.Thread", + blank=True, + null=True, + related_name="+", + on_delete=models.SET_NULL, + ) post = models.ForeignKey( "misago_threads.Post", blank=True, @@ -81,6 +96,8 @@ class Attachment(PluginDataModel): upload_to=upload_to, ) + is_deleted = models.BooleanField(default=False, db_index=True) + def __str__(self): return self.filename @@ -186,12 +203,8 @@ class AttachmentType(PluginDataModel): ], ) - limit_uploads_to = models.ManyToManyField( - "misago_acl.Role", related_name="+", blank=True - ) - limit_downloads_to = models.ManyToManyField( - "misago_acl.Role", related_name="+", blank=True - ) + limit_uploads_to = ArrayField(models.PositiveIntegerField(), default=list) + limit_downloads_to = ArrayField(models.PositiveIntegerField(), default=list) def __str__(self): return self.name diff --git a/misago/permissions/models.py b/misago/permissions/models.py index 7c36e7ef20..8f187a7cbf 100644 --- a/misago/permissions/models.py +++ b/misago/permissions/models.py @@ -1,4 +1,3 @@ -from dataclasses import dataclass from typing import TYPE_CHECKING from django.conf import settings from django.contrib.postgres.fields import ArrayField diff --git a/misago/threads/admin/__init__.py b/misago/threads/admin_old/__init__.py similarity index 100% rename from misago/threads/admin/__init__.py rename to misago/threads/admin_old/__init__.py diff --git a/misago/threads/admin/forms.py b/misago/threads/admin_old/forms.py similarity index 100% rename from misago/threads/admin/forms.py rename to misago/threads/admin_old/forms.py diff --git a/misago/threads/admin_old/tests/__init__.py b/misago/threads/admin_old/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/misago/threads/admin/tests/test_attachment_types_views.py b/misago/threads/admin_old/tests/test_attachment_types_views.py similarity index 100% rename from misago/threads/admin/tests/test_attachment_types_views.py rename to misago/threads/admin_old/tests/test_attachment_types_views.py diff --git a/misago/threads/admin/tests/test_attachments_views.py b/misago/threads/admin_old/tests/test_attachments_views.py similarity index 100% rename from misago/threads/admin/tests/test_attachments_views.py rename to misago/threads/admin_old/tests/test_attachments_views.py diff --git a/misago/threads/admin_old/views/__init__.py b/misago/threads/admin_old/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/misago/threads/admin/views/attachments.py b/misago/threads/admin_old/views/attachments.py similarity index 100% rename from misago/threads/admin/views/attachments.py rename to misago/threads/admin_old/views/attachments.py diff --git a/misago/threads/admin/views/attachmenttypes.py b/misago/threads/admin_old/views/attachmenttypes.py similarity index 100% rename from misago/threads/admin/views/attachmenttypes.py rename to misago/threads/admin_old/views/attachmenttypes.py