Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
33 changes: 32 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,36 @@ 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,
context_phrase="hero_image",
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,
context_phrase="hero_image",
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,
context_phrase="hero_image",
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,
context_phrase="hero_image",
default=default_text,
)

return default_text

def __str__(self):
if self.posted_by:
return "{0} posted by {1} on {2}".format(
Expand Down
22 changes: 22 additions & 0 deletions src/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,27 @@ def _person(self, obj):
return ""


class AltTextAdmin(admin.ModelAdmin):
list_display = (
"content_type",
"object_id",
"file_path",
"context_phrase",
"alt_text",
"created",
"updated",
)
search_fields = (
"alt_text",
"context_phrase",
"file_path",
)
list_filter = (
"content_type",
"context_phrase",
)


admin_list = [
(models.AccountRole, AccountRoleAdmin),
(models.Account, AccountAdmin),
Expand Down Expand Up @@ -773,6 +794,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,
)
96 changes: 94 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,96 @@ class ConfirmDeleteForm(forms.Form):
"""

pass


class AltTextForm(forms.ModelForm):
class Meta:
model = models.AltText
fields = [
"context_phrase",
"alt_text",
]
widgets = {
"context_phrase": forms.TextInput(
attrs={
"class": "sr-only",
"aria-hidden": "true",
},
),
"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,
context_phrase=self.cleaned_data["context_phrase"],
).first()

elif self.file_path:
existing = models.AltText.objects.filter(
file_path=self.file_path,
context_phrase=self.cleaned_data["context_phrase"],
).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
70 changes: 70 additions & 0 deletions src/core/migrations/0110_alttext.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Generated by Django 4.2.20 on 2025-07-31 12:47

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,
),
),
(
"context_phrase",
models.SlugField(
help_text="Slug describing the image context (e.g., 'cover-image', 'homepage-banner').",
max_length=255,
),
),
(
"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", "context_phrase"),
("file_path", "context_phrase"),
},
},
),
]
Loading