+
{{ paragraph.text | richtext }}
diff --git a/apps/documents/templates/a4_candy_documents/react_documents.html b/apps/documents/templates/a4_candy_documents/react_documents.html
deleted file mode 100644
index 72af75380..000000000
--- a/apps/documents/templates/a4_candy_documents/react_documents.html
+++ /dev/null
@@ -1,13 +0,0 @@
-{% load static %}
-
-{{ ckeditor_media.js }}
-
-
-
-
diff --git a/apps/documents/templatetags/react_documents.py b/apps/documents/templatetags/react_documents.py
index 1ddb22934..706e2457a 100644
--- a/apps/documents/templatetags/react_documents.py
+++ b/apps/documents/templatetags/react_documents.py
@@ -1,8 +1,10 @@
import json
-from ckeditor_uploader.widgets import CKEditorUploadingWidget
from django import template
-from rest_framework.renderers import JSONRenderer
+from django.conf import settings
+from django.urls import reverse
+from django.utils.html import format_html
+from django_ckeditor_5.widgets import CKEditor5Widget
from apps.documents.models import Chapter
from apps.documents.serializers import ChapterSerializer
@@ -10,23 +12,26 @@
register = template.Library()
-@register.inclusion_tag("a4_candy_documents/react_documents.html", takes_context=True)
-def react_documents(context, module, reload_on_success=False):
+@register.simple_tag()
+def react_documents(module, reload_on_success=False):
chapters = Chapter.objects.filter(module=module)
serializer = ChapterSerializer(chapters, many=True)
- chapters_json = JSONRenderer().render(serializer.data).decode("utf-8")
+ widget = CKEditor5Widget(config_name="image-editor")
- widget = CKEditorUploadingWidget(config_name="image-editor")
- widget._set_config()
- config = JSONRenderer().render(widget.config).decode("utf-8")
-
- context = {
- "chapters": chapters_json,
+ attributes = {
+ "key": module.pk,
+ "chapters": serializer.data,
"module": module.pk,
- "config": config,
+ "config": widget.config,
+ "csrfCookieName": settings.CSRF_COOKIE_NAME, # double check
+ "uploadUrl": reverse("ck_editor_5_upload_file"),
+ "uploadFileTypes": settings.CKEDITOR_5_UPLOAD_FILE_TYPES,
"id": "document-" + str(module.id),
- "reload_on_success": json.dumps(reload_on_success),
- "ckeditor_media": widget.media,
+ "reload_on_success": reload_on_success,
}
- return context
+ return format_html(
+ '
',
+ attributes=json.dumps(attributes),
+ )
diff --git a/apps/documents/views.py b/apps/documents/views.py
index 293d483a9..458ff873c 100644
--- a/apps/documents/views.py
+++ b/apps/documents/views.py
@@ -3,6 +3,7 @@
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views import generic
+from django_ckeditor_5.widgets import CKEditor5Widget
from adhocracy4.dashboard import mixins as dashboard_mixins
from adhocracy4.exports.views import DashboardExportView
@@ -25,6 +26,12 @@ class DocumentDashboardView(
def get_permission_object(self):
return self.project
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ widget = CKEditor5Widget(config_name="image-editor")
+ context["ckeditor_media"] = widget.media
+ return context
+
class ChapterDetailView(
ProjectMixin,
diff --git a/apps/ideas/migrations/0003_alter_idea_description.py b/apps/ideas/migrations/0003_alter_idea_description.py
new file mode 100644
index 000000000..d8f4a012a
--- /dev/null
+++ b/apps/ideas/migrations/0003_alter_idea_description.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.19 on 2024-03-26 10:34
+
+from django.db import migrations
+import django_ckeditor_5.fields
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("a4_candy_ideas", "0002_rename_moderatorfeedback_fields"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="idea",
+ name="description",
+ field=django_ckeditor_5.fields.CKEditor5Field(verbose_name="Description"),
+ ),
+ ]
diff --git a/apps/ideas/models.py b/apps/ideas/models.py
index c9aa25f32..18ea48680 100644
--- a/apps/ideas/models.py
+++ b/apps/ideas/models.py
@@ -1,10 +1,10 @@
from autoslug import AutoSlugField
-from ckeditor.fields import RichTextField
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
+from django_ckeditor_5.fields import CKEditor5Field
from adhocracy4 import transforms
from adhocracy4.categories.fields import CategoryField
@@ -31,7 +31,7 @@ class AbstractIdea(module_models.Item, Moderateable):
)
slug = AutoSlugField(populate_from="name", unique=True)
name = models.CharField(max_length=120, verbose_name=_("Title"))
- description = RichTextField(verbose_name=_("Description"))
+ description = CKEditor5Field(verbose_name=_("Description"))
image = ConfiguredImageField(
"idea_image",
verbose_name=_("Add image"),
diff --git a/apps/interactiveevents/migrations/0005_ckeditor_iframes.py b/apps/interactiveevents/migrations/0005_ckeditor_iframes.py
new file mode 100644
index 000000000..221671b63
--- /dev/null
+++ b/apps/interactiveevents/migrations/0005_ckeditor_iframes.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.2.20 on 2023-11-16 11:35
+
+from bs4 import BeautifulSoup
+from django.db import migrations
+
+
+def replace_iframe_with_figur(apps, schema_editor):
+ template = (
+ '
'
+ )
+ InteractiveEvent = apps.get_model(
+ "a4_candy_interactive_events", "ExtraFieldsInteractiveEvent"
+ )
+ for interactiveEvent in InteractiveEvent.objects.all():
+ soup = BeautifulSoup(interactiveEvent.live_stream, "html.parser")
+ iframes = soup.findAll("iframe")
+ for iframe in iframes:
+ figure = BeautifulSoup(
+ template.format(url=iframe.attrs["src"]), "html.parser"
+ )
+ iframe.replaceWith(figure)
+ if iframes:
+ interactiveEvent.live_stream = soup.prettify(formatter="html")
+ interactiveEvent.save()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("a4_candy_interactive_events", "0004_verbose_name_created_modified"),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ replace_iframe_with_figur,
+ ),
+ ]
diff --git a/apps/interactiveevents/migrations/0006_alter_extrafieldsinteractiveevent_live_stream.py b/apps/interactiveevents/migrations/0006_alter_extrafieldsinteractiveevent_live_stream.py
new file mode 100644
index 000000000..b170ca934
--- /dev/null
+++ b/apps/interactiveevents/migrations/0006_alter_extrafieldsinteractiveevent_live_stream.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.19 on 2024-03-14 12:05
+
+from django.db import migrations
+import django_ckeditor_5.fields
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("a4_candy_interactive_events", "0005_ckeditor_iframes"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="extrafieldsinteractiveevent",
+ name="live_stream",
+ field=django_ckeditor_5.fields.CKEditor5Field(
+ blank=True, verbose_name="Live Stream"
+ ),
+ ),
+ ]
diff --git a/apps/interactiveevents/models.py b/apps/interactiveevents/models.py
index 53bef5edc..4d848ac16 100644
--- a/apps/interactiveevents/models.py
+++ b/apps/interactiveevents/models.py
@@ -2,10 +2,10 @@
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
+from django_ckeditor_5.fields import CKEditor5Field
from adhocracy4 import transforms
from adhocracy4.categories.fields import CategoryField
-from adhocracy4.ckeditor.fields import RichTextCollapsibleUploadingField
from adhocracy4.images.fields import ConfiguredImageField
from adhocracy4.models.base import TimeStampedModel
from adhocracy4.modules import models as module_models
@@ -70,7 +70,7 @@ class Meta:
class ExtraFieldsInteractiveEvent(module_models.Item):
- live_stream = RichTextCollapsibleUploadingField(
+ live_stream = CKEditor5Field(
verbose_name="Live Stream", blank=True, config_name="video-editor"
)
diff --git a/apps/mapideas/migrations/0003_alter_mapidea_description.py b/apps/mapideas/migrations/0003_alter_mapidea_description.py
new file mode 100644
index 000000000..a246a9b99
--- /dev/null
+++ b/apps/mapideas/migrations/0003_alter_mapidea_description.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.19 on 2024-03-26 10:34
+
+from django.db import migrations
+import django_ckeditor_5.fields
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("a4_candy_mapideas", "0002_moderatorfeedback_fields"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="mapidea",
+ name="description",
+ field=django_ckeditor_5.fields.CKEditor5Field(verbose_name="Description"),
+ ),
+ ]
diff --git a/apps/moderatorfeedback/forms.py b/apps/moderatorfeedback/forms.py
index d15b7ab29..6669c5327 100644
--- a/apps/moderatorfeedback/forms.py
+++ b/apps/moderatorfeedback/forms.py
@@ -1,4 +1,5 @@
from django import forms
+from django.utils.translation import gettext_lazy as _
from . import models
@@ -7,3 +8,10 @@ class ModeratorFeedbackForm(forms.ModelForm):
class Meta:
model = models.ModeratorFeedback
fields = ["feedback_text"]
+ help_texts = {
+ "feedback_text": _(
+ "The official feedback will appear below the idea, "
+ "indicating your organisation. The idea provider receives "
+ "a notification."
+ ),
+ }
diff --git a/apps/moderatorfeedback/migrations/0006_auto_20240326_1134.py b/apps/moderatorfeedback/migrations/0006_auto_20240326_1134.py
new file mode 100644
index 000000000..46fe38a5b
--- /dev/null
+++ b/apps/moderatorfeedback/migrations/0006_auto_20240326_1134.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.19 on 2024-03-26 10:34
+
+from django.db import migrations
+import django_ckeditor_5.fields
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("a4_candy_moderatorfeedback", "0005_moderatorcommentfeedback"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="moderatorcommentfeedback",
+ name="feedback_text",
+ field=django_ckeditor_5.fields.CKEditor5Field(
+ blank=True, verbose_name="Moderator feedback"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderatorfeedback",
+ name="feedback_text",
+ field=django_ckeditor_5.fields.CKEditor5Field(
+ blank=True, verbose_name="Official feedback"
+ ),
+ ),
+ ]
diff --git a/apps/moderatorfeedback/models.py b/apps/moderatorfeedback/models.py
index 2ac0bc53e..bc1115a54 100644
--- a/apps/moderatorfeedback/models.py
+++ b/apps/moderatorfeedback/models.py
@@ -1,6 +1,6 @@
-from ckeditor.fields import RichTextField
from django.db import models
from django.utils.translation import gettext_lazy as _
+from django_ckeditor_5.fields import CKEditor5Field
from adhocracy4 import transforms
from adhocracy4.comments.models import Comment
@@ -16,14 +16,9 @@
class ModeratorFeedback(UserGeneratedContentModel):
- feedback_text = RichTextField(
+ feedback_text = CKEditor5Field(
blank=True,
verbose_name=_("Official feedback"),
- help_text=_(
- "The official feedback will appear below the idea, "
- "indicating your organisation. The idea provider receives "
- "a notification."
- ),
)
def save(self, *args, **kwargs):
@@ -56,7 +51,7 @@ class Meta:
class ModeratorCommentFeedback(UserGeneratedContentModel):
- feedback_text = RichTextField(
+ feedback_text = CKEditor5Field(
blank=True,
verbose_name=_("Moderator feedback"),
)
diff --git a/apps/newsletters/forms.py b/apps/newsletters/forms.py
index dd7564215..b7e807c47 100644
--- a/apps/newsletters/forms.py
+++ b/apps/newsletters/forms.py
@@ -24,6 +24,16 @@ class Meta:
"subject",
"body",
]
+ help_texts = {
+ "body": _(
+ "If you add an image, please ensure to set the width "
+ "no larger than 650px and to provide an alternate text "
+ "of the image. An alternate text serves as a textual "
+ "description of the image content and is read out by "
+ "screen readers. Describe the image in approx. 80 characters. "
+ "Example: A busy square with people in summer."
+ ),
+ }
def __init__(self, user=None, organisation=None, *args, **kwargs):
super().__init__(*args, **kwargs)
diff --git a/apps/newsletters/migrations/0004_alter_newsletter_body.py b/apps/newsletters/migrations/0004_alter_newsletter_body.py
new file mode 100644
index 000000000..a17b43335
--- /dev/null
+++ b/apps/newsletters/migrations/0004_alter_newsletter_body.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.19 on 2024-02-29 11:03
+
+import adhocracy4.images.validators
+from django.db import migrations
+import django_ckeditor_5.fields
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("a4_candy_newsletters", "0003_verbose_name_created_modified"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="newsletter",
+ name="body",
+ field=django_ckeditor_5.fields.CKEditor5Field(
+ blank=True,
+ validators=[adhocracy4.images.validators.ImageAltTextValidator()],
+ verbose_name="Email body",
+ ),
+ ),
+ ]
diff --git a/apps/newsletters/models.py b/apps/newsletters/models.py
index ed75e8e20..861035d2e 100644
--- a/apps/newsletters/models.py
+++ b/apps/newsletters/models.py
@@ -1,12 +1,13 @@
import re
-from ckeditor_uploader.fields import RichTextUploadingField
from django.conf import settings
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
+from django_ckeditor_5.fields import CKEditor5Field
from adhocracy4 import transforms
+from adhocracy4.images.validators import ImageAltTextValidator
from adhocracy4.models.base import UserGeneratedContentModel
from adhocracy4.projects.models import Project
@@ -28,14 +29,11 @@ class Newsletter(UserGeneratedContentModel):
sender_name = models.CharField(max_length=254, verbose_name=_("Name"))
sender = models.EmailField(blank=True, verbose_name=_("Sender"))
subject = models.CharField(max_length=254, verbose_name=_("Subject"))
- body = RichTextUploadingField(
+ body = CKEditor5Field(
blank=True,
config_name="image-editor",
verbose_name=_("Email body"),
- help_text=_(
- "When adding images, please ensure to "
- "set the width no larger than 600px."
- ),
+ validators=[ImageAltTextValidator()],
)
sent = models.DateTimeField(blank=True, null=True, verbose_name=_("Sent"))
diff --git a/apps/offlineevents/forms.py b/apps/offlineevents/forms.py
index be83f5a66..2920daa1a 100644
--- a/apps/offlineevents/forms.py
+++ b/apps/offlineevents/forms.py
@@ -18,3 +18,17 @@ class OfflineEventForm(forms.ModelForm):
class Meta:
model = models.OfflineEvent
fields = ["name", "event_type", "date", "description"]
+ help_texts = {
+ "description": _(
+ "If you add an image, please provide an alternate text. "
+ "It serves as a textual description of the image content "
+ "and is read out by screen readers. Describe the image "
+ "in approx. 80 characters. Example: A busy square with "
+ "people in summer."
+ ),
+ "event_type": _(
+ "The content of this field is shown in the timeline. It "
+ "should have no more than 30 characters e.g. Information "
+ "event or 3rd public workshop."
+ ),
+ }
diff --git a/apps/offlineevents/migrations/0003_auto_20240229_1203.py b/apps/offlineevents/migrations/0003_auto_20240229_1203.py
new file mode 100644
index 000000000..47ef9f882
--- /dev/null
+++ b/apps/offlineevents/migrations/0003_auto_20240229_1203.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.19 on 2024-02-29 11:03
+
+import adhocracy4.images.validators
+from django.db import migrations, models
+import django_ckeditor_5.fields
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("a4_candy_offlineevents", "0002_verbose_name_created_modified"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="offlineevent",
+ name="description",
+ field=django_ckeditor_5.fields.CKEditor5Field(
+ validators=[adhocracy4.images.validators.ImageAltTextValidator()],
+ verbose_name="Description",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="offlineevent",
+ name="event_type",
+ field=models.CharField(max_length=30, verbose_name="Event type"),
+ ),
+ ]
diff --git a/apps/offlineevents/migrations/0004_ckeditor_iframes.py b/apps/offlineevents/migrations/0004_ckeditor_iframes.py
new file mode 100644
index 000000000..8310edd06
--- /dev/null
+++ b/apps/offlineevents/migrations/0004_ckeditor_iframes.py
@@ -0,0 +1,35 @@
+# Generated by Django 3.2.20 on 2023-11-16 11:35
+
+from bs4 import BeautifulSoup
+from django.db import migrations
+
+
+def replace_iframe_with_figur(apps, schema_editor):
+ template = (
+ '
'
+ )
+ OfflineEvent = apps.get_model("a4_candy_offlineevents", "OfflineEvent")
+ for offlineEvent in OfflineEvent.objects.all():
+ soup = BeautifulSoup(offlineEvent.description, "html.parser")
+ iframes = soup.findAll("iframe")
+ for iframe in iframes:
+ figure = BeautifulSoup(
+ template.format(url=iframe.attrs["src"]), "html.parser"
+ )
+ iframe.replaceWith(figure)
+ if iframes:
+ offlineEvent.description = soup.prettify(formatter="html")
+ offlineEvent.save()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("a4_candy_offlineevents", "0003_auto_20240229_1203"),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ replace_iframe_with_figur,
+ ),
+ ]
diff --git a/apps/offlineevents/models.py b/apps/offlineevents/models.py
index c37d5b08f..2d4587077 100644
--- a/apps/offlineevents/models.py
+++ b/apps/offlineevents/models.py
@@ -1,13 +1,14 @@
from datetime import timedelta
from autoslug import AutoSlugField
-from ckeditor_uploader.fields import RichTextUploadingField
from django.db import models
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
+from django_ckeditor_5.fields import CKEditor5Field
from adhocracy4 import transforms
+from adhocracy4.images.validators import ImageAltTextValidator
from adhocracy4.models.base import UserGeneratedContentModel
from adhocracy4.projects import models as project_models
@@ -25,16 +26,13 @@ class OfflineEvent(UserGeneratedContentModel):
event_type = models.CharField(
max_length=30,
verbose_name=_("Event type"),
- help_text=_(
- "The content of this field is shown in the timeline. It "
- "should have no more than 30 characters e.g. Information "
- "event or 3rd public workshop."
- ),
)
date = models.DateTimeField(verbose_name=_("Date"))
- description = RichTextUploadingField(
- config_name="image-editor", verbose_name=_("Description")
- )
+ description = CKEditor5Field(
+ config_name="collapsible-image-editor",
+ verbose_name=_("Description"),
+ validators=[ImageAltTextValidator()],
+ )
project = models.ForeignKey(project_models.Project, on_delete=models.CASCADE)
objects = OfflineEventsQuerySet.as_manager()
diff --git a/apps/offlineevents/templates/a4_candy_offlineevents/offlineevent_detail.html b/apps/offlineevents/templates/a4_candy_offlineevents/offlineevent_detail.html
index 35e51d8eb..d7056faa5 100644
--- a/apps/offlineevents/templates/a4_candy_offlineevents/offlineevent_detail.html
+++ b/apps/offlineevents/templates/a4_candy_offlineevents/offlineevent_detail.html
@@ -16,7 +16,9 @@
{{ object.name }}
- {{ object.description | richtext }}
+
+ {{ object.description | richtext }}
+
{% block additional_content %}{% endblock %}
diff --git a/apps/organisations/admin.py b/apps/organisations/admin.py
index fbe00fcc2..798152d6a 100644
--- a/apps/organisations/admin.py
+++ b/apps/organisations/admin.py
@@ -1,5 +1,5 @@
-from ckeditor_uploader.widgets import CKEditorUploadingWidget
from django.contrib import admin
+from django_ckeditor_5.widgets import CKEditor5Widget
from parler.admin import TranslatableAdmin
from parler.forms import TranslatableModelForm
@@ -19,7 +19,7 @@ class OrganisationAdminForm(TranslatableModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.fields["information"].widget = CKEditorUploadingWidget(
+ self.fields["information"].widget = CKEditor5Widget(
config_name="collapsible-image-editor"
)
diff --git a/apps/organisations/forms.py b/apps/organisations/forms.py
index c43cef3b9..01810d9a3 100644
--- a/apps/organisations/forms.py
+++ b/apps/organisations/forms.py
@@ -1,10 +1,9 @@
import parler
-from ckeditor_uploader import widgets
-from ckeditor_uploader.fields import RichTextUploadingFormField
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
+from django_ckeditor_5.widgets import CKEditor5Widget
from adhocracy4 import transforms
from apps.cms.settings import helpers
@@ -102,7 +101,6 @@
class OrganisationForm(forms.ModelForm):
-
translated_fields = [
(
"description",
@@ -136,20 +134,18 @@ class OrganisationForm(forms.ModelForm):
),
(
"information",
- RichTextUploadingFormField,
+ forms.CharField,
{
- "config_name": "collapsible-image-editor",
"label": OrganisationTranslation._meta.get_field(
"information"
).verbose_name,
"help_text": OrganisationTranslation._meta.get_field(
"information"
).help_text,
- "external_plugin_resources": _external_plugin_resources,
- "extra_plugins": ["collapsibleItem"],
- "widget": widgets.CKEditorUploadingWidget(
- external_plugin_resources=_external_plugin_resources,
- extra_plugins=["collapsibleItem"],
+ "max_length": OrganisationTranslation._meta.get_field(
+ "information"
+ ).max_length,
+ "widget": CKEditor5Widget(
config_name="collapsible-image-editor",
),
},
@@ -300,7 +296,6 @@ def __init__(self, organisation=None, *args, **kwargs):
class CommunicationContentCreationForm(forms.Form):
-
sizes = None
def __init__(self, project=None, format=None, *args, **kwargs):
diff --git a/apps/organisations/migrations/0022_alter_organisationtranslation_information.py b/apps/organisations/migrations/0022_alter_organisationtranslation_information.py
new file mode 100644
index 000000000..16b5d9c96
--- /dev/null
+++ b/apps/organisations/migrations/0022_alter_organisationtranslation_information.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.2.19 on 2024-03-14 12:05
+
+from django.db import migrations
+import django_ckeditor_5.fields
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("a4_candy_organisations", "0021_alter_organisation_logo"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="organisationtranslation",
+ name="information",
+ field=django_ckeditor_5.fields.CKEditor5Field(
+ blank=True,
+ help_text='You can provide general information about your participation platform to your visitors. It’s also helpful to name a general person of contact for inquiries. The information will be shown on a separate "About" page that can be reached via the main menu.',
+ verbose_name="Information about your organisation",
+ ),
+ ),
+ ]
diff --git a/apps/organisations/migrations/0023_auto_20240328_1138.py b/apps/organisations/migrations/0023_auto_20240328_1138.py
new file mode 100644
index 000000000..97216a8b8
--- /dev/null
+++ b/apps/organisations/migrations/0023_auto_20240328_1138.py
@@ -0,0 +1,49 @@
+# Generated by Django 3.2.19 on 2024-03-28 10:38
+
+from django.db import migrations
+import django_ckeditor_5.fields
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("a4_candy_organisations", "0022_alter_organisationtranslation_information"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="organisation",
+ name="data_protection",
+ field=django_ckeditor_5.fields.CKEditor5Field(
+ blank=True,
+ help_text="Please provide all the legally required information of your data protection. The data protection policy will be shown on a separate page.",
+ verbose_name="Data protection policy",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="organisation",
+ name="imprint",
+ field=django_ckeditor_5.fields.CKEditor5Field(
+ blank=True,
+ help_text="Please provide all the legally required information of your imprint. The imprint will be shown on a separate page.",
+ verbose_name="Imprint",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="organisation",
+ name="netiquette",
+ field=django_ckeditor_5.fields.CKEditor5Field(
+ blank=True,
+ help_text="Please provide a netiquette for the participants. The netiquette helps improving the climate of online discussions and supports the moderation.",
+ verbose_name="Netiquette",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="organisation",
+ name="terms_of_use",
+ field=django_ckeditor_5.fields.CKEditor5Field(
+ blank=True,
+ help_text="Please provide all the legally required information of your terms of use. The terms of use will be shown on a separate page.",
+ verbose_name="Terms of use",
+ ),
+ ),
+ ]
diff --git a/apps/organisations/models.py b/apps/organisations/models.py
index fc3d685ab..515f7df99 100644
--- a/apps/organisations/models.py
+++ b/apps/organisations/models.py
@@ -1,5 +1,4 @@
from autoslug import AutoSlugField
-from ckeditor.fields import RichTextField
from django.conf import settings
from django.contrib.sites.models import Site
from django.db import models
@@ -7,12 +6,12 @@
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
+from django_ckeditor_5.fields import CKEditor5Field
from jsonfield.fields import JSONField
from parler.models import TranslatableModel
from parler.models import TranslatedFields
from adhocracy4 import transforms
-from adhocracy4.ckeditor.fields import RichTextCollapsibleUploadingField
from adhocracy4.images import fields as images_fields
from adhocracy4.projects.models import Project
from apps.projects import query
@@ -55,7 +54,7 @@ class Organisation(TranslatableModel):
),
blank=True,
),
- information=RichTextCollapsibleUploadingField(
+ information=CKEditor5Field(
config_name="collapsible-image-editor",
verbose_name=_("Information about your organisation"),
help_text=_(
@@ -120,7 +119,7 @@ class Organisation(TranslatableModel):
blank=True,
verbose_name="Instagram handle",
)
- imprint = RichTextField(
+ imprint = CKEditor5Field(
verbose_name=_("Imprint"),
help_text=_(
"Please provide all the legally "
@@ -129,7 +128,7 @@ class Organisation(TranslatableModel):
),
blank=True,
)
- terms_of_use = RichTextField(
+ terms_of_use = CKEditor5Field(
verbose_name=_("Terms of use"),
help_text=_(
"Please provide all the legally "
@@ -138,7 +137,7 @@ class Organisation(TranslatableModel):
),
blank=True,
)
- data_protection = RichTextField(
+ data_protection = CKEditor5Field(
verbose_name=_("Data protection policy"),
help_text=_(
"Please provide all the legally "
@@ -148,7 +147,7 @@ class Organisation(TranslatableModel):
),
blank=True,
)
- netiquette = RichTextField(
+ netiquette = CKEditor5Field(
verbose_name=_("Netiquette"),
help_text=_(
"Please provide a netiquette for the participants. "
diff --git a/apps/projects/templates/a4_candy_projects/project_detail.html b/apps/projects/templates/a4_candy_projects/project_detail.html
index 788d90186..1c37e67d3 100644
--- a/apps/projects/templates/a4_candy_projects/project_detail.html
+++ b/apps/projects/templates/a4_candy_projects/project_detail.html
@@ -129,7 +129,9 @@
- {{ project.information | transform_collapsibles | richtext }}
+
+ {{ project.information | transform_collapsibles | richtext }}
+
{% if project.contact_name or project.contact_address_text or project.contact_email or project.contact_phone or project.contact_url %}
{% translate 'Contact for questions' %}
{% if project.contact_name %}
@@ -305,7 +307,7 @@
{% translate 'Insights' %}
{{ result_title|title }}
-
+
{% if project.result %}
{{ project.result | transform_collapsibles | richtext }}
{% else %}
diff --git a/apps/topicprio/forms.py b/apps/topicprio/forms.py
index 2fb446b17..71142dcb1 100644
--- a/apps/topicprio/forms.py
+++ b/apps/topicprio/forms.py
@@ -1,19 +1,36 @@
-from ckeditor_uploader import fields
from django import forms
from django.utils.translation import gettext_lazy as _
+from django_ckeditor_5.fields import CKEditor5Field
from adhocracy4.categories.forms import CategorizableFieldMixin
+from adhocracy4.images.validators import ImageAltTextValidator
from adhocracy4.labels.mixins import LabelsAddableFieldMixin
from . import models
-class TopicForm(CategorizableFieldMixin, LabelsAddableFieldMixin, forms.ModelForm):
-
- description = fields.RichTextUploadingFormField(
- config_name="image-editor", required=True, label=_("Description")
+class TopicForm(
+ CategorizableFieldMixin,
+ LabelsAddableFieldMixin,
+ forms.ModelForm,
+):
+ description = CKEditor5Field(
+ config_name="image-editor", validators=[ImageAltTextValidator()]
)
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["description"].label = _("Description")
+
class Meta:
model = models.Topic
fields = ["name", "description", "category", "labels"]
+ help_texts = {
+ "description": _(
+ "If you add an image, please provide an alternate text. "
+ "It serves as a textual description of the image content "
+ "and is read out by screen readers. Describe the image "
+ "in approx. 80 characters. Example: A busy square with "
+ "people in summer."
+ ),
+ }
diff --git a/apps/topicprio/migrations/0003_alter_topic_description.py b/apps/topicprio/migrations/0003_alter_topic_description.py
new file mode 100644
index 000000000..30dbaad7f
--- /dev/null
+++ b/apps/topicprio/migrations/0003_alter_topic_description.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.2.19 on 2024-02-29 11:03
+
+import adhocracy4.images.validators
+from django.db import migrations
+import django_ckeditor_5.fields
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("a4_candy_topicprio", "0002_topic_description_add_verbose_name"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="topic",
+ name="description",
+ field=django_ckeditor_5.fields.CKEditor5Field(
+ validators=[adhocracy4.images.validators.ImageAltTextValidator()],
+ verbose_name="Description",
+ ),
+ ),
+ ]
diff --git a/apps/topicprio/models.py b/apps/topicprio/models.py
index 1861ce713..ad17125ba 100644
--- a/apps/topicprio/models.py
+++ b/apps/topicprio/models.py
@@ -1,14 +1,15 @@
from autoslug import AutoSlugField
-from ckeditor_uploader.fields import RichTextUploadingField
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
+from django_ckeditor_5.fields import CKEditor5Field
from adhocracy4 import transforms
from adhocracy4.categories.fields import CategoryField
from adhocracy4.comments import models as comment_models
from adhocracy4.images.fields import ConfiguredImageField
+from adhocracy4.images.validators import ImageAltTextValidator
from adhocracy4.labels import models as labels_models
from adhocracy4.models import query
from adhocracy4.modules import models as module_models
@@ -28,8 +29,10 @@ class Topic(module_models.Item):
)
slug = AutoSlugField(populate_from="name", unique=True)
name = models.CharField(max_length=120, verbose_name=_("Title"))
- description = RichTextUploadingField(
- config_name="image-editor", verbose_name=_("Description")
+ description = CKEditor5Field(
+ config_name="image-editor",
+ verbose_name=_("Description"),
+ validators=[ImageAltTextValidator()],
)
image = ConfiguredImageField(
"idea_image",
diff --git a/apps/topicprio/templates/a4_candy_topicprio/topic_detail.html b/apps/topicprio/templates/a4_candy_topicprio/topic_detail.html
index 1efb923de..d15e28b56 100644
--- a/apps/topicprio/templates/a4_candy_topicprio/topic_detail.html
+++ b/apps/topicprio/templates/a4_candy_topicprio/topic_detail.html
@@ -45,7 +45,9 @@
{{ object.name }}
- {{ object.description | richtext }}
+
+ {{ object.description | richtext }}
+
diff --git a/changelog/7632.md b/changelog/7632.md
new file mode 100644
index 000000000..01d69a994
--- /dev/null
+++ b/changelog/7632.md
@@ -0,0 +1,24 @@
+### Added
+
+- custom migration to make iframes work with ckeditor5
+- added dependency beautifulsoup4
+- add helptext to paragraph form in documents/text review
+- add helptext for maptopicprio ckeditor5 field
+- add helptext for topicprio ckeditor5 field
+- add helptext for offlinevent ckeditor5 field
+
+
+### Changed
+
+- replace django-ckeditor with django-ckeditor5
+- disable browser-side form checks for forms which use ckeditor by adding
+ `novalidate` to them This is necessary as ckeditor form fields which are
+ required will block form submission otherwise.
+- update and move helptext for plans ckeditor5 field from model to form
+- update and move helptext for newsletter ckeditor5 field from model to form
+- update and move helptext for plattform email ckeditor5 field from model to
+ form
+- update a4 to ckeditor5-transition-a4
+- add image validator which validates that all img tags have the alt attribute
+ set to all ckedito5 fields
+
diff --git a/docs/ckeditor.md b/docs/ckeditor.md
index 68cd3fb57..08f854362 100644
--- a/docs/ckeditor.md
+++ b/docs/ckeditor.md
@@ -1,30 +1,31 @@
-# CKEditor
+# General
-We use the ckeditor in the dashboard for information and result inputs and also user side for idea adding. This allows users to add formatted text, editable images, accordions and videos. To achieve this we have 4 configured editors for different use cases, default (ideas), image-editor, collapsible-image-editor(information, results), video-editor(live stream).
+for general info see [ckeditor](https://github.com/liqd/adhocracy4/blob/main/docs/ckeditor.md) in
+adhocracy4.
-## Editor
-- We use [django-ckeditor](https://pypi.org/project/django-ckeditor/) which utilises [ckeditor 4.7](https://ckeditor.com/docs/ckeditor4/latest/index.html).
+# Local Testing
-## Configs
-- The configs of the editors can be found in base.py.
-- Config options can be found [here](https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_config.html) syntax is slightly different for django-ckeditor (see removeDialogTabs). Official [example config](https://github.com/django-ckeditor/django-ckeditor#example-ckeditor-configuration).
+To test aplus with a local version of django-ckeditor-5 you need to follow these
+steps:
-## Dialog windows
-- The dialog windows which open when addinga link, image, ect. can be customised via [CKEditor Dialog API](https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_dialog.html).
-- An example of this can be seen in app.js where the code snippet is deleting the URL input in the image dialog, futher examples can be found [here](https://docs.cksource.com/CKEditor_3.x/Howto/Editing_Dialog_Windows) and [here](https://ckeditor.com/old/forums/Support/How-remove-Element-particular-Tab#comment-62739).
-- NOTE: The id/name of the input you wish to delete will follow the naming conventions in the examples but will not be the id from the rendered page.
+```
+# clone django-ckeditor-5
+git clone git@github.com:liqd/django-ckeditor-5.git
+# cd into the directory
+cd django-ckeditor-5/django-ckeditor-5
+# install npm dependencies
+npm install
+# build js files
+npm run dev
+```
-## Plugins
-- To add plugins, it should be done in a4 in adhocracy4/ckeditor, then update config in aplus (see embedbase), not all ckeditor plugins can be used in django-ckeditor, see below for a list.
-- It is possible to create custom plugins (see collapsibleItem in a4).
-- The embed plugin we use also uses a self hosted version of iframely in order to serve iframes from a urls, configs are found in admin repository.
+Now in aplus, do the folowing:
-### Allowed plugins
-
-a11yhelp, about, adobeair, ajax, autoembed, autogrow, autolink, bbcode, clipboard, codesnippet,
-codesnippetgeshi, colordialog, devtools, dialog, div, divarea, docprops, embed, embedbase,
-embedsemantic, filetools, find, flash, forms, iframe, iframedialog, image, image2, language,
-lineutils, link, liststyle, magicline, mathjax, menubutton, notification, notificationaggregator,
-pagebreak, pastefromword, placeholder, preview, scayt, sharedspace, showblocks, smiley,
-sourcedialog, specialchar, stylesheetparser, table, tableresize, tabletools, templates, uicolor,
-uploadimage, uploadwidget, widget, wsc, xml
+```
+# activate the venv
+source venv/bin/activate
+# install the local django-ckeditor-5
+# replace ../django-ckeditor-5 with the correct path if its not in the parent
+# directory
+pip install -e ../django-ckeditor-5
+```
diff --git a/package.json b/package.json
index daaea25b1..994a394c6 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"dependencies": {
"@fortawesome/fontawesome-free": "5.15.4",
"@maplibre/maplibre-gl-leaflet": "0.0.19",
- "adhocracy4": "git+https://github.com/liqd/adhocracy4#7378cb6f161a79e5dd9b1d685b62502614fcd35f",
+ "adhocracy4": "git+https://github.com/liqd/adhocracy4#ckeditor5-transition-a4",
"autoprefixer": "10.4.14",
"bootstrap": "5.2.3",
"css-loader": "6.8.1",
diff --git a/requirements/base.txt b/requirements/base.txt
index 161e95b83..c5e115c4f 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -1,5 +1,5 @@
# A4
-git+https://github.com/liqd/adhocracy4.git@7378cb6f161a79e5dd9b1d685b62502614fcd35f#egg=adhocracy4
+git+https://github.com/liqd/adhocracy4.git@ckeditor5-transition-a4#egg=adhocracy4
# Additional requirements
brotli==1.0.9
@@ -19,6 +19,7 @@ Django==3.2.19
django-allauth==0.54.0
git+https://github.com/liqd/django-autoslug.git@liqd2212#egg=django-autoslug
django-ckeditor==6.5.1
+https://github.com/liqd/django-ckeditor-5/releases/download/v0.2.11-liqd/django_ckeditor_5-0.2.11-py3-none-any.whl
django-filter==23.5
django-widget-tweaks==1.4.12
djangorestframework==3.14.0
diff --git a/tests/documents/test_document_api.py b/tests/documents/test_document_api.py
index b41e3d32f..408f6d61a 100644
--- a/tests/documents/test_document_api.py
+++ b/tests/documents/test_document_api.py
@@ -3,6 +3,7 @@
from django.urls import reverse
from rest_framework import status
+from adhocracy4.images.validators import ImageAltTextValidator
from apps.documents import models as document_models
@@ -398,3 +399,67 @@ def test_moderator_can_update_and_create_paragraph(apiclient, module):
paragraphs_all_count = document_models.Paragraph.objects.all().count()
assert paragraphs_all_count == 2
+
+
+@pytest.mark.django_db
+def test_moderator_cannot_create_paragraph_image_without_alt_text(apiclient, module):
+ project = module.project
+ moderator = project.moderators.first()
+ apiclient.force_authenticate(user=moderator)
+ url = reverse("chapters-list", kwargs={"module_pk": module.pk})
+ data = {
+ "chapters": [
+ {
+ "name": "This is a text",
+ "paragraphs": [
+ {
+ "name": "paragraph 1",
+ "text": "A beautiful image
",
+ "weight": 0,
+ },
+ ],
+ }
+ ]
+ }
+ response = apiclient.post(url, data, format="json")
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ chapters_all_count = document_models.Chapter.objects.all().count()
+ assert chapters_all_count == 0
+ paragraphs_all_count = document_models.Paragraph.objects.all().count()
+ assert paragraphs_all_count == 0
+ assert (
+ response.data["chapters"][0]["paragraphs"][0]["text"][0]
+ == ImageAltTextValidator.message
+ )
+
+
+@pytest.mark.django_db
+def test_moderator_can_create_paragraph_image_has_alt_text(apiclient, module):
+ project = module.project
+ moderator = project.moderators.first()
+ apiclient.force_authenticate(user=moderator)
+ url = reverse("chapters-list", kwargs={"module_pk": module.pk})
+ data = {
+ "chapters": [
+ {
+ "name": "This is a text",
+ "paragraphs": [
+ {
+ "name": "paragraph 1",
+ "text": 'A beautiful image
',
+ "weight": 0,
+ },
+ ],
+ }
+ ]
+ }
+ response = apiclient.post(url, data, format="json")
+ assert response.status_code == status.HTTP_201_CREATED
+ chapters_all_count = document_models.Chapter.objects.all().count()
+ assert chapters_all_count == 1
+ chapter = response.data["chapters"][0]
+ paragraphs_all_count = document_models.Paragraph.objects.all().count()
+ assert paragraphs_all_count == 1
+ paragraph_pk = chapter["paragraphs"][0]["id"]
+ paragraph_text = document_models.Paragraph.objects.get(pk=paragraph_pk).text
+ assert paragraph_text == 'A beautiful image
'
diff --git a/tests/newsletters/test_newsletter_views.py b/tests/newsletters/test_newsletter_views.py
index 2dcd56d5d..d570a3e05 100644
--- a/tests/newsletters/test_newsletter_views.py
+++ b/tests/newsletters/test_newsletter_views.py
@@ -4,6 +4,7 @@
from django.urls import reverse
from adhocracy4.follows import models as follow_models
+from adhocracy4.images.validators import ImageAltTextValidator
from adhocracy4.test.helpers import assert_template_response
from adhocracy4.test.helpers import redirect_target
from apps.newsletters import models as newsletter_models
@@ -256,3 +257,68 @@ def test_limit_initiators_organisation_projects(client, project_factory):
response = client.post(url, data)
assert not response.context["form"].is_valid()
assert newsletter_models.Newsletter.objects.count() == 0
+
+
+@pytest.mark.django_db
+def test_send_organisation_missing_alt_text(admin, client, project):
+ organisation = project.organisation
+
+ data = {
+ "sender_name": "Tester",
+ "sender": "test@test.de",
+ "subject": "Testsubject",
+ "body": "Testbody
",
+ "receivers": newsletter_models.PROJECT,
+ "organisation": organisation.pk,
+ "project": project.pk,
+ "send": "Send",
+ }
+
+ url = reverse(
+ "a4dashboard:newsletter-create", kwargs={"organisation_slug": organisation.slug}
+ )
+ client.login(username=admin.email, password="password")
+ response = client.post(url, data)
+ assert newsletter_models.Newsletter.objects.count() == 0
+ assert "body" in response.context_data["form"].errors
+ assert (
+ response.context_data["form"].errors["body"][0] == ImageAltTextValidator.message
+ )
+
+
+@pytest.mark.django_db
+def test_send_organisation_with_alt_text(
+ admin, client, project, user_factory, follow_factory, email_address_factory
+):
+ organisation = project.organisation
+ user1 = user_factory(get_newsletters=True)
+ user2 = user_factory(get_newsletters=True)
+
+ email_address_factory(user=user1, email=user1.email, primary=True, verified=True)
+ email_address_factory(user=user2, email=user2.email, primary=True, verified=True)
+
+ follow_models.Follow.objects.all().delete()
+ follow_factory(creator=user1, project=project)
+ follow_factory(creator=user2, project=project, enabled=False)
+ assert newsletter_models.Newsletter.objects.count() == 0
+
+ data = {
+ "sender_name": "Tester",
+ "sender": "test@test.de",
+ "subject": "Testsubject",
+ "body": 'Testbody
',
+ "receivers": newsletter_models.PROJECT,
+ "organisation": organisation.pk,
+ "project": project.pk,
+ "send": "Send",
+ }
+
+ url = reverse(
+ "a4dashboard:newsletter-create", kwargs={"organisation_slug": organisation.slug}
+ )
+ client.login(username=admin.email, password="password")
+ client.post(url, data)
+ assert newsletter_models.Newsletter.objects.count() == 1
+ assert len(mail.outbox) == 1
+ assert mail.outbox[0].to == [user1.email]
+ assert mail.outbox[0].subject == "Testsubject"
diff --git a/tests/offlineevents/dashboard_components/test_views_project_offlineevents.py b/tests/offlineevents/dashboard_components/test_views_project_offlineevents.py
index 064628094..c30dcd9c6 100644
--- a/tests/offlineevents/dashboard_components/test_views_project_offlineevents.py
+++ b/tests/offlineevents/dashboard_components/test_views_project_offlineevents.py
@@ -1,8 +1,13 @@
+from unittest.mock import ANY
+from unittest.mock import MagicMock
+
import pytest
from dateutil.parser import parse
from django.urls import reverse
from adhocracy4.dashboard import components
+from adhocracy4.dashboard.signals import project_component_updated
+from adhocracy4.images.validators import ImageAltTextValidator
from adhocracy4.test.helpers import assert_template_response
from adhocracy4.test.helpers import redirect_target
from adhocracy4.test.helpers import setup_phase
@@ -94,3 +99,72 @@ def test_offlineevent_delete_view(client, phase_factory, offline_event_factory):
response = client.delete(url)
assert redirect_target(response) == "offlineevent-list"
assert not OfflineEvent.objects.exists()
+
+
+@pytest.mark.django_db
+def test_offlineevent_update_view_missing_alt_text(
+ client, phase_factory, offline_event_factory
+):
+ phase, module, project, item = setup_phase(
+ phase_factory, None, CollectFeedbackPhase
+ )
+ initiator = module.project.organisation.initiators.first()
+ event = offline_event_factory(project=project)
+ url = reverse(
+ "a4dashboard:offlineevent-update",
+ kwargs={"organisation_slug": project.organisation.slug, "slug": event.slug},
+ )
+ data = {
+ "name": "name",
+ "event_type": "event_type",
+ "description": "desc
",
+ "date_0": "2013-01-01",
+ "date_1": "18:00",
+ }
+ client.login(username=initiator.email, password="password")
+ signal_handler = MagicMock()
+ project_component_updated.connect(signal_handler)
+ response = client.post(url, data)
+ assert "description" in response.context_data["form"].errors
+ assert (
+ response.context_data["form"].errors["description"][0]
+ == ImageAltTextValidator.message
+ )
+ signal_handler.assert_not_called()
+
+
+@pytest.mark.django_db
+def test_offlineevent_update_view_with_alt_text(
+ client, phase_factory, offline_event_factory
+):
+ phase, module, project, item = setup_phase(
+ phase_factory, None, CollectFeedbackPhase
+ )
+ initiator = module.project.organisation.initiators.first()
+ event = offline_event_factory(project=project)
+ url = reverse(
+ "a4dashboard:offlineevent-update",
+ kwargs={"organisation_slug": project.organisation.slug, "slug": event.slug},
+ )
+ data = {
+ "name": "name",
+ "event_type": "event_type",
+ "description": 'desc
',
+ "date_0": "2013-01-01",
+ "date_1": "18:00",
+ }
+ client.login(username=initiator.email, password="password")
+ signal_handler = MagicMock()
+ project_component_updated.connect(signal_handler)
+ response = client.post(url, data)
+ assert redirect_target(response) == "offlineevent-list"
+ event.refresh_from_db()
+ assert event.description == data.get("description")
+ assert event.date == parse("2013-01-01 17:00:00 UTC")
+ signal_handler.assert_called_once_with(
+ signal=ANY,
+ sender=component.__class__,
+ project=project,
+ component=component,
+ user=initiator,
+ )
diff --git a/tests/topicprio/dashboard_components/test_views_module_topics.py b/tests/topicprio/dashboard_components/test_views_module_topics.py
index c88e2c707..b0779a489 100644
--- a/tests/topicprio/dashboard_components/test_views_module_topics.py
+++ b/tests/topicprio/dashboard_components/test_views_module_topics.py
@@ -2,6 +2,7 @@
from django.urls import reverse
from adhocracy4.dashboard import components
+from adhocracy4.images.validators import ImageAltTextValidator
from adhocracy4.test.helpers import assert_template_response
from adhocracy4.test.helpers import redirect_target
from adhocracy4.test.helpers import setup_phase
@@ -96,3 +97,64 @@ def test_topic_delete_view(client, phase_factory, topic_factory):
response = client.delete(url)
assert redirect_target(response) == "topic-list"
assert not Topic.objects.exists()
+
+
+@pytest.mark.django_db
+def test_topic_update_view_missing_alt_text(
+ client, phase_factory, topic_factory, category_factory
+):
+ phase, module, project, item = setup_phase(
+ phase_factory, topic_factory, PrioritizePhase
+ )
+ initiator = module.project.organisation.initiators.first()
+ category = category_factory(module=module)
+ url = reverse(
+ "a4dashboard:topic-update",
+ kwargs={
+ "organisation_slug": item.module.project.organisation.slug,
+ "pk": item.pk,
+ "year": item.created.year,
+ },
+ )
+ data = {
+ "name": "name",
+ "description": "desc
",
+ "category": category.pk,
+ }
+ client.login(username=initiator.email, password="password")
+ response = client.post(url, data)
+ assert "description" in response.context_data["form"].errors
+ assert (
+ response.context_data["form"].errors["description"][0]
+ == ImageAltTextValidator.message
+ )
+
+
+@pytest.mark.django_db
+def test_topic_update_view_with_alt_text(
+ client, phase_factory, topic_factory, category_factory
+):
+ phase, module, project, item = setup_phase(
+ phase_factory, topic_factory, PrioritizePhase
+ )
+ initiator = module.project.organisation.initiators.first()
+ category = category_factory(module=module)
+ url = reverse(
+ "a4dashboard:topic-update",
+ kwargs={
+ "organisation_slug": item.module.project.organisation.slug,
+ "pk": item.pk,
+ "year": item.created.year,
+ },
+ )
+ data = {
+ "name": "name",
+ "description": 'desc
',
+ "category": category.pk,
+ }
+ client.login(username=initiator.email, password="password")
+ response = client.post(url, data)
+ assert redirect_target(response) == "topic-list"
+ topic = Topic.objects.get(name=data.get("name"))
+ assert topic.description == data.get("description")
+ assert topic.category.pk == data.get("category")