From ef11d2f6824e89b31e745a6a7b50f7f5738e2382 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 15:24:52 +0000 Subject: [PATCH 1/5] feat(feedback): add a bug report and feature request form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reporting a problem meant opening a GitHub issue, which needs a GitHub account — the issue notes this is exactly what stops users giving feedback. Adds a public form at /feedback/, linked from the footer: report type, description, optional e-mail and an optional attachment. Reports are stored and triaged in the admin rather than e-mailed, so nothing is lost when mail delivery is misconfigured and reports stay searchable. Attachments are restricted to screenshots, PDFs and text logs, capped at 5 MB and stored under a generated name, since both the file and its name come from anonymous visitors. Closes #691 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HXUw7kyELDu8ycGQxtaFbb --- src/core/admin.py | 11 ++- src/core/forms.py | 43 ++++++++++- src/core/jinja2/core/feedback.jinja2 | 28 +++++++ src/core/jinja2/core/footer.jinja2 | 1 + src/core/migrations/0045_feedback.py | 75 ++++++++++++++++++ src/core/models.py | 42 ++++++++++ src/core/tests/test_feedback_form.py | 110 +++++++++++++++++++++++++++ src/core/urls.py | 2 + src/core/views/views.py | 14 ++++ 9 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 src/core/jinja2/core/feedback.jinja2 create mode 100644 src/core/migrations/0045_feedback.py create mode 100644 src/core/tests/test_feedback_form.py diff --git a/src/core/admin.py b/src/core/admin.py index 761d3ff0..851aaba7 100644 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -11,7 +11,7 @@ from core.marker_utils import generate_marker_variants from core.media_dimensions import extract_dimensions -from core.models import Artwork, Exhibit, Marker, Object, Sound +from core.models import Artwork, Exhibit, Feedback, Marker, Object, Sound from core.spritesheet_converter import gif_to_spritesheet from core.utils import filesizeformat, get_admin_url @@ -392,3 +392,12 @@ def _owner(self, obj): def preview(self, obj): return format_html(obj.as_html(), "") + + +@admin.register(Feedback) +class FeedbackAdmin(admin.ModelAdmin): + list_display = ["__str__", "kind", "email", "handled", "created"] + list_filter = ["kind", "handled"] + list_editable = ["handled"] + search_fields = ["description", "email"] + readonly_fields = ["kind", "description", "email", "attachment", "created"] diff --git a/src/core/forms.py b/src/core/forms.py index 6806a9ba..7d88b8d1 100644 --- a/src/core/forms.py +++ b/src/core/forms.py @@ -6,7 +6,7 @@ from core.marker_utils import generate_marker_variants from core.media_dimensions import extract_dimensions -from core.models import Artwork, Marker, ObjectExtensions +from core.models import Artwork, Feedback, Marker, ObjectExtensions from .models import Exhibit, ExhibitTypes, Object, Sound @@ -53,6 +53,10 @@ def render(self, name, value, attrs=None, renderer=None): return mark_safe(template) +# Feedback attachments come from anonymous visitors, so keep them small. +MAX_FEEDBACK_ATTACHMENT_BYTES = 5 * 1024 * 1024 + + class UploadObjectForm(forms.ModelForm): selected_sound = forms.ModelChoiceField( queryset=Sound.objects.all(), @@ -418,3 +422,40 @@ def save(self, *args, **kwargs): self.instance.file_extension = self.instance.file.name.split(".")[-1].lower() return super(SoundForm, self).save(*args, **kwargs) + + +class FeedbackForm(forms.ModelForm): + """Public bug report / feature request form linked from the footer.""" + + class Meta: + model = Feedback + fields = ("kind", "description", "email", "attachment") + widgets = { + "description": forms.Textarea( + attrs={ + "rows": 6, + "placeholder": _("What happened, and what did you expect?"), + } + ), + "email": forms.EmailInput( + attrs={"placeholder": _("Your e-mail (optional, so we can reply)")} + ), + } + + def clean_attachment(self): + attachment = self.cleaned_data.get("attachment") + if not attachment: + return None + + allowed_extensions = ["png", "jpg", "jpeg", "gif", "webp", "pdf", "txt", "log"] + extension = getattr(attachment, "name", "").split(".")[-1].lower() + if extension not in allowed_extensions: + raise forms.ValidationError( + _("Attach a screenshot (PNG, JPG, GIF, WebP), a PDF or a text log.") + ) + if attachment.size > MAX_FEEDBACK_ATTACHMENT_BYTES: + raise forms.ValidationError( + _("Attachments are limited to %(limit)s MB."), + params={"limit": MAX_FEEDBACK_ATTACHMENT_BYTES // (1024 * 1024)}, + ) + return attachment diff --git a/src/core/jinja2/core/feedback.jinja2 b/src/core/jinja2/core/feedback.jinja2 new file mode 100644 index 00000000..ef9b0efa --- /dev/null +++ b/src/core/jinja2/core/feedback.jinja2 @@ -0,0 +1,28 @@ +{% extends "core/base.jinja2" %} +{% block content %} +
+ {% if submitted %} +

{{ _("Thank you!") }}

+

{{ _("Your report was received. If you left an e-mail, we may get back to you.") }}

+ {{ _("Back to Jandig") }} + {% else %} +

{{ _("Report a bug or request a feature") }}

+

+ {{ _("Found something broken, or missing? Tell us here — no GitHub account needed.") }} +

+
+ {{ csrf_input }} + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %}{{ field.help_text }}{% endif %} + {{ field.errors }} +
+ {% endfor %} + {{ form.non_field_errors() }} + +
+ {% endif %} +
+{% endblock content %} diff --git a/src/core/jinja2/core/footer.jinja2 b/src/core/jinja2/core/footer.jinja2 index af13af06..6765d2cd 100644 --- a/src/core/jinja2/core/footer.jinja2 +++ b/src/core/jinja2/core/footer.jinja2 @@ -1,6 +1,7 @@