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
40 changes: 39 additions & 1 deletion website/admin/artifact_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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:
Expand Down
168 changes: 168 additions & 0 deletions website/static/website/css/admin_artifact_form.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading