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
11 changes: 10 additions & 1 deletion src/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -418,3 +418,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"]
41 changes: 40 additions & 1 deletion src/core/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
strip_existing_border,
)
from core.media_dimensions import extract_dimensions
from core.models import Artwork, Marker, ObjectExtensions
from core.models import Artwork, Marker, ObjectExtensions, Feedback
from core.upload_limits import (
AUDIO,
OBJECT_KIND_BY_EXTENSION,
Expand Down Expand Up @@ -93,6 +93,8 @@ 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
# Markers are rasterised and uploaded to the AR viewer as textures, so an
# upload far larger than a 4K texture costs storage and marker-generation time
# without improving detection. Both sides are capped at this value.
Expand Down Expand Up @@ -606,3 +608,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
28 changes: 28 additions & 0 deletions src/core/jinja2/core/feedback.jinja2
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{% extends "core/focused_layout.jinja2" %}
{% block content %}
<main class="container feedback">
{% if submitted %}
<h1>{{ _("Thank you!") }}</h1>
<p>{{ _("Your report was received. If you left an e-mail, we may get back to you.") }}</p>
<a class="button" href="{{ url('home') }}">{{ _("Back to Jandig") }}</a>
{% else %}
<h1>{{ _("Report a bug or request a feature") }}</h1>
<p>
{{ _("Found something broken, or missing? Tell us here — no GitHub account needed.") }}
</p>
<form method="post" enctype="multipart/form-data" novalidate>
{{ csrf_input }}
{% for field in form %}
<div class="form-field">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}
{{ field.errors }}
</div>
{% endfor %}
{{ form.non_field_errors() }}
<button type="submit" class="button">{{ _("Send") }}</button>
</form>
{% endif %}
</main>
{% endblock content %}
1 change: 1 addition & 0 deletions src/core/jinja2/core/footer.jinja2
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<section class="footer">
<div class="container flex">
<a href="{{ url('community') }}" class="useful">{{ _("About Us") }}</a>
<a href="{{ url('feedback') }}" class="useful">{{ _("Report a bug") }}</a>
<div class="social flex">
<a href="https://www.instagram.com/jandig.art/"
class="instagram"
Expand Down
71 changes: 71 additions & 0 deletions src/core/migrations/0048_feedback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Generated by Django 6.1.1 on 2026-09-17 15:21

import core.models
import django_extensions.db.fields
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("core", "0047_remove_artwork_insert_insert_and_more"),
]

operations = [
migrations.CreateModel(
name="Feedback",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
models.DateTimeField(auto_now_add=True, db_index=True),
),
(
"modified",
models.DateTimeField(auto_now=True, db_index=True)
),
(
"kind",
models.CharField(
choices=[
("bug", "Report a bug"),
("feature", "Request a feature"),
],
db_index=True,
default="bug",
max_length=10,
),
),
("description", models.TextField(max_length=5000)),
("email", models.EmailField(blank=True, max_length=254)),
(
"attachment",
models.FileField(
blank=True,
null=True,
upload_to=core.models.feedback_attachment_path,
),
),
(
"handled",
models.BooleanField(
default=False,
help_text="Tick once this report has been triaged.",
),
),
],
options={
"verbose_name_plural": "feedback",
"get_latest_by": "modified",
"abstract": False,
},
),
]
44 changes: 44 additions & 0 deletions src/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -786,3 +786,47 @@ def exhibit_sounds_changed(sender, instance, action, pk_set, **kwargs):
# post_clear doesn't provide pk_set; recalculate all sounds
for sound in Sound.objects.filter(in_use=True):
_recalculate_sound_flags(sound.pk)


class FeedbackKinds(models.TextChoices):
BUG = "bug", _("Report a bug")
FEATURE = "feature", _("Request a feature")


def feedback_attachment_path(instance, filename):
"""Upload path: feedback/<uuid>.<ext> — the name comes from the public."""
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
name = uuid4().hex
return f"feedback/{name}.{ext}" if ext else f"feedback/{name}"


class Feedback(models.Model):
"""A bug report or feature request sent from the site's feedback form.

Stored rather than e-mailed so nothing is lost if mail delivery is
misconfigured, and so reports remain searchable in the admin.
"""

kind = models.CharField(
max_length=10,
choices=FeedbackKinds.choices,
default=FeedbackKinds.BUG,
db_index=True,
)
description = models.TextField(max_length=5000)
email = models.EmailField(blank=True)
attachment = models.FileField(
upload_to=feedback_attachment_path, null=True, blank=True
)
handled = models.BooleanField(
default=False, help_text="Tick once this report has been triaged."
)
created = models.DateTimeField(auto_now_add=True, db_index=True)
modified = models.DateTimeField(auto_now=True, db_index=True)

class Meta():
verbose_name_plural = "feedback"
get_latest_by = "modified"

def __str__(self):
return f"{self.get_kind_display()} #{self.pk}"
110 changes: 110 additions & 0 deletions src/core/tests/test_feedback_form.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import io

from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.urls import reverse
from PIL import Image

from core.forms import MAX_FEEDBACK_ATTACHMENT_BYTES
from core.models import Feedback, FeedbackKinds


def screenshot(name="shot.png"):
buffer = io.BytesIO()
Image.new("RGB", (8, 8), "white").save(buffer, format="PNG")
buffer.seek(0)
return SimpleUploadedFile(name, buffer.read(), content_type="image/png")


class TestFeedbackForm(TestCase):
def test_the_form_is_reachable_without_an_account(self):
response = self.client.get(reverse("feedback"))

assert response.status_code == 200

def test_a_bug_report_is_stored(self):
response = self.client.post(
reverse("feedback"),
{
"kind": FeedbackKinds.BUG,
"description": "The marker preview never loads on Firefox.",
"email": "someone@example.com",
},
)

assert response.status_code == 200
report = Feedback.objects.get()
assert report.kind == FeedbackKinds.BUG
assert report.email == "someone@example.com"
assert report.handled is False

def test_a_feature_request_is_stored_with_its_own_kind(self):
self.client.post(
reverse("feedback"),
{
"kind": FeedbackKinds.FEATURE,
"description": "Let me sort exhibits by name.",
},
)

assert Feedback.objects.get().kind == FeedbackKinds.FEATURE

def test_the_email_is_optional(self):
self.client.post(
reverse("feedback"),
{"kind": FeedbackKinds.BUG, "description": "Something broke."},
)

assert Feedback.objects.get().email == ""

def test_a_description_is_required(self):
self.client.post(reverse("feedback"), {"kind": FeedbackKinds.BUG})

assert not Feedback.objects.exists()

def test_an_attachment_is_stored_under_a_generated_name(self):
self.client.post(
reverse("feedback"),
{
"kind": FeedbackKinds.BUG,
"description": "See the screenshot.",
"attachment": screenshot("Captura de tela #1.png"),
},
)
report = Feedback.objects.get()

assert report.attachment.name.startswith("feedback/")
assert report.attachment.name.endswith(".png")
assert "Captura" not in report.attachment.name

def test_an_executable_attachment_is_rejected(self):
self.client.post(
reverse("feedback"),
{
"kind": FeedbackKinds.BUG,
"description": "Trying to attach a binary.",
"attachment": SimpleUploadedFile("payload.exe", b"MZ\x00\x00"),
},
)

assert not Feedback.objects.exists()

def test_an_oversized_attachment_is_rejected(self):
oversized = SimpleUploadedFile(
"huge.png", b"\x00" * (MAX_FEEDBACK_ATTACHMENT_BYTES + 1)
)
self.client.post(
reverse("feedback"),
{
"kind": FeedbackKinds.BUG,
"description": "Attaching something enormous.",
"attachment": oversized,
},
)

assert not Feedback.objects.exists()

def test_the_footer_links_to_the_form(self):
response = self.client.get(reverse("home"))

assert reverse("feedback").encode() in response.content
2 changes: 2 additions & 0 deletions src/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
exhibit_detail,
exhibit_qrcode,
exhibit_select,
feedback,
get_element,
marker_preview,
marker_upload,
Expand All @@ -57,6 +58,7 @@
path("community/", community, name="community"),
path("content/delete/", delete, name="delete-content"),
path("documentation/", documentation, name="documentation"),
path("feedback/", feedback, name="feedback"),
path("privacy/", privacy_policy, name="privacy-policy"),
path("exhibit_select/", exhibit_select, name="exhibit_select"),
path("exhibit/", exhibit_detail, name="exhibit-detail"),
Expand Down
14 changes: 14 additions & 0 deletions src/core/views/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
ArtworkForm,
ExhibitForm,
ExhibitSelectForm,
FeedbackForm,
SoundForm,
UploadMarkerForm,
UploadObjectForm,
Expand Down Expand Up @@ -995,3 +996,16 @@ def ar_view(request):
debug = request.GET.get("debug", "false").lower() == "true"
ctx = {"artworks": exhibit.artworks.all(), "debug": debug}
return render(request, "core/ar.jinja2", ctx)


@require_http_methods(["GET", "POST"])
def feedback(request):
"""Public bug report / feature request form, linked from the footer."""
if request.method == "POST":
form = FeedbackForm(request.POST, request.FILES)
if form.is_valid():
form.save()
return render(request, "core/feedback.jinja2", {"submitted": True})
else:
form = FeedbackForm()
return render(request, "core/feedback.jinja2", {"form": form})
Loading