diff --git a/website/admin/artifact_admin.py b/website/admin/artifact_admin.py
index 2f119856..c9175597 100644
--- a/website/admin/artifact_admin.py
+++ b/website/admin/artifact_admin.py
@@ -2,13 +2,32 @@
from website.models import Artifact
from django.contrib.admin import widgets
from sortedm2m_filter_horizontal_widget.forms import SortedFilteredSelectMultiple
+from website.utils.upload_validators import PDF_EXTENSIONS, RAW_FILE_EXTENSIONS
import logging
# This retrieves a Python logging instance (or creates it)
_logger = logging.getLogger(__name__)
+
+def _accept_attr(extensions):
+ """Build an HTML ``accept`` value (e.g. ``.pdf,.pptx``) from an extension
+ allowlist. Used to seed the file inputs so both the OS file picker and the
+ client-side check in ``admin_artifact_form.js`` read the allowed types
+ straight from the markup — keeping them in sync with the server validators
+ (issue #248)."""
+ return ",".join(f".{ext}" for ext in extensions)
+
+
class ArtifactAdmin(admin.ModelAdmin):
+ # Loaded on every artifact add/change form (Talk/Poster/Publication, which
+ # all subclass this). Guards against losing selected files when the form is
+ # submitted with a missing required field, plus drag-and-drop (issue #248).
+ # PublicationAdmin defines its own Media; Django merges this base in.
+ class Media:
+ css = {"all": ("website/css/admin_artifact_form.css",)}
+ js = ("website/js/admin_artifact_form.js",)
+
# The list display lets us control what is shown in the default talk table at Home > Website > Talk
# See: https://docs.djangoproject.com/en/dev/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_display
list_display = ('title', 'date', 'get_first_author_last_name', 'forum_name', 'location')
@@ -27,9 +46,28 @@ class ArtifactAdmin(admin.ModelAdmin):
('Keyword Info', {'fields': ['keywords']}),
]
+ def get_form(self, request, obj=None, **kwargs):
+ """
+ Seed the ``accept`` attribute on the file inputs from the same extension
+ allowlists the server validators enforce (``PDF_EXTENSIONS`` /
+ ``RAW_FILE_EXTENSIONS`` in ``website.utils.upload_validators``). This gives
+ the OS file picker a native type filter and is the single source of truth
+ the client-side check in ``admin_artifact_form.js`` reads back from the
+ DOM, so the JS can't drift from the Python rules (issue #248).
+
+ Subclasses (Talk/Publication) call ``super().get_form()`` first and then
+ layer their own widget tweaks, so this runs for all artifact admins.
+ """
+ form = super().get_form(request, obj, **kwargs)
+ if "pdf_file" in form.base_fields:
+ form.base_fields["pdf_file"].widget.attrs["accept"] = _accept_attr(PDF_EXTENSIONS)
+ if "raw_file" in form.base_fields:
+ form.base_fields["raw_file"].widget.attrs["accept"] = _accept_attr(RAW_FILE_EXTENSIONS)
+ return form
+
def formfield_for_manytomany(self, db_field, request, **kwargs):
"""
- Overrides the formfield_for_manytomany method of the parent ModelAdmin class to customize the widgets
+ Overrides the formfield_for_manytomany method of the parent ModelAdmin class to customize the widgets
used for ManyToMany fields in the admin interface.
Parameters:
diff --git a/website/static/website/css/admin_artifact_form.css b/website/static/website/css/admin_artifact_form.css
new file mode 100644
index 00000000..e60ed199
--- /dev/null
+++ b/website/static/website/css/admin_artifact_form.css
@@ -0,0 +1,168 @@
+/*
+ * Styles for the artifact (Talk / Poster / Publication) admin drag-and-drop
+ * upload zone and the submit guard. See
+ * website/static/website/js/admin_artifact_form.js (issue #248).
+ *
+ * Accessibility: error state is never signaled by color alone — it always pairs
+ * with an icon-color change plus text (the inline message and the top-of-form
+ * summary), so it stays perceivable for low-vision and color-blind users
+ * (WCAG 2.0 AA). The native file input is visually hidden but kept focusable;
+ * its focus is mirrored onto the zone (`.is-focused`) as a visible ring.
+ */
+
+/* Visually hide the raw file input while keeping it focusable + submittable. */
+.artifact-file-input-hidden {
+ position: absolute !important;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* --- The drop zone (primary control) --- */
+.artifact-dropzone {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 6px;
+ padding: 16px 18px;
+ max-width: 520px;
+ border: 2px dashed #b8c4cc;
+ border-radius: 8px;
+ background: #f8fafb;
+ color: #2b3a42;
+ cursor: pointer;
+ transition: border-color 0.12s ease, background-color 0.12s ease, box-shadow 0.12s ease;
+}
+
+.artifact-dropzone:hover {
+ border-color: #79aec8; /* Django admin accent */
+ background: #f0f6fa;
+}
+
+/* Keyboard focus (mirrored from the hidden native input). */
+.artifact-dropzone.is-focused {
+ outline: 2px solid #417690;
+ outline-offset: 2px;
+}
+
+/* Dragging a file over the zone. */
+.artifact-dropzone-active {
+ border-style: solid;
+ border-color: #417690;
+ background: #e2f0f8;
+ box-shadow: inset 0 0 0 3px rgba(65, 118, 144, 0.12);
+}
+
+/* Error state (paired with text + icon color below — not color alone). */
+.artifact-dropzone.has-error {
+ border-color: #ba2121;
+ background: #fdf4f4;
+}
+
+.artifact-dropzone-icon {
+ flex: 0 0 auto;
+ width: 26px;
+ height: 26px;
+ color: #5a7a8a;
+}
+
+.artifact-dropzone:hover .artifact-dropzone-icon {
+ color: #417690;
+}
+
+.artifact-dropzone.has-error .artifact-dropzone-icon {
+ color: #ba2121;
+}
+
+.artifact-dropzone-copy {
+ display: flex;
+ flex-direction: column;
+ min-width: 0; /* allow filename to ellipsize */
+ line-height: 1.3;
+}
+
+.artifact-dropzone-title,
+.artifact-dropzone-filename {
+ font-weight: 600;
+ font-size: 13px;
+}
+
+.artifact-dropzone-filename {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.artifact-dropzone-sub {
+ margin-top: 2px;
+ font-size: 11px;
+ color: #6a7b85;
+}
+
+.artifact-dropzone.has-error .artifact-dropzone-sub {
+ color: #ba2121;
+ font-weight: 600;
+}
+
+.artifact-dropzone-link {
+ color: #417690;
+ text-decoration: underline;
+}
+
+/* Remove / replace button in the filled state. */
+.artifact-file-remove {
+ flex: 0 0 auto;
+ margin-left: auto;
+ padding: 3px 10px;
+ background: none;
+ border: 1px solid #b8c4cc;
+ border-radius: 4px;
+ color: #444;
+ font-size: 11px;
+ cursor: pointer;
+}
+
+.artifact-file-remove:hover {
+ border-color: #ba2121;
+ color: #ba2121;
+}
+
+/* --- Top-of-form error summary --- */
+.artifact-error-summary {
+ margin: 0 0 16px 0;
+ padding: 12px 16px;
+ border: 1px solid #ba2121;
+ border-left-width: 6px;
+ border-radius: 4px;
+ background: #fff4f4;
+}
+
+.artifact-error-summary:focus {
+ outline: 2px solid #417690;
+ outline-offset: 2px;
+}
+
+.artifact-error-summary-heading {
+ margin: 0 0 8px 0;
+ font-weight: 700;
+ color: #6b1414;
+}
+
+.artifact-error-summary ul {
+ margin: 0;
+ padding-left: 20px;
+}
+
+.artifact-error-summary li {
+ margin: 2px 0;
+}
+
+.artifact-error-summary a {
+ color: #ba2121;
+ text-decoration: underline;
+}
diff --git a/website/static/website/js/admin_artifact_form.js b/website/static/website/js/admin_artifact_form.js
new file mode 100644
index 00000000..4d9cb67a
--- /dev/null
+++ b/website/static/website/js/admin_artifact_form.js
@@ -0,0 +1,407 @@
+/**
+ * Client-side guard + drag-and-drop for the artifact (Talk / Poster / Publication)
+ * admin add/change forms. Addresses issue #248.
+ *
+ * Why this exists
+ * ---------------
+ * The Django admin renders its change form with the `novalidate` attribute, which
+ * disables the browser's native HTML5 constraint validation. So even though Django
+ * emits `required` on every form-required field, the browser does NOT block a submit
+ * that's missing one. The POST goes to the server, validation fails, the form is
+ * re-rendered with errors — and any file the user had selected is silently dropped
+ * (the browser never re-sends a file input's value, and Django can't repopulate it).
+ * Re-uploading the lost PDF/PPTX after fixing an unrelated required field is the
+ * exact pain reported in #248.
+ *
+ * What this does (all progressive enhancement — the form still works without JS):
+ * 1. Required-field guard: on submit, blocks the POST if any required field is
+ * empty, shows an accessible summary, and scrolls to the first offender — so
+ * the round-trip that loses files never happens.
+ * 2. File pre-checks: on selection, validates extension (against the field's
+ * `accept` list) and, for PDF-only fields, the `%PDF-` signature — mirroring
+ * the server validators so a bad file is caught before it costs a round-trip.
+ * 3. A standard drag-and-drop upload zone per file field: the raw file input is
+ * hidden (but kept focusable), the zone is the primary control with idle /
+ * hover / drag-over / filled / error states, and the selected file is shown
+ * with its name, size, and a Remove/Replace control.
+ *
+ * Accessibility: the native stays in the DOM, focusable, and
+ * keeps its