Skip to content
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1851c27
Initial work on alt-text.
ajrbyers Aug 7, 2025
99a30c4
WIP
ajrbyers Aug 15, 2025
9f0eba2
wip: added more work onto the alt text branch.
ajrbyers Aug 26, 2025
3d72f73
feat: adds alt text to OLH theme
ajrbyers Aug 29, 2025
a00fcbb
feat: adds alt text to news item homepage elements.
ajrbyers Oct 31, 2025
7dc30f0
chore: remove character
ajrbyers Oct 31, 2025
6f0cf64
Resolve conflicts
ajrbyers Nov 21, 2025
7008340
chore: ruff formatting.
ajrbyers Nov 21, 2025
af67af4
Added clean and material
ajrbyers Dec 2, 2025
59ce222
chore: remove comment that is not needed.
ajrbyers Dec 3, 2025
35bf738
Merge
ajrbyers Dec 5, 2025
ec084c7
fix: undo bad changes
ajrbyers Dec 5, 2025
94f0044
chore: removes context_phrase, changes hash to md5.
ajrbyers Jan 28, 2026
d3eb4ac
fix: add security to the partial views.
ajrbyers Jan 28, 2026
3dee02c
chore: ruff formatting
ajrbyers Jan 28, 2026
a327776
fix: recreate alt text migration
ajrbyers Jan 28, 2026
8bf920a
chore: ruff formating on migration
ajrbyers Jan 28, 2026
81b4cde
fix: add missing alt text entries
ajrbyers Feb 27, 2026
d9e31ac
fix: fixes broken security decorator
ajrbyers Mar 12, 2026
3a8a8f4
chore: update docs.
ajrbyers Mar 13, 2026
7ec3994
chore: make alt_text module more DRY.
ajrbyers Mar 13, 2026
16ecaf5
feat: add generic toastr response for HTMX views.
ajrbyers Mar 13, 2026
c0ad985
fix: update templates with fixes and new patterns.
ajrbyers Mar 13, 2026
d570cb2
chore: ruff formatting
ajrbyers Mar 13, 2026
07912e6
feat: adds new single image form
ajrbyers Mar 16, 2026
6bdac6a
feat: add new partial views for handling file upload and removal.
ajrbyers Mar 16, 2026
caac1ad
feat: add alt text to the press header image.
ajrbyers Mar 16, 2026
16de74b
feat: tidy up templates and add tests.
ajrbyers Mar 16, 2026
d034159
feat: generic HTMX spinner and hx_show_message helper
ajrbyers Mar 17, 2026
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,5 @@ src/utils/management/commands/test_command.py
/src/static/admin/hypothesis/**
jenkins/test-results
jenkins/test_results

src/file_editor
17 changes: 17 additions & 0 deletions docs/source/manager/journal/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,22 @@ Favicon
Default Profile Image
The default image used when editorial team groups have "Display profile images" enabled. The image is also used on the public profile page when a user has enabled it.

Alt Text
--------
Most images in the Images settings page have an **Edit alt text** button. Clicking it opens a popup where you can enter descriptive alt text for that image. Alt text is saved immediately when you click **Save alt text** — there is no need to submit the main form afterwards.

Alt text is available for images that appear in page content and can be described meaningfully:

- Header image
- Default large image
- Default cover image
- Default thumbnail
- Default profile image (only when a custom image is uploaded; the built-in fallback image has its own alt text)
- Press override image (only when an override image is uploaded; the press logo is used by default)
- Issue cover images and large images

The **Favicon** does not have an alt text option. Favicons appear in the browser tab, not in the page content, so they do not support alt text.

Styling
-------
This page displays some general settings for controlling the styling of your journal.
Expand Down Expand Up @@ -148,3 +164,4 @@ Setting values can be accessed inside templates using **{{ journal_settings.grou
In Django they can be accessed with **get_setting**::

request.journal.get_setting('group_name', 'setting_name')

29 changes: 28 additions & 1 deletion src/comms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions src/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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]
1 change: 1 addition & 0 deletions src/core/forms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@
SimpleTinyMCEForm,
UserCreationFormExtended,
XSLFileForm,
AltTextForm,
)
87 changes: 85 additions & 2 deletions src/core/forms/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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
5 changes: 4 additions & 1 deletion src/core/include_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/core/janeway_global_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
],
"builtins": [
"core.templatetags.fqdn",
"core.templatetags.alt_text",
"security.templatetags.securitytags",
"django.templatetags.i18n",
],
Expand Down
41 changes: 39 additions & 2 deletions src/core/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
61 changes: 61 additions & 0 deletions src/core/migrations/0110_alttext.py
Original file line number Diff line number Diff line change
@@ -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")},
},
),
]
Loading
Loading