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

+ + {% include "core/partials/alt_text/edit_alt_text_button.html" with object=article.large_image_file %} {% endif %} @@ -67,6 +69,8 @@

Current Thumbnail

+ + {% 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" %} +
+ + +
+
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 %} + +
+ {% csrf_token %} + + {{ form.non_field_errors }} + +
+ + {{ form.alt_text }} + {{ form.alt_text.errors }} +
+ + {% if object %} + + + + {% endif %} + + {% if file_path %} + + {% endif %} +
+ +
+
+{% 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 %} + + + + + + + {% 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

-
-
-

{% trans 'For details about where each image appears, see the Janeway documentation: ' %} - - Journal Images Settings - -

-
-
-
- {% 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 %} -
-
+
+
+

{% trans 'For details about where each image appears, see the Janeway documentation: ' %} + + Journal Images Settings + +

+
+
+
+ {% 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 %}