diff --git a/concordia/admin/__init__.py b/concordia/admin/__init__.py index 24ef79a9b..cf0372ab1 100644 --- a/concordia/admin/__init__.py +++ b/concordia/admin/__init__.py @@ -1,4 +1,5 @@ import io +import json import logging import zipfile from typing import Any @@ -11,7 +12,13 @@ from django.contrib.auth.decorators import permission_required from django.contrib.auth.models import User from django.db.models import Exists, OuterRef, QuerySet -from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect +from django.http import ( + Http404, + HttpRequest, + HttpResponse, + HttpResponseRedirect, + JsonResponse, +) from django.shortcuts import get_object_or_404, render from django.template.defaultfilters import truncatechars from django.template.response import TemplateResponse @@ -266,8 +273,18 @@ def truncated_metadata(self, obj): return "" +class TinyMCEMediaMixin: + """ + Mixin to automatically inject the custom S3 file picker asset + into any ModelAdmin form that renders a TinyMCE rich text widget. + """ + + class Media: + js = ("js/src/tinymce-picker.js",) + + @admin.register(Campaign) -class CampaignAdmin(admin.ModelAdmin, CustomListDisplayFieldsMixin): +class CampaignAdmin(admin.ModelAdmin, CustomListDisplayFieldsMixin, TinyMCEMediaMixin): """ Admin configuration for `Campaign` objects. @@ -590,7 +607,9 @@ def file_url(self, obj: ConcordiaFile) -> str: # do not want the querystring, so we remove it. # This looks hacky, but seems to be the least hacky way to do # this without a third-party library. - return obj.uploaded_file.url.split("?")[0] + if obj.uploaded_file and hasattr(obj.uploaded_file, "url"): + return obj.uploaded_file.url.split("?")[0] + return "" def get_fields( self, @@ -620,6 +639,161 @@ def get_fields( ) return ("name", "uploaded_file") + def changelist_view( + self, + request: HttpRequest, + extra_context: dict[str, Any] | None = None, + ) -> HttpResponse: + """ + Intercept the default changelist to provide a tailored picker experience + when executing within a modal popup frame. + """ + if "_popup" in request.GET: + extra_context = extra_context or {} + extra_context["is_popup"] = True + # Fetch files ordered by newest to optimize the picker interface workflow + extra_context["available_files"] = self.get_queryset(request).order_by( + "-updated_on" + ) + return render( + request, + "admin/concordia/concordiafile/popup_picker.html", + extra_context, + ) + return super().changelist_view(request, extra_context=extra_context) + + def delete_model(self, request: HttpRequest, obj: ConcordiaFile) -> None: + """ + Ensures the physical storage block is cleanly removed from the AWS S3 bucket + via the django-storages backend layer before dropping the metadata record. + """ + storage = getattr(obj.uploaded_file, "storage", None) + file_name = getattr(obj.uploaded_file, "name", None) + + super().delete_model(request, obj) + + if storage and file_name: + try: + storage.delete(file_name) + except Exception as e: + logger.error( + "S3 object elimination failure for key %s: %s", file_name, e + ) + + def get_urls(self): + urls = super().get_urls() + opts = self.model._meta + custom_urls = [ + path( + "explicit-popup-delete/", + self.admin_site.admin_view(self.explicit_popup_delete_view), + name=f"{opts.app_label}_{opts.model_name}_explicit_popup_delete", + ), + ] + return custom_urls + urls + + @method_decorator(csrf_protect) + def explicit_popup_delete_view(self, request: HttpRequest) -> HttpResponse: + """ + Securely handles single-file deletion directly from the interactive gallery UI. + + Args: + request (HttpRequest): Secure POST request carrying the primary key target. + + Returns: + JsonResponse: Result status indicator mapping execution success or failure. + """ + if request.method != "POST": + return JsonResponse({"error": "Method not allowed"}, status=405) + + try: + if not request.body: + return JsonResponse( + {"error": "Malformed request payload: Body empty"}, status=400 + ) + + data = json.loads(request.body) + file_id = data.get("file_id") + + if not file_id: + return JsonResponse( + {"error": "Required field 'file_id' missing"}, status=400 + ) + + file_obj = ConcordiaFile.objects.get(pk=file_id) + storage = getattr(file_obj.uploaded_file, "storage", None) + file_name = getattr(file_obj.uploaded_file, "name", None) + + # Purge from DB + file_obj.delete() + + # Evict from AWS S3 bucket directly + if storage and file_name: + try: + storage.delete(file_name) + except Exception as e: + logger.error( + "Gallery-triggered S3 purge failed for key %s: %s", file_name, e + ) + + return JsonResponse({"success": True}) + except ConcordiaFile.DoesNotExist: + return JsonResponse({"error": "Target file no longer exists"}, status=404) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON format in payload"}, status=400) + except Exception as e: + logger.exception( + "Unhandled system error in explicit_popup_delete_view: %s", e + ) + return JsonResponse( + {"error": "Internal server configuration error"}, status=500 + ) + + def response_add( + self, + request: HttpRequest, + obj: ConcordiaFile, + post_url_continue: str | None = None, + ) -> HttpResponse: + """ + Determine the HTTP response after a successful object creation form submission. + + If the object was created inside a TinyMCE popup window interface, this + intercepts the standard redirect and instead returns an inline JavaScript block + that passes the clean S3 URL and file name back to the editor session and closes + the window. + + Args: + request (HttpRequest): Current admin request. + obj (ConcordiaFile): The newly created file instance. + post_url_continue (str | None): The URL to redirect to if "save and continue + editing" was selected. + + Returns: + HttpResponse: An inline script payload if triggered via a popup context, + otherwise a standard administrative redirect response. + """ + if "_popup" in request.POST or "_popup" in request.GET: + url_data = self.file_url(obj) + clean_s3_url_string = str(url_data).split("?")[0] + + return HttpResponse( + format_html( + """ + + """, + clean_s3_url_string, + obj.name, + ) + ) + + return super().response_add(request, obj, post_url_continue) + class TopicProjectInline(admin.TabularInline): model = ProjectTopic @@ -631,7 +805,7 @@ class TopicProjectInline(admin.TabularInline): @admin.register(Topic) -class TopicAdmin(admin.ModelAdmin): +class TopicAdmin(admin.ModelAdmin, TinyMCEMediaMixin): form = TopicAdminForm inlines = [TopicProjectInline] @@ -662,7 +836,7 @@ class ProjectTopicInline(admin.TabularInline): @admin.register(Project) -class ProjectAdmin(admin.ModelAdmin, CustomListDisplayFieldsMixin): +class ProjectAdmin(admin.ModelAdmin, CustomListDisplayFieldsMixin, TinyMCEMediaMixin): form = ProjectAdminForm inlines = [ProjectTopicInline] @@ -808,7 +982,7 @@ def item_import_view( @admin.register(Item) -class ItemAdmin(admin.ModelAdmin): +class ItemAdmin(admin.ModelAdmin, TinyMCEMediaMixin): form = ItemAdminForm list_display = ("title", "item_id", "campaign_title", "project", "published") list_display_links = ("title", "item_id") @@ -1709,7 +1883,7 @@ def completion(self, obj: CampaignRetirementProgress) -> str: @admin.register(Card) -class CardAdmin(admin.ModelAdmin): +class CardAdmin(admin.ModelAdmin, TinyMCEMediaMixin): form = CardAdminForm fields = ("title", "display_heading", "body_text", "image", "image_alt_text") list_display = ["title", "display_heading", "created_on", "updated_on"] @@ -1731,7 +1905,7 @@ class Media: @admin.register(Guide) -class GuideAdmin(admin.ModelAdmin): +class GuideAdmin(admin.ModelAdmin, TinyMCEMediaMixin): form = GuideAdminForm diff --git a/concordia/admin/forms.py b/concordia/admin/forms.py index f8db8199a..b3488f78e 100644 --- a/concordia/admin/forms.py +++ b/concordia/admin/forms.py @@ -44,6 +44,9 @@ "h6", "hr", "section", + "figure", + "figcaption", + "img", } ALLOWED_ATTRIBUTES = { @@ -53,6 +56,9 @@ "div": {"class", "id"}, "span": {"class", "id"}, "p": {"class", "id"}, + "figure": {"class"}, + "figcaption": {"class"}, + "img": {"src", "alt", "title", "width", "height", "class", "style"}, } diff --git a/concordia/settings_template.py b/concordia/settings_template.py index 4f4c80ebf..5854485f5 100644 --- a/concordia/settings_template.py +++ b/concordia/settings_template.py @@ -531,11 +531,19 @@ "referrer_policy": "origin", "skin": "oxide-dark", "content_css": "dark", - "plugins": "link lists searchreplace wordcount", - "browser_spellcheck": "true", + "plugins": "link lists searchreplace wordcount code image", + "browser_spellcheck": True, "newline_behavior": "linebreak", - "toolbar1": "bold italic | numlist bullist | link | searchreplace wordcount", + "toolbar1": "bold italic | numlist bullist | link | searchreplace wordcount | code | image", # noqa: E501 + "image_description": True, + "image_caption": True, + "image_title": True, "width": 624, + "file_picker_callback": "concordiaTinyMcePicker", + "file_picker_types": "image", + "convert_urls": False, + "relative_urls": False, + "remove_script_host": False, } TINYMCE_JS_URL = "https://cdn.tiny.cloud/1/rf486i5f1ww9m8191oolczn7f0ry61mzdtfwbu7maiiiv2kv/tinymce/6/tinymce.min.js" diff --git a/concordia/static/js/src/tinymce-picker.js b/concordia/static/js/src/tinymce-picker.js new file mode 100644 index 000000000..2e71ca5ff --- /dev/null +++ b/concordia/static/js/src/tinymce-picker.js @@ -0,0 +1,26 @@ +/** + * Custom file picker callback for TinyMCE inside the Concordia admin panel. + * Routes administrators directly to the interactive Media Library Manager overview. + */ +function concordiaTinyMcePicker(callback, value, meta) { + if (meta.filetype !== 'image') { + alert('This picker only supports image uploads.'); + return; + } + + // Capture standard mapping fields securely from frame interactions + window.tinymce_callback = function (url, titleText = '') { + callback(url, {alt: titleText, title: titleText}); + }; + + // Route to the changelist view to open the asset gallery view + const adminLibraryUrl = '/admin/concordia/concordiafile/?_popup=1'; + + window.open( + adminLibraryUrl, + 'concordia_upload_popup', + 'width=950,height=700,resizable=yes,scrollbars=yes,status=no', + ); +} + +window.concordiaTinyMcePicker = concordiaTinyMcePicker; diff --git a/concordia/templates/admin/concordia/concordiafile/popup_picker.html b/concordia/templates/admin/concordia/concordiafile/popup_picker.html new file mode 100644 index 000000000..16416916a --- /dev/null +++ b/concordia/templates/admin/concordia/concordiafile/popup_picker.html @@ -0,0 +1,150 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block content %} +