Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 182 additions & 8 deletions concordia/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import io
import json
import logging
import zipfile
from typing import Any
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
"""
<script type="text/javascript">
if (window.opener && window.opener.tinymce_callback) {{
window.opener.tinymce_callback('{0}');
}}
window.close();
</script>
""",
clean_s3_url_string,
obj.name,
)
)

return super().response_add(request, obj, post_url_continue)


class TopicProjectInline(admin.TabularInline):
model = ProjectTopic
Expand All @@ -631,7 +805,7 @@ class TopicProjectInline(admin.TabularInline):


@admin.register(Topic)
class TopicAdmin(admin.ModelAdmin):
class TopicAdmin(admin.ModelAdmin, TinyMCEMediaMixin):
form = TopicAdminForm

inlines = [TopicProjectInline]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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"]
Expand All @@ -1731,7 +1905,7 @@ class Media:


@admin.register(Guide)
class GuideAdmin(admin.ModelAdmin):
class GuideAdmin(admin.ModelAdmin, TinyMCEMediaMixin):
form = GuideAdminForm


Expand Down
6 changes: 6 additions & 0 deletions concordia/admin/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
"h6",
"hr",
"section",
"figure",
"figcaption",
"img",
}

ALLOWED_ATTRIBUTES = {
Expand All @@ -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"},
}


Expand Down
14 changes: 11 additions & 3 deletions concordia/settings_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
26 changes: 26 additions & 0 deletions concordia/static/js/src/tinymce-picker.js
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading