Skip to content

Commit 3a377d1

Browse files
authored
apps/polls: add question images alt text, UI, feature flag
1 parent 9dc6567 commit 3a377d1

13 files changed

Lines changed: 146 additions & 40 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 5.2.8 on 2026-06-16 12:00
2+
3+
import adhocracy4.images.fields
4+
from django.db import migrations
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("a4polls", "0009_question_image"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="question",
16+
name="image_alt_text",
17+
field=adhocracy4.images.fields.ImageAltTextField(
18+
blank=True,
19+
image_name="Question image",
20+
max_length=80,
21+
),
22+
),
23+
]

adhocracy4/polls/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from adhocracy4.comments import models as comment_models
77
from adhocracy4.images.fields import ConfiguredImageField
8+
from adhocracy4.images.fields import ImageAltTextField
89
from adhocracy4.models.base import GeneratedContentModel
910
from adhocracy4.modules import models as module_models
1011
from adhocracy4.polls import validators
@@ -77,6 +78,8 @@ class Question(models.Model):
7778
max_length=300,
7879
)
7980

81+
image_alt_text = ImageAltTextField(image_name=_("Question image"))
82+
8083
@property
8184
def has_other_option(self):
8285
return self.choices.filter(is_other_choice=True).exists()

adhocracy4/polls/serializers.py

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ def get_count(self, choice: Choice) -> int:
4747

4848
class QuestionSerializer(serializers.ModelSerializer):
4949
id = serializers.IntegerField(required=False)
50+
51+
def __init__(self, *args, **kwargs):
52+
super().__init__(*args, **kwargs)
53+
if not getattr(settings, "A4_POLL_QUESTION_IMAGES", True):
54+
for field in [
55+
"image_base64",
56+
"image_url",
57+
"image_alt_text",
58+
"image_help_text",
59+
]:
60+
self.fields.pop(field, None)
61+
5062
isReadOnly = serializers.SerializerMethodField(method_name="get_is_read_only")
5163
authenticated = serializers.SerializerMethodField()
5264
choices = ChoiceSerializer(many=True)
@@ -72,6 +84,7 @@ class QuestionSerializer(serializers.ModelSerializer):
7284
required=False, allow_blank=True, allow_null=True, write_only=True
7385
)
7486
image_url = serializers.SerializerMethodField(method_name="get_image_url")
87+
image_help_text = serializers.SerializerMethodField()
7588

7689
class Meta:
7790
model = Question
@@ -81,6 +94,8 @@ class Meta:
8194
"help_text",
8295
"image_base64",
8396
"image_url",
97+
"image_alt_text",
98+
"image_help_text",
8499
"multiple_choice",
85100
"is_open",
86101
"is_confidential",
@@ -100,6 +115,9 @@ class Meta:
100115
def get_image_url(self, question):
101116
return question.image.url if question.image else None
102117

118+
def get_image_help_text(self, question):
119+
return str(question._meta.get_field("image").help_text)
120+
103121
def _base64_to_image(self, base64_str):
104122
if "base64," in base64_str:
105123
format, imgstr = base64_str.split(";base64,")
@@ -349,23 +367,30 @@ def update(self, instance, validated_data):
349367
for q_id in set(instance.questions.values_list("id", flat=True)) - keep_ids:
350368
self._delete_question_with_image(q_id, instance)
351369

370+
question_images_enabled = getattr(settings, "A4_POLL_QUESTION_IMAGES", True)
371+
352372
# Update or create questions
353373
for weight, q_data in enumerate(questions_data):
374+
defaults = {
375+
"poll": instance,
376+
"label": q_data.get("label", ""),
377+
"help_text": q_data.get("help_text", ""),
378+
"multiple_choice": q_data.get("multiple_choice", False),
379+
"is_open": q_data.get("is_open", False),
380+
"is_confidential": q_data.get("is_confidential", False),
381+
"weight": weight,
382+
}
383+
if question_images_enabled:
384+
defaults["image_alt_text"] = q_data.get("image_alt_text", "")
385+
354386
question, _ = Question.objects.update_or_create(
355387
id=q_data.get("id"),
356-
defaults={
357-
"poll": instance,
358-
"label": q_data.get("label", ""),
359-
"help_text": q_data.get("help_text", ""),
360-
"multiple_choice": q_data.get("multiple_choice", False),
361-
"is_open": q_data.get("is_open", False),
362-
"is_confidential": q_data.get("is_confidential", False),
363-
"weight": weight,
364-
},
388+
defaults=defaults,
365389
)
366390

367-
image_data = q_data.get("image") or q_data.get("image_base64")
368-
self._handle_question_image(question, image_data)
391+
if question_images_enabled:
392+
image_data = q_data.get("image") or q_data.get("image_base64")
393+
self._handle_question_image(question, image_data)
369394
if not question.is_open and "choices" in q_data:
370395
self._update_choices(q_data["choices"], question)
371396

adhocracy4/polls/static/PollDashboard/EditPollManagement.jsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ const createEmptyQuestion = (label = '', helpText = '', isOpen = false) => ({
3838
choices: isOpen ? [] : [createEmptyChoice(), createEmptyChoice()],
3939
answers: [],
4040
image_base64: null,
41-
image_url: null
41+
image_url: null,
42+
image_alt_text: ''
4243
})
4344

4445
export const EditPollManagement = (props) => {
@@ -76,6 +77,8 @@ export const EditPollManagement = (props) => {
7677
image_url: imageBase64 || null
7778
})
7879

80+
const handleQuestionAltText = (index, altText) => updateQuestion(index, { image_alt_text: altText })
81+
7982
const handleQuestionAppend = (isOpen = false) => {
8083
setQuestions([...questions, createEmptyQuestion('', '', isOpen)])
8184
}
@@ -136,13 +139,14 @@ export const EditPollManagement = (props) => {
136139

137140
const payload = {
138141
questions: questions.map(q => {
139-
const { key, answers, imageUrl, image_base64, ...clean } = q
142+
const { key, answers, imageUrl, image_base64, image_alt_text, ...clean } = q
140143

141-
// Only add image field if it was explicitly set
142-
if (image_base64 !== undefined) {
143-
clean.image = image_base64 === '' ? '' : image_base64
144+
if (props.questionImagesEnabled) {
145+
if (image_base64 !== undefined) {
146+
clean.image = image_base64 === '' ? '' : image_base64
147+
}
148+
clean.image_alt_text = image_alt_text || ''
144149
}
145-
// Otherwise, omit the field entirely - server will keep existing image
146150

147151
return clean
148152
}),
@@ -218,7 +222,12 @@ export const EditPollManagement = (props) => {
218222
}
219223

220224
return question.is_open
221-
? <EditPollOpenQuestion {...commonProps} onImageChange={(image) => handleQuestionImage(index, image)} />
225+
? <EditPollOpenQuestion
226+
{...commonProps}
227+
onImageChange={(image) => handleQuestionImage(index, image)}
228+
onAltTextChange={(text) => handleQuestionAltText(index, text)}
229+
questionImagesEnabled={props.questionImagesEnabled}
230+
/>
222231
: <EditPollQuestion
223232
{...commonProps}
224233
onMultipleChoiceChange={(val) => handleQuestionMultiChoice(index, val)}
@@ -227,6 +236,8 @@ export const EditPollManagement = (props) => {
227236
onDeleteChoice={(cIndex) => handleChoiceDelete(index, cIndex)}
228237
onAppendChoice={(hasOther) => handleChoiceAppend(index, hasOther)}
229238
onImageChange={(image) => handleQuestionImage(index, image)}
239+
onAltTextChange={(text) => handleQuestionAltText(index, text)}
240+
questionImagesEnabled={props.questionImagesEnabled}
230241
/>
231242
})}
232243
</FlipMove>

adhocracy4/polls/static/PollDashboard/EditPollOpenQuestion.jsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,16 @@ export const EditPollOpenQuestion = React.forwardRef((props, ref) => {
3333
{hasHelptext
3434
? <HelptextForm id={props.id} question={props.question} onHelptextChange={props.onHelptextChange} errors={props.errors} />
3535
: null}
36-
<QuestionImageUploadButton
37-
id={props.id}
38-
question={props.question}
39-
onImageChange={props.onImageChange}
40-
/>
36+
{props.questionImagesEnabled && (
37+
<QuestionImageUploadButton
38+
id={props.id}
39+
question={props.question}
40+
onImageChange={props.onImageChange}
41+
helpText={props.question.image_help_text}
42+
altText={props.question.image_alt_text}
43+
onAltTextChange={props.onAltTextChange}
44+
/>
45+
)}
4146
<EditPollCheckbox
4247
id={props.id}
4348
field="is_confidential"

adhocracy4/polls/static/PollDashboard/EditPollQuestion.jsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,17 @@ export const EditPollQuestion = React.forwardRef((props, ref) => {
3535
{hasHelptext
3636
? <HelptextForm id={props.id} question={props.question} onHelptextChange={props.onHelptextChange} errors={props.errors} />
3737
: null}
38-
<QuestionImageUploadButton
39-
id={props.id}
40-
question={props.question}
41-
onImageChange={props.onImageChange}
42-
error={props.errors?.image_base64}
43-
/>
38+
{props.questionImagesEnabled && (
39+
<QuestionImageUploadButton
40+
id={props.id}
41+
question={props.question}
42+
onImageChange={props.onImageChange}
43+
error={props.errors?.image_base64}
44+
helpText={props.question.image_help_text}
45+
altText={props.question.image_alt_text}
46+
onAltTextChange={props.onAltTextChange}
47+
/>
48+
)}
4449

4550
<EditPollCheckbox
4651
id={props.id}

adhocracy4/polls/static/PollDashboard/QuestionImageUploadButton.jsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import React, { useRef } from 'react'
33
import django from 'django'
44

5-
const QuestionImageUploadButton = ({ id, question, onImageChange, error }) => {
5+
const QuestionImageUploadButton = ({ id, question, onImageChange, error, helpText, altText, onAltTextChange }) => {
66
const fileInputRef = useRef(null)
77

88
const handleFileChange = (e) => {
@@ -31,6 +31,8 @@ const QuestionImageUploadButton = ({ id, question, onImageChange, error }) => {
3131
{django.gettext('Question image')}
3232
</label>
3333

34+
{helpText && <div className="form-hint">{helpText}</div>}
35+
3436
<div className={`image-upload-container ${error ? 'is-invalid' : ''}`}>
3537
<span className="image-upload-text">
3638
{question.image_url
@@ -84,6 +86,22 @@ const QuestionImageUploadButton = ({ id, question, onImageChange, error }) => {
8486
</div>
8587
)}
8688

89+
{question.image_url && (
90+
<div className="form-group">
91+
<label htmlFor={`id_questions-${id}-image_alt_text`}>
92+
{django.gettext('Alt text')}
93+
</label>
94+
<input
95+
type="text"
96+
id={`id_questions-${id}-image_alt_text`}
97+
className="form-control"
98+
value={altText || ''}
99+
onChange={(e) => onAltTextChange(e.target.value)}
100+
maxLength={80}
101+
/>
102+
</div>
103+
)}
104+
87105
<input
88106
ref={fileInputRef}
89107
type="file"

adhocracy4/polls/static/PollDetail/PollChoice.jsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,12 @@ export const PollChoice = (props) => {
7373
<legend className="poll__question-legend">
7474
<h3>{props.question.label}</h3>
7575
</legend>
76-
<QuestionImage
77-
imageUrl={props.question.image_url}
78-
alt={props.question.label}
79-
/>
76+
{props.questionImagesEnabled && (
77+
<QuestionImage
78+
imageUrl={props.question.image_url}
79+
alt={props.question.image_alt_text || props.question.label}
80+
/>
81+
)}
8082
{questionHelpText}
8183
{multiHelpText}
8284
{props.question.is_confidential && <ConfidentialNotice />}

adhocracy4/polls/static/PollDetail/PollOpenQuestion.jsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ export const PollOpenQuestion = ({
77
question,
88
allowUnregisteredUsers,
99
onOpenChange,
10-
errors
10+
errors,
11+
questionImagesEnabled
1112
}) => {
1213
const getUserOpenAnswer = () => {
1314
const userAnswerId = question.userAnswer
@@ -36,10 +37,12 @@ export const PollOpenQuestion = ({
3637
<div className="poll poll--question">
3738
<h3>{question.label}</h3>
3839
{questionHelpText}
39-
<QuestionImage
40-
imageUrl={question.image_url}
41-
alt={question.label}
42-
/>
40+
{questionImagesEnabled && (
41+
<QuestionImage
42+
imageUrl={question.image_url}
43+
alt={question.image_alt_text || question.label}
44+
/>
45+
)}
4346
{question.is_confidential && <ConfidentialNotice />}
4447
<TextareaWithCounter
4548
value={userAnswer}

adhocracy4/polls/static/PollDetail/PollQuestions.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ class PollQuestions extends React.Component {
430430
onOpenChange={(questionId, voteData) =>
431431
this.handleVoteOpen(questionId, voteData)}
432432
errors={this.state.errors}
433+
questionImagesEnabled={this.props.questionImagesEnabled}
433434
/>
434435
)
435436
: (
@@ -444,6 +445,7 @@ class PollQuestions extends React.Component {
444445
onOtherChange={(questionId, voteAnswer, otherChoice) =>
445446
this.handleVoteOther(questionId, voteAnswer, otherChoice)}
446447
errors={this.state.errors}
448+
questionImagesEnabled={this.props.questionImagesEnabled}
447449
/>
448450
)
449451
)}

0 commit comments

Comments
 (0)