diff --git a/.gitignore b/.gitignore
index 3223115ac4..28fc64f2d4 100755
--- a/.gitignore
+++ b/.gitignore
@@ -150,3 +150,5 @@ src/utils/management/commands/test_command.py
/src/static/admin/hypothesis/**
jenkins/test-results
jenkins/test_results
+
+src/file_editor
diff --git a/src/comms/models.py b/src/comms/models.py
index 0b72c3115a..f01bbde03d 100755
--- a/src/comms/models.py
+++ b/src/comms/models.py
@@ -7,11 +7,12 @@
from django.http import Http404
from django.utils.translation import gettext as _
from django.templatetags.static import static
-from simple_history.models import HistoricalRecords
from django.utils.html import mark_safe
+from django.utils.html import strip_tags
from core import files
from core.model_utils import JanewayBleachField, JanewayBleachCharField
+from core.templatetags import alt_text
__copyright__ = "Copyright 2017 Birkbeck, University of London"
__author__ = "Martin Paul Eve & Andy Byers"
@@ -182,6 +183,32 @@ def best_large_image_url(self):
"""
return self.best_image_url
+ def best_large_image_alt_text(self):
+ default_text = strip_tags(self.title)
+ if self.large_image_file:
+ return alt_text.get_alt_text(
+ obj=self.large_image_file,
+ default=default_text,
+ )
+ elif self.content_type.name == "press" and self.object.default_carousel_image:
+ return alt_text.get_alt_text(
+ file_path=self.object.default_carousel_image.url,
+ default=default_text,
+ )
+ elif self.content_type.name == "journal":
+ if self.object.default_large_image:
+ return alt_text.get_alt_text(
+ file_path=self.object.default_large_image.url,
+ default=default_text,
+ )
+ elif self.object.press.default_carousel_image:
+ return alt_text.get_alt_text(
+ file_path=self.object.press.default_carousel_image.url,
+ default=default_text,
+ )
+
+ return default_text
+
def __str__(self):
if self.posted_by:
return "{0} posted by {1} on {2}".format(
diff --git a/src/core/admin.py b/src/core/admin.py
index 740e7ae5ea..19942e58f9 100755
--- a/src/core/admin.py
+++ b/src/core/admin.py
@@ -737,6 +737,22 @@ def _person(self, obj):
return ""
+class AltTextAdmin(admin.ModelAdmin):
+ list_display = (
+ "content_type",
+ "object_id",
+ "file_path",
+ "alt_text",
+ "created",
+ "updated",
+ )
+ search_fields = (
+ "alt_text",
+ "file_path",
+ )
+ list_filter = ("content_type",)
+
+
admin_list = [
(models.AccountRole, AccountRoleAdmin),
(models.Account, AccountAdmin),
@@ -773,6 +789,7 @@ def _person(self, obj):
(models.OrganizationName, OrganizationNameAdmin),
(models.Location, LocationAdmin),
(models.ControlledAffiliation, ControlledAffiliationAdmin),
+ (models.AltText, AltTextAdmin),
]
[admin.site.register(*t) for t in admin_list]
diff --git a/src/core/forms/__init__.py b/src/core/forms/__init__.py
index c7bf9a113e..c659cdcff4 100644
--- a/src/core/forms/__init__.py
+++ b/src/core/forms/__init__.py
@@ -37,4 +37,5 @@
SimpleTinyMCEForm,
UserCreationFormExtended,
XSLFileForm,
+ AltTextForm,
)
diff --git a/src/core/forms/forms.py b/src/core/forms/forms.py
index 2063fba894..014a52ed1c 100755
--- a/src/core/forms/forms.py
+++ b/src/core/forms/forms.py
@@ -10,7 +10,6 @@
from django import forms
from django.db.models import Q
from django.utils.datastructures import MultiValueDict
-from django.forms.fields import Field
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.forms import UserCreationForm
@@ -775,7 +774,7 @@ def __init__(self, *args, **kwargs):
except:
result = None
- if result != None:
+ if result is not None:
values_list.append(result)
elif result == None and "default" in facet:
values_list.append(facet["default"])
@@ -1152,3 +1151,87 @@ class ConfirmDeleteForm(forms.Form):
"""
pass
+
+
+class AltTextForm(forms.ModelForm):
+ class Meta:
+ model = models.AltText
+ fields = [
+ "alt_text",
+ ]
+ widgets = {
+ "alt_text": forms.Textarea(
+ attrs={"rows": 5},
+ ),
+ }
+
+ def __init__(
+ self,
+ *args,
+ content_type=None,
+ object_id=None,
+ file_path=None,
+ **kwargs,
+ ):
+ if "initial" not in kwargs:
+ kwargs["initial"] = {}
+
+ # Populate initial to help form rendering
+ if content_type and object_id:
+ kwargs["initial"].update(
+ {
+ "content_type": content_type,
+ "object_id": object_id,
+ }
+ )
+ elif file_path:
+ kwargs["initial"].update(
+ {
+ "file_path": file_path,
+ }
+ )
+
+ super().__init__(*args, **kwargs)
+
+ # Set these on the form so we can assign them to the instance in save()
+ self.content_type = content_type
+ self.object_id = object_id
+ self.file_path = file_path
+
+ def clean(self):
+ cleaned_data = super().clean()
+ self.instance.content_type = self.content_type
+ self.instance.object_id = self.object_id
+ self.instance.file_path = self.file_path
+ return cleaned_data
+
+ def save(self, commit=True):
+ # Attempt to find an existing instance to update
+ existing = None
+
+ if self.content_type and self.object_id:
+ existing = models.AltText.objects.filter(
+ content_type=self.content_type,
+ object_id=self.object_id,
+ ).first()
+
+ elif self.file_path:
+ existing = models.AltText.objects.filter(
+ file_path=self.file_path,
+ ).first()
+
+ # If existing, update its fields
+ if existing:
+ existing.alt_text = self.cleaned_data["alt_text"]
+ instance = existing
+ else:
+ instance = super().save(commit=False)
+ instance.content_type = self.content_type
+ instance.object_id = self.object_id
+ instance.file_path = self.file_path
+
+ if commit:
+ instance.full_clean()
+ instance.save()
+
+ return instance
diff --git a/src/core/include_urls.py b/src/core/include_urls.py
index 165c320da9..1d8ba33075 100644
--- a/src/core/include_urls.py
+++ b/src/core/include_urls.py
@@ -11,7 +11,7 @@
from django.views.decorators.cache import cache_page
from journal import urls as journal_urls
-from core import views as core_views, plugin_loader
+from core import views as core_views, plugin_loader, partial_views
from utils import notify
from press import views as press_views
from cms import views as cms_views
@@ -431,6 +431,9 @@
core_views.manage_access_requests,
name="manage_access_requests",
),
+ # Partial views used for HTMX
+ path("alt-text/form/", partial_views.alt_text_form, name="alt_text_form"),
+ path("alt-text/submit/", partial_views.alt_text_submit, name="alt_text_submit"),
]
# Journal homepage block loading
diff --git a/src/core/janeway_global_settings.py b/src/core/janeway_global_settings.py
index 0343c2f73f..5e87ccfc40 100755
--- a/src/core/janeway_global_settings.py
+++ b/src/core/janeway_global_settings.py
@@ -165,6 +165,7 @@
],
"builtins": [
"core.templatetags.fqdn",
+ "core.templatetags.alt_text",
"security.templatetags.securitytags",
"django.templatetags.i18n",
],
diff --git a/src/core/logic.py b/src/core/logic.py
index 85479724e1..38f2636141 100755
--- a/src/core/logic.py
+++ b/src/core/logic.py
@@ -11,18 +11,19 @@
import operator
import re
from functools import reduce
-from urllib.parse import unquote, urlparse
from django.conf import settings
from django.contrib.auth import logout
from django.contrib import messages
from django.template.loader import get_template
from django.db.models import Q
-from django.http import JsonResponse, QueryDict
+from django.http import JsonResponse
from django.forms.models import model_to_dict
from django.shortcuts import reverse
from django.utils import timezone
from django.utils.translation import get_language, gettext_lazy as _
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
from core import forms, models, files, plugin_installed_apps
from utils.function_cache import cache
@@ -1257,3 +1258,39 @@ def create_organization_name(request):
% {"organization": organization_name},
)
return organization_name
+
+
+def resolve_alt_text_target(request):
+ """
+ Resolve the content_type, object_id, file_path, and object instance
+ from the request data (POST or GET). Expects 'model', 'pk', and/or 'file_path'.
+
+ Returns:
+ (content_type, object_id, file_path, obj)
+
+ Raises:
+ ValidationError if model or pk is invalid.
+ """
+ data = request.POST or request.GET
+
+ model = data.get("model")
+ pk = data.get("pk")
+ file_path = data.get("file_path")
+
+ content_type = None
+ object_id = None
+ obj = None
+
+ if model and pk:
+ if "." not in model:
+ raise ValidationError("Model should be in the form 'app_label.model_name'.")
+
+ app_label, model_name = model.split(".")
+ content_type = ContentType.objects.get(
+ app_label=app_label,
+ model=model_name,
+ )
+ object_id = int(pk)
+ obj = content_type.get_object_for_this_type(pk=object_id)
+
+ return content_type, object_id, file_path, obj
diff --git a/src/core/migrations/0110_alttext.py b/src/core/migrations/0110_alttext.py
new file mode 100644
index 0000000000..e534bce610
--- /dev/null
+++ b/src/core/migrations/0110_alttext.py
@@ -0,0 +1,61 @@
+# Generated by Django 4.2.20 on 2026-01-28 13:57
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("contenttypes", "0002_remove_content_type_name"),
+ ("core", "0109_salutation_name_20250707_1420"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AltText",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("object_id", models.PositiveIntegerField(blank=True, null=True)),
+ (
+ "file_path",
+ models.CharField(
+ blank=True,
+ help_text="Path to a file for alt text fallback (e.g., /media/image.jpg).",
+ max_length=500,
+ null=True,
+ unique=True,
+ ),
+ ),
+ (
+ "alt_text",
+ models.TextField(
+ help_text="Descriptive alternative text for screen readers."
+ ),
+ ),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("updated", models.DateTimeField(auto_now=True)),
+ (
+ "content_type",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="contenttypes.contenttype",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Alt text",
+ "verbose_name_plural": "Alt texts",
+ "unique_together": {("content_type", "object_id")},
+ },
+ ),
+ ]
diff --git a/src/core/models.py b/src/core/models.py
index e02a2ab8b3..0e5d917d87 100644
--- a/src/core/models.py
+++ b/src/core/models.py
@@ -12,10 +12,7 @@
from django.utils.html import format_html
import pytz
from hijack.signals import hijack_started, hijack_ended
-from iso639 import Lang
-from iso639.exceptions import InvalidLanguageValue
import warnings
-import tqdm
import zipfile
from bs4 import BeautifulSoup
@@ -35,15 +32,13 @@
from django.utils import timezone
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
-from django.contrib.postgres.search import SearchVector, SearchVectorField
+from django.contrib.postgres.search import SearchVectorField
from django.core.validators import validate_email
from django.utils.translation import gettext_lazy as _
-from django.db.models import F
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils.functional import cached_property
-from django.template.defaultfilters import date
import swapper
from core import files, validators
@@ -54,9 +49,7 @@
AffiliationCompatibleQueryset,
DynamicChoiceField,
JanewayBleachField,
- JanewayBleachCharField,
PGCaseInsensitiveEmailField,
- SearchLookup,
default_press_id,
check_exclusive_fields_constraint,
)
@@ -65,8 +58,6 @@
from repository import models as repository_models
from utils.models import RORImportError
from submission import models as submission_models
-from utils.forms import clean_orcid_id
-from submission.models import CreditRecord
from utils.logger import get_logger
from utils import logic as utils_logic
from utils.forms import plain_text_validator
@@ -1179,7 +1170,7 @@ def process_value(self):
elif self.setting.types == "json" and self.value:
try:
return json.loads(self.value)
- except json.JSONDecodeError as e:
+ except json.JSONDecodeError:
logger.error(
"Error loading JSON setting {setting_name} on {site_name} site.".format(
setting_name=self.setting.name,
@@ -1670,7 +1661,7 @@ def render_crossref(self):
return files.render_xml(self.file, self.article, xsl_path=xsl_path)
def has_missing_image_files(self, show_all=False):
- if not self.file.mime_type in files.MIMETYPES_WITH_FIGURES:
+ if self.file.mime_type not in files.MIMETYPES_WITH_FIGURES:
return []
xml_file_contents = self.file.get_file(self.article)
@@ -2345,7 +2336,7 @@ def bulk_create_from_ror(self, ror_records):
for org in Organization.objects.filter(~models.Q(ror_id__exact=""))
}
organization_names = []
- logger.debug(f"Importing organization names")
+ logger.debug("Importing organization names")
for record in ror_records:
ror_id = os.path.split(record.get("id", ""))[-1]
organization = organizations_by_ror_id[ror_id]
@@ -2384,7 +2375,7 @@ def bulk_update_from_ror(self, ror_records):
ror_records_with_updated_names = []
org_name_pks_to_delete = set()
- logger.debug(f"Updating names")
+ logger.debug("Updating names")
for record in ror_records:
ror_id = os.path.split(record.get("id", ""))[-1]
organization = organizations_by_ror_id[ror_id]
@@ -2609,7 +2600,7 @@ def bulk_link_locations_from_ror(self, ror_records):
)
}
organization_location_links = []
- logger.debug(f"Linking locations")
+ logger.debug("Linking locations")
for record in ror_records:
ror_id = os.path.split(record.get("id", ""))[-1]
organization = organizations_by_ror_id[ror_id]
@@ -2640,7 +2631,7 @@ def bulk_link_locations_from_ror(self, ror_records):
def bulk_create_from_ror(self, ror_records):
new_organizations = []
- logger.debug(f"Importing organizations")
+ logger.debug("Importing organizations")
for record in ror_records:
ror_id = os.path.split(record.get("id", ""))[-1]
last_modified = record.get("admin", {}).get("last_modified", {})
@@ -2670,7 +2661,7 @@ def bulk_update_from_ror(self, ror_records):
}
organizations_to_update = []
fields_to_update = set()
- logger.debug(f"Updating organizations")
+ logger.debug("Updating organizations")
for record in ror_records:
ror_id = os.path.split(record.get("id", ""))[-1]
organization = organizations_by_ror_id[ror_id]
@@ -3207,7 +3198,7 @@ def bulk_create_from_ror(self, ror_records):
)
countries_by_code = {country.code: country for country in Country.objects.all()}
new_locations = []
- logger.debug(f"Importing locations")
+ logger.debug("Importing locations")
for record in ror_records:
for record_location in record.get("locations"):
geonames_id = record_location.get("geonames_id")
@@ -3241,7 +3232,7 @@ def bulk_update_from_ror(self, ror_records):
locations_to_update = []
ror_records_with_new_loc = []
fields_to_update = set()
- logger.debug(f"Updating locations")
+ logger.debug("Updating locations")
for record in ror_records:
for record_location in record.get("locations"):
geonames_id = record_location.get("geonames_id")
@@ -3295,3 +3286,101 @@ def __str__(self):
str(self.country) if self.country else "",
]
return ", ".join([element for element in elements if element])
+
+
+class AltText(models.Model):
+ content_type = models.ForeignKey(
+ ContentType,
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ )
+ object_id = models.PositiveIntegerField(
+ null=True,
+ blank=True,
+ )
+ content_object = GenericForeignKey(
+ "content_type",
+ "object_id",
+ )
+ file_path = models.CharField(
+ max_length=500,
+ blank=True,
+ null=True,
+ help_text="Path to a file for alt text fallback (e.g., /media/image.jpg).",
+ unique=True,
+ )
+ alt_text = models.TextField(
+ help_text="Descriptive alternative text for screen readers.",
+ )
+ created = models.DateTimeField(
+ auto_now_add=True,
+ )
+ updated = models.DateTimeField(
+ auto_now=True,
+ )
+
+ class Meta:
+ unique_together = [
+ ("content_type", "object_id"),
+ ]
+ verbose_name = "Alt text"
+ verbose_name_plural = "Alt texts"
+
+ def __str__(self):
+ return self.alt_text
+
+ def clean(self):
+ """
+ Ensure either a GFK (content_type + object_id) OR file_path is set — not both
+ or neither.
+ """
+ has_gfk = self.content_type is not None and self.object_id is not None
+ has_path = bool(self.file_path)
+
+ if has_gfk and has_path:
+ raise ValidationError(
+ "Provide either a related object or a file path — not both."
+ )
+
+ if not has_gfk and not has_path:
+ raise ValidationError(
+ "You must provide either a related object or a file path."
+ )
+
+ def save(self, *args, **kwargs):
+ self.clean()
+ super().save(*args, **kwargs)
+
+ @classmethod
+ def get_text(
+ cls,
+ obj=None,
+ path=None,
+ ):
+ """
+ Retrieve alt text for a model instance or file path.
+ """
+ if obj is not None:
+ try:
+ content_type = ContentType.objects.get_for_model(obj)
+ object_id = obj.pk
+ qs = cls.objects.filter(
+ content_type=content_type,
+ object_id=object_id,
+ )
+
+ match = qs.first()
+ if match:
+ return match.alt_text
+ except Exception:
+ pass
+
+ if path:
+ qs = cls.objects.filter(file_path=path)
+
+ match = qs.first()
+ if match:
+ return match.alt_text
+
+ return ""
diff --git a/src/core/partial_views.py b/src/core/partial_views.py
new file mode 100644
index 0000000000..bd30772285
--- /dev/null
+++ b/src/core/partial_views.py
@@ -0,0 +1,86 @@
+from django.shortcuts import render
+from django.http import HttpResponseBadRequest
+from django.views.decorators.http import require_GET, require_POST
+from django.core.exceptions import ValidationError
+
+from core import forms, models, logic
+from security.decorators import editor_or_journal_manager_required
+
+
+@require_GET
+@editor_or_journal_manager_required
+def alt_text_form(request):
+ try:
+ content_type, object_id, file_path, obj = logic.resolve_alt_text_target(
+ request,
+ )
+ except ValidationError:
+ return HttpResponseBadRequest("Invalid model or pk")
+
+ instance = models.AltText.objects.filter(
+ content_type=content_type,
+ object_id=object_id,
+ file_path=file_path,
+ ).first()
+
+ form = forms.AltTextForm(
+ instance=instance,
+ content_type=content_type,
+ object_id=object_id,
+ file_path=file_path,
+ )
+
+ return render(
+ request,
+ "core/partials/alt_text/form.html",
+ {
+ "form": form,
+ "object": obj,
+ "file_path": file_path,
+ "token": file_path,
+ },
+ )
+
+
+@require_POST
+@editor_or_journal_manager_required
+def alt_text_submit(request):
+ try:
+ content_type, object_id, file_path, obj = logic.resolve_alt_text_target(request)
+ except ValidationError:
+ return HttpResponseBadRequest("Invalid model or pk")
+
+ instance = models.AltText.objects.filter(
+ content_type=content_type,
+ object_id=object_id,
+ file_path=file_path,
+ ).first()
+
+ form = forms.AltTextForm(
+ request.POST,
+ instance=instance,
+ content_type=content_type,
+ object_id=object_id,
+ file_path=file_path,
+ )
+
+ if form.is_valid():
+ form.save()
+ return render(
+ request,
+ "core/partials/alt_text/edit_alt_text_button.html",
+ {
+ "object": obj,
+ "token": file_path,
+ },
+ )
+
+ return render(
+ request,
+ "core/partials/alt_text/form.html",
+ {
+ "form": form,
+ "object": obj,
+ "file_path": file_path,
+ },
+ )
diff --git a/src/core/templatetags/alt_text.py b/src/core/templatetags/alt_text.py
new file mode 100644
index 0000000000..7b2ef93c3e
--- /dev/null
+++ b/src/core/templatetags/alt_text.py
@@ -0,0 +1,135 @@
+import hashlib
+
+from django import template
+from django.template.defaultfilters import slugify
+from django.template.loader import render_to_string
+
+from core import models
+
+register = template.Library()
+
+
+@register.filter
+def encode_file_path(value):
+ """
+ Encode a file path string to an MD5 hash.
+
+ :param value: File path string to encode
+ :return: MD5 hexadecimal hash of the value, or empty string if value is falsy
+ """
+ if not value:
+ return ""
+ return hashlib.md5(value.encode()).hexdigest()
+
+
+@register.simple_tag
+def get_alt_text(obj=None, file_path=None, token=None, default=""):
+ """
+ Render alt text for a file given a context phrase.
+ Priority order for identifier: file_path > token > None.
+ file_path will be hashed using MD5 before lookup.
+
+ :param obj: Model instance to associate with the alt text
+ :param file_path: File path string to hash and use as identifier
+ :param token: Pre-computed token/hash to use as identifier
+ :param default: Default text to return if no alt text is found
+ :return: The alt text string, default value if alt text is empty, or empty string
+ """
+ if file_path:
+ path = encode_file_path(file_path.strip())
+ elif token:
+ path = token
+ else:
+ path = None
+
+ alt_text = models.AltText.get_text(
+ obj=obj,
+ path=path,
+ )
+
+ if alt_text == "" and default:
+ return default
+
+ return str(alt_text)
+
+
+@register.simple_tag
+def get_admin_alt_text_snippet(obj=None, file_path=None, token=None):
+ """
+ Render a block of alt text wrapped in HTML for admin interface with HTMX targeting.
+ Priority order for identifier: file_path > token > None.
+ file_path will be hashed using MD5 before lookup.
+
+ :param obj: Model instance to associate with the alt text
+ :param file_path: File path string to hash and use as identifier
+ :param token: Pre-computed token/hash to use as identifier
+ :return: Rendered HTML string from the alt_text_snippet.html template
+ """
+ if file_path:
+ path = encode_file_path(file_path.strip())
+ elif token:
+ path = token
+ else:
+ path = None
+
+ alt_text = models.AltText.get_text(
+ obj=obj,
+ path=path,
+ )
+
+ return render_to_string(
+ "core/partials/alt_text/alt_text_snippet.html",
+ {
+ "alt_text": alt_text,
+ "object": obj,
+ "file_path": file_path,
+ "token": token,
+ },
+ )
+
+
+@register.filter
+def model_string(obj):
+ """
+ Return the 'app_label.modelname' string for a Django model instance.
+
+ :param obj: Model instance
+ :return: String in format 'app_label.modelname' or empty string if obj has no _meta
+ """
+ if hasattr(obj, "_meta"):
+ return f"{obj._meta.app_label}.{obj._meta.model_name}"
+ return ""
+
+
+@register.filter
+def app_label(obj):
+ """
+ Returns the app_label for a Django model instance.
+
+ :param obj: Model instance
+ :return: The app_label string or empty string if obj has no _meta attribute
+ """
+ try:
+ return obj._meta.app_label
+ except AttributeError:
+ return ""
+
+
+@register.simple_tag
+def get_id_token(obj=None, file_path=None, token=None):
+ """
+ Returns a slugified identifier string based on a model instance, token, or file path.
+ Priority order: obj > token > file_path > fallback.
+
+ :param obj: Model instance to generate ID from
+ :param file_path: File path string to hash and use as identifier
+ :param token: Pre-existing token/hash to use as identifier
+ :return: Slugified identifier string or 'unknown-id-token' if no arguments provided
+ """
+ if obj:
+ return slugify(f"{model_string(obj)}-{obj.pk}")
+ elif token:
+ return token
+ elif file_path:
+ return encode_file_path(file_path)
+ return "unknown-id-token"
diff --git a/src/journal/models.py b/src/journal/models.py
index 731dd6d7f5..e149f52df4 100644
--- a/src/journal/models.py
+++ b/src/journal/models.py
@@ -34,6 +34,7 @@
from django.utils import timezone, translation
from django.utils.functional import cached_property
from django.utils.translation import gettext
+from django.utils.html import strip_tags
from core import (
files,
@@ -48,6 +49,7 @@
JanewayBleachField,
JanewayBleachCharField,
)
+from core.templatetags import alt_text
from press import models as press_models
from submission import models as submission_models
from utils import (
@@ -964,6 +966,13 @@ def best_large_image_url(self):
"""
return self.hero_image_url
+ @property
+ def best_large_image_alt_text(self):
+ return alt_text.get_alt_text(
+ file_path=self.best_large_image_url,
+ default=strip_tags(self.display_title),
+ )
+
@property
def date_published(self):
return datetime.datetime(
@@ -1113,7 +1122,7 @@ def manage_issue_list(self):
)
for article in articles:
- if not article in article_list:
+ if article not in article_list:
article_list.append(article)
section_article_dict[ordered_section.section] = article_list
@@ -1141,7 +1150,7 @@ def all_sections(self):
articles = self.articles.all().order_by("section")
for article in articles:
- if not article.section in ordered_sections:
+ if article.section not in ordered_sections:
ordered_sections.append(article.section)
return ordered_sections
@@ -1206,7 +1215,7 @@ def structure(self):
article_list.append(order.article)
for article in articles.filter(section=section):
- if not article in article_list:
+ if article not in article_list:
article_list.append(article)
structure[section] = article_list
diff --git a/src/journal/views.py b/src/journal/views.py
index e9491ad805..01b9b06cba 100755
--- a/src/journal/views.py
+++ b/src/journal/views.py
@@ -2294,7 +2294,6 @@ def old_search(request):
if redir:
return redir
- from itertools import chain
if search_term:
escaped = re.escape(search_term)
diff --git a/src/metrics/management/commands/export_article_accesses.py b/src/metrics/management/commands/export_article_accesses.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/static/admin/css/admin.css b/src/static/admin/css/admin.css
index cc49bab8a5..98abb7db03 100644
--- a/src/static/admin/css/admin.css
+++ b/src/static/admin/css/admin.css
@@ -595,7 +595,7 @@ th {
}
.no-bottom-margin {
- margin-bottom: 0;
+ margin-bottom: 0 !important;
}
.no-top-margin {
@@ -1089,3 +1089,64 @@ ul.menu {
grid-template-columns: 20rem minmax(auto, 60rem);
}
}
+
+/*
+ * Accessible Modals
+ * Uses the dialog tag.
+ * */
+
+dialog[open] {
+ position: fixed;
+ inset: 0;
+ margin: auto;
+ padding: 1.5rem;
+ background: white;
+ border: none;
+ border-radius: 4px;
+ max-width: 600px;
+ width: 90%;
+ box-shadow: 0 2px 15px rgba(0, 0, 0, 0.3);
+ z-index: 9999;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+dialog::backdrop {
+ background: rgba(0, 0, 0, 0.4);
+}
+
+.modal-close-button {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.75rem;
+ font-size: 1.5rem;
+ background: none;
+ border: none;
+ cursor: pointer;
+ line-height: 1;
+ padding: 0;
+ color: #333;
+}
+
+.modal-close-button:hover {
+ color: #000;
+}
+
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.alt-text-container {
+ clear: right;
+}
+
diff --git a/src/submission/models.py b/src/submission/models.py
index 583d6bc87a..b8e1e90146 100755
--- a/src/submission/models.py
+++ b/src/submission/models.py
@@ -38,6 +38,7 @@
from django.core import exceptions
from django.utils.functional import cached_property
from django.utils.html import mark_safe
+from django.utils.html import strip_tags
import swapper
from core.file_system import JanewayFileSystemStorage
@@ -52,6 +53,7 @@
)
from core import workflow, model_utils, files, models as core_models
from core.templatetags.truncate import truncatesmart
+from core.templatetags import alt_text
from identifiers import logic as id_logic
from identifiers import models as identifier_models
from metrics.logic import ArticleMetrics
@@ -2590,6 +2592,28 @@ def best_large_image_url(self):
else:
return static(settings.HERO_IMAGE_FALLBACK)
+ @property
+ def best_large_image_alt_text(self):
+ default_text = strip_tags(self.title)
+ if self.large_image_file:
+ return alt_text.get_alt_text(
+ obj=self.large_image_file,
+ default=default_text,
+ )
+ elif self.issue and self.issue.large_image:
+ return self.issue.best_large_image_alt_text
+ elif self.journal.default_large_image:
+ return alt_text.get_alt_text(
+ file_path=self.journal.default_large_image.url,
+ default=default_text,
+ )
+ elif self.journal.press.default_carousel_image:
+ return alt_text.get_alt_text(
+ file_path=self.journal.press.default_carousel_image.url,
+ default=default_text,
+ )
+ return default_text
+
class FrozenAuthorQueryset(model_utils.AffiliationCompatibleQueryset):
AFFILIATION_RELATED_NAME = "frozen_author"
diff --git a/src/templates/admin/core/manager/images/article_image.html b/src/templates/admin/core/manager/images/article_image.html
index 20d2a2623c..81c55282b8 100644
--- a/src/templates/admin/core/manager/images/article_image.html
+++ b/src/templates/admin/core/manager/images/article_image.html
@@ -29,6 +29,8 @@
Current Large Image
Delete File
+
+ {% include "core/partials/alt_text/edit_alt_text_button.html" with object=article.large_image_file %}
{% endif %}
@@ -67,6 +69,8 @@ Current Thumbnail
Delete File
+
+ {% include "core/partials/alt_text/edit_alt_text_button.html" with object=article.thumbnail_image_file %}
{% endif %}
@@ -104,3 +108,7 @@ Article Meta Image
{% endblock body %}
+
+{% block js %}
+{% include "admin/core/partials/htmx.html" %}
+{% endblock js %}
\ No newline at end of file
diff --git a/src/templates/admin/core/partials/alt_text/alt_text_snippet.html b/src/templates/admin/core/partials/alt_text/alt_text_snippet.html
new file mode 100644
index 0000000000..bbd17c8f29
--- /dev/null
+++ b/src/templates/admin/core/partials/alt_text/alt_text_snippet.html
@@ -0,0 +1,16 @@
+{% load alt_text %}
+
+{% get_id_token object file_path token as id_token %}
+
+{% if object or file_path or token %}
+
+ {{ alt_text|default:"No alt text is set." }}
+
+{% else %}
+
+ No alt text context provided.
+
+{% endif %}
\ No newline at end of file
diff --git a/src/templates/admin/core/partials/alt_text/edit_alt_text_button.html b/src/templates/admin/core/partials/alt_text/edit_alt_text_button.html
new file mode 100644
index 0000000000..a16f7a6ae1
--- /dev/null
+++ b/src/templates/admin/core/partials/alt_text/edit_alt_text_button.html
@@ -0,0 +1,32 @@
+{% comment %}
+ Usage:
+ {% include "core/partials/alt_text/edit_alt_text_button.html" with object=obj %}
+ or
+ {% include "core/partials/alt_text/edit_alt_text_button.html" with file_path=image.url %}
+{% endcomment %}
+
+{% load alt_text %}
+
+{% get_id_token object file_path token as id_token %}
+
+
+
+
+
+
+ {% get_admin_alt_text_snippet obj=object file_path=file_path token=token as alt_text %}
+
+ {% include "admin/elements/layout/key_value_above.html" with key="Alt Text" value=alt_text no_bottom_margin="True" %}
+
+
+
+ Edit alt text
+
+
+
diff --git a/src/templates/admin/core/partials/alt_text/form.html b/src/templates/admin/core/partials/alt_text/form.html
new file mode 100644
index 0000000000..531a7a1be3
--- /dev/null
+++ b/src/templates/admin/core/partials/alt_text/form.html
@@ -0,0 +1,49 @@
+{% extends "core/partials/modal.html" %}
+{% load alt_text %}
+
+{% block modal_id %}{% get_id_token object file_path token as id_token %}alt-text-modal-{{ id_token }}{% endblock modal_id %}
+
+{% block modal_class %}modal{% endblock modal_class %}
+
+{% block modal_title %}
+ Edit alternative text
+{% endblock %}
+
+{% block modal_description %}
+ Use this form to provide or update alternative text for the image. Alt text helps screen reader users understand the content and function of the image.
+{% endblock %}
+
+{% block modal_content %}
+ {% get_id_token object file_path token as id_token %}
+
+
+{% endblock %}
diff --git a/src/templates/admin/core/partials/htmx.html b/src/templates/admin/core/partials/htmx.html
new file mode 100644
index 0000000000..e7579592f9
--- /dev/null
+++ b/src/templates/admin/core/partials/htmx.html
@@ -0,0 +1,5 @@
+
diff --git a/src/templates/admin/core/partials/modal.html b/src/templates/admin/core/partials/modal.html
new file mode 100644
index 0000000000..86126ae670
--- /dev/null
+++ b/src/templates/admin/core/partials/modal.html
@@ -0,0 +1,67 @@
+{% comment %}
+ Reusable accessible modal template.
+
+ Usage:
+
+ 1. Include with overrides:
+ {% include "core/partials/modal.html" withmodal_id="my-modal-id"modal_class="my-css-classes" %}
+
+ 2. Extend and override blocks:
+ {% extends "core/partials/modal.html" %}
+
+ {% block modal_id %}your-modal-id{% endblock modal_id %}
+
+ {% block modal_class %}your-modal-class{% endblock modal_class %}
+
+ {% block modal_title %}
+ My Modal Title
+ {% endblock %}
+
+ {% block modal_description %}
+ Content that describes the purpose of this modal.
+ {% endblock modal_description %}
+
+ {% block modal_content %}
+ {% endblock %}
+
+{% endcomment %}
+
+{% with modal_id|default:"modal" as id %}
+ {% with modal_class|default:"modal" as class %}
+
+
+
+
+ {% block modal_title %}{% endblock modal_title %}
+
+
+
+ ×
+ close
+
+
+
+ {% block modal_description %}
+ {% endblock modal_description %}
+
+
+ {% block modal_content %}
+ {% endblock %}
+
+
+
+
+ {% endwith %}
+{% endwith %}
diff --git a/src/templates/admin/elements/forms/group_journal_images.html b/src/templates/admin/elements/forms/group_journal_images.html
index 5c97a63b96..9c90cd64db 100644
--- a/src/templates/admin/elements/forms/group_journal_images.html
+++ b/src/templates/admin/elements/forms/group_journal_images.html
@@ -3,94 +3,107 @@
{% load static %}
-
Journal Images
+ Journal Images
-
-
-
-
- {% include "admin/elements/forms/field.html" with field=attr_form.header_image %}
-
-
- {% if request.journal.header_image %}
-
- {% endif %}
-
-
-
-
- {% include "admin/elements/forms/field.html" with field=attr_form.default_large_image %}
-
-
- {% if request.journal.default_large_image %}
-
- {% endif %}
-
-
+
+
+
+
+ {% include "admin/elements/forms/field.html" with field=attr_form.header_image %}
+
+
+ {% if request.journal.header_image %}
+
+ {% include "core/partials/alt_text/edit_alt_text_button.html" with file_path=request.journal.header_image.url %}
+ {% endif %}
+
+
-
- {% include "admin/elements/forms/field.html" with field=attr_form.press_image_override %}
-
-
- {% if request.journal.press_image_override %}
- {% svg_or_image request.journal.press_image_override "thumbnail" %}
- {% elif 'svg' in request.press_cover %}
- {% svg request.press_cover %}
- {% else %}
- {% svg "static/common/img/sample/janeway.svg" %}
- {% endif %}
-
-
+
+ {% include "admin/elements/forms/field.html" with field=attr_form.default_large_image %}
+
+
+ {% if request.journal.default_large_image %}
+
+ {% include "core/partials/alt_text/edit_alt_text_button.html" with file_path=request.journal.default_large_image.url %}
+ {% endif %}
+
+
-
- {% include "admin/elements/forms/field.html" with field=attr_form.default_cover_image %}
-
-
- {% if request.journal.default_cover_image %}
-
- {% endif %}
-
-
+
+ {% include "admin/elements/forms/field.html" with field=attr_form.press_image_override %}
+
+
+ {% if request.journal.press_image_override %}
+ {% svg_or_image request.journal.press_image_override "thumbnail" %}
+ {% elif 'svg' in request.press_cover %}
+ {% svg request.press_cover %}
+ {% else %}
+ {% svg "static/common/img/sample/janeway.svg" %}
+ {% endif %}
+
+
+
+ {% include "admin/elements/forms/field.html" with field=attr_form.default_cover_image %}
+
+
+ {% if request.journal.default_cover_image %}
+
+ {% include "core/partials/alt_text/edit_alt_text_button.html" with file_path=request.journal.default_cover_image.url %}
+ {% endif %}
+
+
-
- {% include "admin/elements/forms/field.html" with field=attr_form.default_thumbnail %}
-
-
- {% if request.journal.header_image %}
-
- {% endif %}
-
-
-
- {% include "admin/elements/forms/field.html" with field=attr_form.favicon %}
-
-
- {% if request.journal.favicon %}
-
- {% endif %}
-
-
+
+ {% include "admin/elements/forms/field.html" with field=attr_form.default_thumbnail %}
+
+
+ {% if request.journal.thumbnail_image %}
+
+ {% include "core/partials/alt_text/edit_alt_text_button.html" with object=request.journal.thumbnail_image %}
+ {% endif %}
+
+
-
- {% include "admin/elements/forms/field.html" with field=attr_form.default_profile_image %}
-
-
- {% if request.journal.default_profile_image %}
-
- {% else %}
-
- {% endif %}
-
-
+
+ {% include "admin/elements/forms/field.html" with field=attr_form.favicon %}
+
+
+ {% if request.journal.favicon %}
+
+ {% endif %}
+
+
+
+ {% include "admin/elements/forms/field.html" with field=attr_form.default_profile_image %}
+
+
+ {% if request.journal.default_profile_image %}
+
+ {% include "core/partials/alt_text/edit_alt_text_button.html" with file_path=request.journal.default_profile_image.url %}
+ {% else %}
+
+ {% endif %}
+
+
+
+
+{% block js %}
+ {% include "admin/core/partials/htmx.html" %}
+{% endblock js %}
\ No newline at end of file
diff --git a/src/templates/admin/elements/issue/issue_modal.html b/src/templates/admin/elements/issue/issue_modal.html
index 86ee1027e6..1db0faebc5 100644
--- a/src/templates/admin/elements/issue/issue_modal.html
+++ b/src/templates/admin/elements/issue/issue_modal.html
@@ -30,7 +30,8 @@
{% if issue %}Edit Issue{% else %}Create Issue{% endif %}
{% if issue.cover_image %}
-
+
+ {% include "core/partials/alt_text/edit_alt_text_button.html" with file_path=issue.cover_image.url %}
{% endif %}
@@ -39,6 +40,7 @@
{% if issue %}Edit Issue{% else %}Create Issue{% endif %}
{% if issue.large_image %}
+ {% include "core/partials/alt_text/edit_alt_text_button.html" with file_path=issue.large_image.url %}
{% endif %}
diff --git a/src/templates/admin/elements/layout/key_value_above.html b/src/templates/admin/elements/layout/key_value_above.html
index 10fafcf3aa..466d76ab28 100644
--- a/src/templates/admin/elements/layout/key_value_above.html
+++ b/src/templates/admin/elements/layout/key_value_above.html
@@ -6,7 +6,7 @@
{% endcomment %}
-
+
{{ key }}
diff --git a/src/templates/admin/journal/manage/issues.html b/src/templates/admin/journal/manage/issues.html
index a06c98f22c..6efc381a58 100644
--- a/src/templates/admin/journal/manage/issues.html
+++ b/src/templates/admin/journal/manage/issues.html
@@ -61,7 +61,7 @@
Issue Management
{% for i in issues %}
{{ i.pk }}
- {{ i.issue_title }}
+ {{ i.issue_title|safe }}
{{ i.issue_type }}
{{ i.volume }}
{{ i.issue }}
@@ -130,4 +130,5 @@ Issue Management
});
$( ".sortable" ).disableSelection();
+ {% include "admin/core/partials/htmx.html" %}
{% endblock js %}
diff --git a/src/templates/common/elements/favicons.html b/src/templates/common/elements/favicons.html
index f7d6011c45..11b9a467a5 100644
--- a/src/templates/common/elements/favicons.html
+++ b/src/templates/common/elements/favicons.html
@@ -1,9 +1,7 @@
-{% load mimetype %}
-
{% if request.journal and request.journal.favicon %}
-
+
{% elif request.repository and request.repository.favicon %}
-
+
{% elif request.press and request.press.favicon %}
-
+
{% endif %}
\ No newline at end of file
diff --git a/src/themes/OLH/templates/core/base.html b/src/themes/OLH/templates/core/base.html
index a94a7344e7..17132b9d9c 100644
--- a/src/themes/OLH/templates/core/base.html
+++ b/src/themes/OLH/templates/core/base.html
@@ -55,7 +55,8 @@
{% if request.journal %}
{% if request.journal.header_image %}
- {% svg_or_image request.journal.header_image css_class='header-image top-bar-image' %}
+ {% get_alt_text file_path=request.journal.header_image.url default=request.journal.name as header_alt_text %}
+ {% svg_or_image request.journal.header_image css_class='header-image top-bar-image' alt_text=header_alt_text %}
{% else %}
{% endif %}
@@ -99,7 +100,7 @@
-
+ {% hook 'language_header' %}
{% if journal_settings.general.switch_language %}