The feedback app owns /forms/: a single, fixed public feedback form. There is no admin-configurable form builder and no per-form slug - one form, one URL, same shape for every association. This is a separate app from harassment - the two forms live at their own URLs, save to their own models, and are reviewed in their own admin sections; there is no shared page or shared model between them.
FeedbackSubmission captures a feedback submission with an optional email plus a free-text message (max 1500 chars). __str__ returns a content-free f'Inskickad feedback #{self.pk}', never the message: Django copies str(obj)[:200] into django_admin_log.object_repr on every admin add, change, and delete, and that log is readable by any staff member with admin.view_logentry, not just the configured feedback recipients. FeedbackSubmissionAdmin.message_preview is the authorised place to read the message in the admin; see FeedbackAdminLogRedactionTests in feedback/tests.py, and harassment/redaction.py for the sibling app’s version of the same fix.FeedbackEmailRecipient stores the feedback notification recipient list, structurally the same idea as harassment.HarassmentEmailRecipient but a completely separate table - harassment reports still notify harassment.HarassmentEmailRecipients.FeedbackFormSettings holds the small amount of copy an admin can edit without a code change - currently just intro_text, the paragraph shown above the form. feedback/migrations/0003_seed_feedbackformsettings.py seeds that row (DEFAULT_INTRO_TEXT in intro_text_sv) once per database, so the public path is a read and no anonymous visit ever writes a row. get_intro_text() is that read: it returns the lowest-pk row without writing, and falls back to DEFAULT_INTRO_TEXT when no row exists (a hand-deleted row, or a database restored from before the migration ran) so the page still renders. get_solo() returns the same lowest-pk row and still creates it when the table is empty, so admin and test callers cannot break on a missing row; it does not pin pk=1. FeedbackFormSettingsAdmin.get_queryset pins the changelist to that same lowest-pk row, so an editor can only ever change the row that takes effect. feedback.translation.py registers intro_text with django-modeltranslation, so it gets intro_text_sv/intro_text_en/intro_text_fi columns like any other translated content field (see the Translations section in AGENTS.md); the form template just reads `` and modeltranslation resolves the active language. The seed fills the intro_text_sv column only, on purpose - the field’s own default is '' for every language, so an admin opening the English or Finnish tab for the first time sees an honestly blank field to translate, not the Swedish text copied into every tab looking as if it were already translated. The public page still reads correctly in every language before anyone translates it, because an empty column equal to the field’s default is what triggers django-modeltranslation’s fallback to the source language (sv, via MODELTRANSLATION_FALLBACK_LANGUAGES’s unset default).FeedbackSubmissionForm is a ModelForm that adds Bootstrap classes to its fields, plus a non-model consent BooleanField. The captcha token is read with request.POST.get('cf-turnstile-response').FeedbackSubmissionForm.clean(): the message alone needs no consent, but an email address is personal data, so consent is required exactly when an email is filled in. The checkbox reuses the field’s own “required” error message, and the same Jag godkänner/villkoren strings the harassment form and member signup use, instead of adding duplicates to the locale catalogues. FeedbackFormConsentTests covers rejected-without-consent, message-only, and consented submissions. Like the harassment form’s checkbox, this one is not a model field: nothing about the consent itself is stored.feedback.views.feedback_form follows the same PRG (Post-Redirect-Get) pattern as harassment.views.harassment_form: on a valid POST (form.is_valid() before validate_captcha(...), so an invalid submission never triggers the outbound Cloudflare call) it saves the submission, enqueues a notification email after commit if any recipients are configured, sets a one-shot session flag, and redirects back to /forms/; the next GET consumes that flag and renders the thank-you page instead of the form. The flag is consumed on GET only: a POST that arrives while it is still set (the redirect GET never landed) is validated and saved rather than answered with the thank-you page and silently dropped - see FeedbackFormSuccessFlagTests and the same fix in harassment/views.py. A rejected captcha adds a non-field error to the bound form instead of silently re-rendering it, and logs a warning when a valid submission has no configured recipients. The email context (including the admin reverse()) is built only when there is at least one recipient. The view passes intro_text into the template context with FeedbackFormSettings.get_intro_text(), a plain read that never writes.templates/common/feedback/feedback_form.html renders and the per-field//, so a mistyped email, a missing consent, or a failed captcha explains itself on the re-rendered page. The consent checkbox sits in the same <p class="consent-form-group mt-2"> block, with the same link to /pages/registerbeskrivning/, as the harassment form.'forms' entry in core.urls.common.ROUTES, mounted at forms/ with a single route ('', i.e. the form lives directly at /forms/).core/urls/<variant>.py’s build_urlpatterns(...) call, same pattern as 'pages'. Also listed in each variant’s INSTALLED_APPS (next to 'staticpages') and in core/tests/test_url_route_parity.py’s EXPECTED_PREFIXES.templates/common/feedback/feedback_admin_email.txt receives only submission_url. That is all the template renders, so the submission’s message text never travels in the notification email..txt template: djlint does not inspect .txt files at all (its templates/common/feedback/ check covers the two HTML templates only), so the body’s whitespace is exactly what the template contains and needs no formatter-compatible formatting.FeedbackSubmissionAdmin and FeedbackEmailRecipientAdmin are both plain ModelAdmins - no inlines, no per-form scoping, since there’s only one feedback form.FeedbackFormSettingsAdmin locks the row count to one: has_add_permission refuses once a row exists, has_delete_permission always refuses, and get_queryset filters the changelist to the solo row (the lowest pk, read with a plain query rather than get_solo() so a GET of the changelist does not create it). When ENABLE_LANGUAGE_FEATURES is on it uses the same ActiveLanguageTranslationAdminMixin + LanguageTabbedTranslationAdmin combination as functionaries.FunctionaryRoleAdmin, so the change form shows per-language tabs for intro_text; otherwise it falls back to a plain ModelAdmin, whose change form lists all four intro_text columns (intro_text plus intro_text_sv/intro_text_en/intro_text_fi) as a flat field list, the three translated ones carrying modeltranslation’s appended language suffix in their label.core/admin_ui.py’s SIDEBAR_NAVIGATION) under Social & Ads, as Feedback, Feedback Recipients, and Feedback Settings, alongside the separate Harassment entries.FeedbackForm, /forms/<slug>/), and later a single page combining feedback with the harassment report form as two switchable panels. Both were dropped before shipping in favor of a plain, standalone form - there’s no migration history to preserve either way.templates/common/social/harassment_form.html still renders form.message/form.email without `` or non-field errors, so a harassment reporter who fails validation (a bad email) gets the blank form back with no explanation and has to retype their message. The feedback form now renders both, so the two forms behave differently for no reason a visitor could tell. An empty TURNSTILE_SECRET_KEY also makes core.utils.validate_captcha return True for every submission; that fail-open default is shared with the harassment form and member signup and is relied on in dev and test, so it is left as it is.harassment.