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