Skip to content

Commit 3316a03

Browse files
authored
Merge pull request openlibhums#5027 from openlibhums/al-text-manager
Alt Text Management
2 parents fdd6df8 + d034159 commit 3316a03

76 files changed

Lines changed: 1554 additions & 223 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,5 @@ src/utils/management/commands/test_command.py
150150
/src/static/admin/hypothesis/**
151151
jenkins/test-results
152152
jenkins/test_results
153+
154+
src/file_editor

docs/source/manager/journal/index.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,22 @@ Favicon
108108
Default Profile Image
109109
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.
110110

111+
Alt Text
112+
--------
113+
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.
114+
115+
Alt text is available for images that appear in page content and can be described meaningfully:
116+
117+
- Header image
118+
- Default large image
119+
- Default cover image
120+
- Default thumbnail
121+
- Default profile image (only when a custom image is uploaded; the built-in fallback image has its own alt text)
122+
- Press override image (only when an override image is uploaded; the press logo is used by default)
123+
- Issue cover images and large images
124+
125+
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.
126+
111127
Styling
112128
-------
113129
This page displays some general settings for controlling the styling of your journal.
@@ -148,3 +164,4 @@ Setting values can be accessed inside templates using **{{ journal_settings.grou
148164
In Django they can be accessed with **get_setting**::
149165

150166
request.journal.get_setting('group_name', 'setting_name')
167+

src/comms/models.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
from django.http import Http404
88
from django.utils.translation import gettext as _
99
from django.templatetags.static import static
10-
from simple_history.models import HistoricalRecords
1110
from django.utils.html import mark_safe
11+
from django.utils.html import strip_tags
1212

1313
from core import files
1414
from core.model_utils import JanewayBleachField, JanewayBleachCharField
15+
from core.templatetags import alt_text
1516

1617
__copyright__ = "Copyright 2017 Birkbeck, University of London"
1718
__author__ = "Martin Paul Eve & Andy Byers"
@@ -182,6 +183,32 @@ def best_large_image_url(self):
182183
"""
183184
return self.best_image_url
184185

186+
def best_large_image_alt_text(self):
187+
default_text = strip_tags(self.title)
188+
if self.large_image_file:
189+
return alt_text.get_alt_text(
190+
obj=self.large_image_file,
191+
default=default_text,
192+
)
193+
elif self.content_type.name == "press" and self.object.default_carousel_image:
194+
return alt_text.get_alt_text(
195+
file_path=self.object.default_carousel_image.url,
196+
default=default_text,
197+
)
198+
elif self.content_type.name == "journal":
199+
if self.object.default_large_image:
200+
return alt_text.get_alt_text(
201+
file_path=self.object.default_large_image.url,
202+
default=default_text,
203+
)
204+
elif self.object.press.default_carousel_image:
205+
return alt_text.get_alt_text(
206+
file_path=self.object.press.default_carousel_image.url,
207+
default=default_text,
208+
)
209+
210+
return default_text
211+
185212
def __str__(self):
186213
if self.posted_by:
187214
return "{0} posted by {1} on {2}".format(

src/core/admin.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,22 @@ def _person(self, obj):
737737
return ""
738738

739739

740+
class AltTextAdmin(admin.ModelAdmin):
741+
list_display = (
742+
"content_type",
743+
"object_id",
744+
"file_path",
745+
"alt_text",
746+
"created",
747+
"updated",
748+
)
749+
search_fields = (
750+
"alt_text",
751+
"file_path",
752+
)
753+
list_filter = ("content_type",)
754+
755+
740756
admin_list = [
741757
(models.AccountRole, AccountRoleAdmin),
742758
(models.Account, AccountAdmin),
@@ -773,6 +789,7 @@ def _person(self, obj):
773789
(models.OrganizationName, OrganizationNameAdmin),
774790
(models.Location, LocationAdmin),
775791
(models.ControlledAffiliation, ControlledAffiliationAdmin),
792+
(models.AltText, AltTextAdmin),
776793
]
777794

778795
[admin.site.register(*t) for t in admin_list]

src/core/forms/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
JournalAttributeForm,
2323
JournalContactForm,
2424
JournalImageForm,
25+
JournalSingleImageForm,
2526
JournalStylingForm,
2627
JournalSubmissionForm,
2728
LoginForm,
@@ -37,4 +38,5 @@
3738
SimpleTinyMCEForm,
3839
UserCreationFormExtended,
3940
XSLFileForm,
41+
AltTextForm,
4042
)

src/core/forms/forms.py

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from django import forms
1111
from django.db.models import Q
1212
from django.utils.datastructures import MultiValueDict
13-
from django.forms.fields import Field
1413
from django.utils import timezone
1514
from django.utils.translation import gettext_lazy as _
1615
from django.contrib.auth.forms import UserCreationForm
@@ -513,6 +512,12 @@ class Meta:
513512
"default_profile_image",
514513
)
515514

515+
def __init__(self, *args, **kwargs):
516+
super().__init__(*args, **kwargs)
517+
for field in self.fields.values():
518+
if isinstance(field.widget, forms.ClearableFileInput):
519+
field.widget = forms.FileInput()
520+
516521
def save(self, commit=True):
517522
instance = super().save(commit=True)
518523
try:
@@ -526,6 +531,41 @@ def save(self, commit=True):
526531
return instance
527532

528533

534+
class JournalSingleImageForm(forms.ModelForm):
535+
"""Single-field form for uploading one journal image field at a time."""
536+
537+
class Meta:
538+
model = journal_models.Journal
539+
fields = (
540+
"header_image",
541+
"default_cover_image",
542+
"default_large_image",
543+
"favicon",
544+
"press_image_override",
545+
"default_profile_image",
546+
)
547+
548+
def __init__(self, *args, field_name, **kwargs):
549+
super().__init__(*args, **kwargs)
550+
for name in list(self.fields):
551+
if name != field_name:
552+
del self.fields[name]
553+
if field_name in self.fields:
554+
self.fields[field_name].widget = forms.FileInput()
555+
556+
def save(self, commit=True):
557+
instance = super().save(commit=True)
558+
if "default_large_image" in self.fields and instance.default_large_image:
559+
try:
560+
resize_and_crop(
561+
instance.default_large_image.path,
562+
field_name="Default large image",
563+
)
564+
except ValueError:
565+
pass
566+
return instance
567+
568+
529569
class JournalStylingForm(forms.ModelForm):
530570
class Meta:
531571
model = journal_models.Journal
@@ -775,7 +815,7 @@ def __init__(self, *args, **kwargs):
775815
except:
776816
result = None
777817

778-
if result != None:
818+
if result is not None:
779819
values_list.append(result)
780820
elif result == None and "default" in facet:
781821
values_list.append(facet["default"])
@@ -1152,3 +1192,87 @@ class ConfirmDeleteForm(forms.Form):
11521192
"""
11531193

11541194
pass
1195+
1196+
1197+
class AltTextForm(forms.ModelForm):
1198+
class Meta:
1199+
model = models.AltText
1200+
fields = [
1201+
"alt_text",
1202+
]
1203+
widgets = {
1204+
"alt_text": forms.Textarea(
1205+
attrs={"rows": 5},
1206+
),
1207+
}
1208+
1209+
def __init__(
1210+
self,
1211+
*args,
1212+
content_type=None,
1213+
object_id=None,
1214+
file_path=None,
1215+
**kwargs,
1216+
):
1217+
if "initial" not in kwargs:
1218+
kwargs["initial"] = {}
1219+
1220+
# Populate initial to help form rendering
1221+
if content_type and object_id:
1222+
kwargs["initial"].update(
1223+
{
1224+
"content_type": content_type,
1225+
"object_id": object_id,
1226+
}
1227+
)
1228+
elif file_path:
1229+
kwargs["initial"].update(
1230+
{
1231+
"file_path": file_path,
1232+
}
1233+
)
1234+
1235+
super().__init__(*args, **kwargs)
1236+
1237+
# Set these on the form so we can assign them to the instance in save()
1238+
self.content_type = content_type
1239+
self.object_id = object_id
1240+
self.file_path = file_path
1241+
1242+
def clean(self):
1243+
cleaned_data = super().clean()
1244+
self.instance.content_type = self.content_type
1245+
self.instance.object_id = self.object_id
1246+
self.instance.file_path = self.file_path
1247+
return cleaned_data
1248+
1249+
def save(self, commit=True):
1250+
# Attempt to find an existing instance to update
1251+
existing = None
1252+
1253+
if self.content_type and self.object_id:
1254+
existing = models.AltText.objects.filter(
1255+
content_type=self.content_type,
1256+
object_id=self.object_id,
1257+
).first()
1258+
1259+
elif self.file_path:
1260+
existing = models.AltText.objects.filter(
1261+
file_path=self.file_path,
1262+
).first()
1263+
1264+
# If existing, update its fields
1265+
if existing:
1266+
existing.alt_text = self.cleaned_data["alt_text"]
1267+
instance = existing
1268+
else:
1269+
instance = super().save(commit=False)
1270+
instance.content_type = self.content_type
1271+
instance.object_id = self.object_id
1272+
instance.file_path = self.file_path
1273+
1274+
if commit:
1275+
instance.full_clean()
1276+
instance.save()
1277+
1278+
return instance

src/core/include_urls.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from django.views.decorators.cache import cache_page
1212

1313
from journal import urls as journal_urls
14-
from core import views as core_views, plugin_loader
14+
from core import views as core_views, plugin_loader, partial_views
1515
from utils import notify
1616
from press import views as press_views
1717
from cms import views as cms_views
@@ -431,6 +431,19 @@
431431
core_views.manage_access_requests,
432432
name="manage_access_requests",
433433
),
434+
# Partial views used for HTMX
435+
path("alt-text/form/", partial_views.alt_text_form, name="alt_text_form"),
436+
path("alt-text/submit/", partial_views.alt_text_submit, name="alt_text_submit"),
437+
path(
438+
"manager/settings/images/upload/<str:field_name>/",
439+
partial_views.journal_image_upload,
440+
name="journal_image_upload",
441+
),
442+
path(
443+
"manager/settings/images/remove/<str:field_name>/",
444+
partial_views.journal_image_remove,
445+
name="journal_image_remove",
446+
),
434447
]
435448

436449
# Journal homepage block loading

src/core/janeway_global_settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@
165165
],
166166
"builtins": [
167167
"core.templatetags.fqdn",
168+
"core.templatetags.alt_text",
168169
"security.templatetags.securitytags",
169170
"django.templatetags.i18n",
170171
],

src/core/logic.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,19 @@
1111
import operator
1212
import re
1313
from functools import reduce
14-
from urllib.parse import unquote, urlparse
1514

1615
from django.conf import settings
1716
from django.contrib.auth import logout
1817
from django.contrib import messages
1918
from django.template.loader import get_template
2019
from django.db.models import Q
21-
from django.http import JsonResponse, QueryDict
20+
from django.http import JsonResponse
2221
from django.forms.models import model_to_dict
2322
from django.shortcuts import reverse
2423
from django.utils import timezone
2524
from django.utils.translation import get_language, gettext_lazy as _
25+
from django.contrib.contenttypes.models import ContentType
26+
from django.core.exceptions import ValidationError
2627

2728
from core import forms, models, files, plugin_installed_apps
2829
from utils.function_cache import cache
@@ -1263,3 +1264,39 @@ def create_organization_name(request):
12631264
% {"organization": organization_name},
12641265
)
12651266
return organization_name
1267+
1268+
1269+
def resolve_alt_text_target(request):
1270+
"""
1271+
Resolve the content_type, object_id, file_path, and object instance
1272+
from the request data (POST or GET). Expects 'model', 'pk', and/or 'file_path'.
1273+
1274+
Returns:
1275+
(content_type, object_id, file_path, obj)
1276+
1277+
Raises:
1278+
ValidationError if model or pk is invalid.
1279+
"""
1280+
data = request.POST or request.GET
1281+
1282+
model = data.get("model")
1283+
pk = data.get("pk")
1284+
file_path = data.get("file_path")
1285+
1286+
content_type = None
1287+
object_id = None
1288+
obj = None
1289+
1290+
if model and pk:
1291+
if "." not in model:
1292+
raise ValidationError("Model should be in the form 'app_label.model_name'.")
1293+
1294+
app_label, model_name = model.split(".")
1295+
content_type = ContentType.objects.get(
1296+
app_label=app_label,
1297+
model=model_name,
1298+
)
1299+
object_id = int(pk)
1300+
obj = content_type.get_object_for_this_type(pk=object_id)
1301+
1302+
return content_type, object_id, file_path, obj

0 commit comments

Comments
 (0)