diff --git a/locale/fi/LC_MESSAGES/django.po b/locale/fi/LC_MESSAGES/django.po
index c3f6ec86a..632561bcb 100644
--- a/locale/fi/LC_MESSAGES/django.po
+++ b/locale/fi/LC_MESSAGES/django.po
@@ -707,6 +707,11 @@ msgstr ""
"vaihtoehdot on hylätty tai lukittu.
%(rejected)s: Kaikki haetut "
"toistokerrat tälle hakemuksen osalle ovat lukittu tai hylätty.
"
+#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/reservation/form.py
+msgid "External UUID"
+msgstr "Ulkoinen UUID"
+
#: tilavarauspalvelu/admin/application_section/form.py
#: tilavarauspalvelu/admin/reservation/form.py
msgid "Number of persons"
@@ -724,6 +729,11 @@ msgstr "Varausten loppumispäivä"
msgid "Purpose"
msgstr "Käyttötarkoitus"
+#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/reservation/form.py
+msgid "ID for external systems to use"
+msgstr "Tunniste ulkoisten järjestelmien käyttöön"
+
#: tilavarauspalvelu/admin/application_section/form.py
msgid "Name that describes this section."
msgstr "Nimi joka kuvaa tätä hakeuksen osaa."
@@ -791,6 +801,10 @@ msgstr ""
"Onko ilmoitus luonnos. Luonnoksia ei näytetä käyttäjille, vaikka ne olisivat "
"aktiivisia."
+#: tilavarauspalvelu/admin/bug_report/admin.py
+msgid "Search by name or description"
+msgstr "Etsi nimellä tai kuvauksella"
+
#: tilavarauspalvelu/admin/city/admin.py
msgid "Municipality"
msgstr "Kunta"
@@ -1188,10 +1202,6 @@ msgstr "Ei"
msgid "Paid reservation"
msgstr "Maksullinen varaus"
-#: tilavarauspalvelu/admin/reservation/form.py
-msgid "External UUID"
-msgstr "Ulkoinen UUID"
-
#: tilavarauspalvelu/admin/reservation/form.py
#: tilavarauspalvelu/admin/reservation_unit/form.py
msgid "SKU"
@@ -1352,10 +1362,6 @@ msgstr "Syy hylkäämiselle"
msgid "Reason for cancellation"
msgstr "Syy perumiselle"
-#: tilavarauspalvelu/admin/reservation/form.py
-msgid "ID for external systems to use"
-msgstr "Tunniste ulkoisten järjestelmien käyttöön"
-
#: tilavarauspalvelu/admin/reservation/form.py
msgid "SKU for this particular reservation"
msgstr "Tämän varauksen SKU"
@@ -1560,6 +1566,19 @@ msgstr "Julkaisun tila"
msgid "Reservation state"
msgstr "Varattavuuden tila"
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid ""
+"Additional search terms that will bring up this reservation unit when making "
+"text searches in the customer UI. These terms should be added to make sure "
+"search results using text search in links from external sources work "
+"regardless of the UI language."
+msgstr ""
+"Ylimääräiset hakusanat, joilla tätä varausyksikköä on mahdollista hakea "
+"tekstihaun kautta asiakaskäyttöliittymässä. Hakusanoja tulee lisätä tähän "
+"listaan silloin kun halutaan taata, että järjestelmän ulkopuolelta tulevissa "
+"linkeissä käytettävät hakusanat tuottavat oikeat hakutulokset "
+"käyttöliittymän kielestä rippumatta."
+
#: tilavarauspalvelu/admin/reservation_unit/form.py
msgid "Description (Finnish)"
msgstr "Kuvaus (Suomeksi)"
@@ -1978,19 +1997,6 @@ msgstr "Maksujen kirjanpidon tiliöintitiedot"
msgid "Product used for payments"
msgstr "Tuote, jota käytetään maksuihin"
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid ""
-"Additional search terms that will bring up this reservation unit when making "
-"text searches in the customer UI. These terms should be added to make sure "
-"search results using text search in links from external sources work "
-"regardless of the UI language."
-msgstr ""
-"Ylimääräiset hakusanat, joilla tätä varausyksikköä on mahdollista hakea "
-"tekstihaun kautta asiakaskäyttöliittymässä. Hakusanoja tulee lisätä tähän "
-"listaan silloin kun halutaan taata, että järjestelmän ulkopuolelta tulevissa "
-"linkeissä käytettävät hakusanat tuottavat oikeat hakutulokset "
-"käyttöliittymän kielestä rippumatta."
-
#: tilavarauspalvelu/admin/space/admin.py
msgid "Search by name or unit name"
msgstr "Etsi nimellä tai toimipisteen nimellä"
@@ -3274,6 +3280,18 @@ msgstr ""
"'active_from' ja 'active_until' täytyy olla joko tyhjiä tai asetettuja. Jos "
"molemmat on asetettu, 'active_until' täytyy olla 'active_from' jälkeen."
+#: tilavarauspalvelu/models/bug_report/model.py
+msgid "Someone else, who?"
+msgstr "Joku muu, kuka?"
+
+#: tilavarauspalvelu/models/bug_report/model.py
+msgid "bug report"
+msgstr "virheilmoitus"
+
+#: tilavarauspalvelu/models/bug_report/model.py
+msgid "bug reports"
+msgstr "virheilmoitukset"
+
#: tilavarauspalvelu/models/city/model.py
msgid "city"
msgstr "kotikunta"
diff --git a/locale/sv/LC_MESSAGES/django.po b/locale/sv/LC_MESSAGES/django.po
index 7b5ad61c7..ebe37a614 100644
--- a/locale/sv/LC_MESSAGES/django.po
+++ b/locale/sv/LC_MESSAGES/django.po
@@ -681,6 +681,11 @@ msgid ""
"applied slots for this application section have been locked or rejected.
"
msgstr ""
+#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/reservation/form.py
+msgid "External UUID"
+msgstr ""
+
#: tilavarauspalvelu/admin/application_section/form.py
#: tilavarauspalvelu/admin/reservation/form.py
msgid "Number of persons"
@@ -698,6 +703,11 @@ msgstr ""
msgid "Purpose"
msgstr ""
+#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/reservation/form.py
+msgid "ID for external systems to use"
+msgstr ""
+
#: tilavarauspalvelu/admin/application_section/form.py
msgid "Name that describes this section."
msgstr ""
@@ -758,6 +768,10 @@ msgid ""
"would be active."
msgstr ""
+#: tilavarauspalvelu/admin/bug_report/admin.py
+msgid "Search by name or description"
+msgstr ""
+
#: tilavarauspalvelu/admin/city/admin.py
msgid "Municipality"
msgstr ""
@@ -1145,10 +1159,6 @@ msgstr ""
msgid "Paid reservation"
msgstr ""
-#: tilavarauspalvelu/admin/reservation/form.py
-msgid "External UUID"
-msgstr ""
-
#: tilavarauspalvelu/admin/reservation/form.py
#: tilavarauspalvelu/admin/reservation_unit/form.py
msgid "SKU"
@@ -1309,10 +1319,6 @@ msgstr ""
msgid "Reason for cancellation"
msgstr ""
-#: tilavarauspalvelu/admin/reservation/form.py
-msgid "ID for external systems to use"
-msgstr ""
-
#: tilavarauspalvelu/admin/reservation/form.py
msgid "SKU for this particular reservation"
msgstr ""
@@ -1517,6 +1523,14 @@ msgstr ""
msgid "Reservation state"
msgstr ""
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid ""
+"Additional search terms that will bring up this reservation unit when making "
+"text searches in the customer UI. These terms should be added to make sure "
+"search results using text search in links from external sources work "
+"regardless of the UI language."
+msgstr ""
+
#: tilavarauspalvelu/admin/reservation_unit/form.py
msgid "Description (Finnish)"
msgstr ""
@@ -1921,14 +1935,6 @@ msgstr ""
msgid "Product used for payments"
msgstr ""
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid ""
-"Additional search terms that will bring up this reservation unit when making "
-"text searches in the customer UI. These terms should be added to make sure "
-"search results using text search in links from external sources work "
-"regardless of the UI language."
-msgstr ""
-
#: tilavarauspalvelu/admin/space/admin.py
msgid "Search by name or unit name"
msgstr ""
@@ -3203,6 +3209,18 @@ msgid ""
"are set, 'active_until' must be after 'active_from'."
msgstr ""
+#: tilavarauspalvelu/models/bug_report/model.py
+msgid "Someone else, who?"
+msgstr ""
+
+#: tilavarauspalvelu/models/bug_report/model.py
+msgid "bug report"
+msgstr ""
+
+#: tilavarauspalvelu/models/bug_report/model.py
+msgid "bug reports"
+msgstr ""
+
#: tilavarauspalvelu/models/city/model.py
msgid "city"
msgstr ""
diff --git a/tests/test_admin/test_django_admin_site.py b/tests/test_admin/test_django_admin_site.py
index e16f53fcb..0957ed3f8 100644
--- a/tests/test_admin/test_django_admin_site.py
+++ b/tests/test_admin/test_django_admin_site.py
@@ -9,9 +9,11 @@
from django.urls import reverse
from tilavarauspalvelu.enums import EmailType
-from tilavarauspalvelu.models import RequestLog, Reservation, SQLLog
+from tilavarauspalvelu.models import BugReport, RequestLog, Reservation, SQLLog
+from tilavarauspalvelu.models.bug_report.model import BugReportPhaseChoice, BugReportUserChoice
from tilavarauspalvelu.tasks import create_or_update_reservation_statistics
from tilavarauspalvelu.utils.verkkokauppa.verkkokauppa_api_client import VerkkokauppaAPIClient
+from utils.date_utils import local_datetime
from tests import factories
from tests.helpers import patch_method
@@ -46,6 +48,12 @@ def create_all_models():
duration_ns=123,
stack_info="stack_info",
)
+ BugReport.objects.create(
+ name="Example",
+ phase=BugReportPhaseChoice.PULL_REQUEST,
+ found_by=BugReportUserChoice.MATTI,
+ found_at=local_datetime(),
+ )
create_or_update_reservation_statistics(Reservation.objects.values_list("pk", flat=True))
@@ -78,6 +86,9 @@ def test_django_admin_site__pages_load__model_admins(create_all_models):
# Edit & Delete views
elif url_pattern.lookup_str.endswith((".change_view", ".delete_view")):
first_object = model.objects.first()
+ if first_object is None:
+ pytest.fail(f"Did not find any objects for model '{model}'")
+
admin_url = reverse(f"admin:{url_pattern.name}", args=[first_object.pk])
assert client.get(admin_url).status_code == 200, admin_url
diff --git a/tilavarauspalvelu/admin/__init__.py b/tilavarauspalvelu/admin/__init__.py
index c98fb0a38..664370fa3 100644
--- a/tilavarauspalvelu/admin/__init__.py
+++ b/tilavarauspalvelu/admin/__init__.py
@@ -8,6 +8,7 @@
from .application_round.admin import ApplicationRoundAdmin
from .application_section.admin import ApplicationSectionAdmin
from .banner_notification.admin import BannerNotificationAdmin
+from .bug_report.admin import BugReportAdmin
from .city.admin import CityAdmin
from .equipment.admin import EquipmentAdmin
from .equipment_category.admin import EquipmentCategoryAdmin
@@ -53,6 +54,7 @@
"ApplicationRoundAdmin",
"ApplicationSectionAdmin",
"BannerNotificationAdmin",
+ "BugReportAdmin",
"CityAdmin",
"EquipmentAdmin",
"EquipmentCategoryAdmin",
diff --git a/tilavarauspalvelu/admin/bug_report/__init__.py b/tilavarauspalvelu/admin/bug_report/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tilavarauspalvelu/admin/bug_report/admin.py b/tilavarauspalvelu/admin/bug_report/admin.py
new file mode 100644
index 000000000..4c1fd55af
--- /dev/null
+++ b/tilavarauspalvelu/admin/bug_report/admin.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from django.contrib import admin
+from django.utils.translation import gettext_lazy as _
+from import_export.admin import ExportMixin
+from import_export.formats.base_formats import CSV
+
+from tilavarauspalvelu.models import BugReport
+
+
+@admin.register(BugReport)
+class BugReportAdmin(ExportMixin, admin.ModelAdmin):
+ # Functions
+ search_fields = [
+ "name",
+ "description",
+ ]
+ search_help_text = _("Search by name or description")
+ formats = [CSV]
+
+ # List
+ list_display = [
+ "name",
+ "found_at",
+ "fixed_at",
+ ]
+ list_filter = [
+ "phase",
+ "was_real_issue",
+ "found_by",
+ "fixed_by",
+ ]
+
+ # Form
+ fields = [
+ "name",
+ "description",
+ "phase",
+ "was_real_issue",
+ "found_by",
+ "found_at",
+ "fixed_by",
+ "fixed_at",
+ "created_at",
+ "updated_at",
+ ]
+ readonly_fields = [
+ "created_at",
+ "updated_at",
+ ]
diff --git a/tilavarauspalvelu/migrations/0054_bugreport.py b/tilavarauspalvelu/migrations/0054_bugreport.py
new file mode 100644
index 000000000..827622937
--- /dev/null
+++ b/tilavarauspalvelu/migrations/0054_bugreport.py
@@ -0,0 +1,93 @@
+# Generated by Django 5.1.3 on 2024-12-16 11:52
+from __future__ import annotations
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("tilavarauspalvelu", "0053_unique_application_section_ext_uuid"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="BugReport",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("name", models.CharField(max_length=255)),
+ ("description", models.TextField(blank=True, default="")),
+ (
+ "phase",
+ models.CharField(
+ choices=[
+ ("Pull Request", "Pull Request"),
+ ("Manual Testing", "Manual Testing"),
+ ("Production", "Production"),
+ ],
+ max_length=255,
+ ),
+ ),
+ ("was_real_issue", models.BooleanField(default=True)),
+ (
+ "found_by",
+ models.CharField(
+ choices=[
+ ("Eemeli", "Eemeli"),
+ ("Jesse", "Jesse"),
+ ("Joonatan", "Joonatan"),
+ ("Jusa", "Jusa"),
+ ("Matti", "Matti"),
+ ("Matu", "Matu"),
+ ("Milla", "Milla"),
+ ("Oiva", "Oiva"),
+ ("Risto", "Risto"),
+ ("Other", "Someone else, who?"),
+ ],
+ max_length=255,
+ ),
+ ),
+ ("found_at", models.DateTimeField()),
+ (
+ "fixed_by",
+ models.CharField(
+ blank=True,
+ choices=[
+ ("Eemeli", "Eemeli"),
+ ("Jesse", "Jesse"),
+ ("Joonatan", "Joonatan"),
+ ("Jusa", "Jusa"),
+ ("Matti", "Matti"),
+ ("Matu", "Matu"),
+ ("Milla", "Milla"),
+ ("Oiva", "Oiva"),
+ ("Risto", "Risto"),
+ ("Other", "Someone else, who?"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ("fixed_at", models.DateTimeField(blank=True, null=True)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ "verbose_name": "bug report",
+ "verbose_name_plural": "bug reports",
+ "db_table": "bug_report",
+ "ordering": ["pk"],
+ "base_manager_name": "objects",
+ "constraints": [
+ models.CheckConstraint(
+ check=models.Q(
+ models.Q(("fixed_by__isnull", True), ("fixed_at__isnull", True)),
+ models.Q(("fixed_by__isnull", False), ("fixed_at__isnull", False)),
+ _connector="OR",
+ ),
+ name="must_set_both_fixed_by_and_fixed_at_together",
+ violation_error_message="Bug report fixed by and fixed at must be set together.",
+ )
+ ],
+ },
+ ),
+ ]
diff --git a/tilavarauspalvelu/models/__init__.py b/tilavarauspalvelu/models/__init__.py
index 736a9d223..3b0bdb919 100644
--- a/tilavarauspalvelu/models/__init__.py
+++ b/tilavarauspalvelu/models/__init__.py
@@ -10,6 +10,7 @@
from .application_round_time_slot.model import ApplicationRoundTimeSlot
from .application_section.model import ApplicationSection
from .banner_notification.model import BannerNotification
+from .bug_report.model import BugReport
from .city.model import City
from .equipment.model import Equipment
from .equipment_category.model import EquipmentCategory
@@ -67,6 +68,7 @@
"ApplicationRoundTimeSlot",
"ApplicationSection",
"BannerNotification",
+ "BugReport",
"City",
"Equipment",
"EquipmentCategory",
diff --git a/tilavarauspalvelu/models/bug_report/__init__.py b/tilavarauspalvelu/models/bug_report/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tilavarauspalvelu/models/bug_report/actions.py b/tilavarauspalvelu/models/bug_report/actions.py
new file mode 100644
index 000000000..9f66bbca2
--- /dev/null
+++ b/tilavarauspalvelu/models/bug_report/actions.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from tilavarauspalvelu.models import BugReport
+
+__all__ = [
+ "BugReportActions",
+]
+
+
+class BugReportActions:
+ def __init__(self, bug_report: BugReport) -> None:
+ self.bug_report = bug_report
diff --git a/tilavarauspalvelu/models/bug_report/model.py b/tilavarauspalvelu/models/bug_report/model.py
new file mode 100644
index 000000000..fc640c86b
--- /dev/null
+++ b/tilavarauspalvelu/models/bug_report/model.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from functools import cached_property
+from typing import TYPE_CHECKING
+
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from .actions import BugReportActions
+from .queryset import BugReportManager
+
+if TYPE_CHECKING:
+ import datetime
+
+__all__ = [
+ "BugReport",
+]
+
+
+class BugReportUserChoice(models.TextChoices):
+ # Known users
+ EEMELI = "Eemeli"
+ JESSE = "Jesse"
+ JOONATAN = "Joonatan"
+ JUSA = "Jusa"
+ MATTI = "Matti"
+ MATU = "Matu"
+ MILLA = "Milla"
+ OIVA = "Oiva"
+ RISTO = "Risto"
+ # Unknown users
+ OTHER = "Other", _("Someone else, who?")
+
+
+class BugReportPhaseChoice(models.TextChoices):
+ PULL_REQUEST = "Pull Request"
+ MANUAL_TESTING = "Manual Testing"
+ PRODUCTION = "Production"
+
+
+class BugReport(models.Model):
+ name: str = models.CharField(max_length=255)
+ description: str = models.TextField(blank=True, default="")
+
+ phase: str = models.CharField(max_length=255, choices=BugReportPhaseChoice.choices)
+ was_real_issue: bool = models.BooleanField(default=True)
+
+ found_by: str = models.CharField(max_length=255, choices=BugReportUserChoice.choices)
+ found_at: datetime.datetime = models.DateTimeField()
+
+ fixed_by: str | None = models.CharField(max_length=255, choices=BugReportUserChoice.choices, null=True, blank=True)
+ fixed_at: datetime.datetime | None = models.DateTimeField(null=True, blank=True)
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ objects = BugReportManager()
+
+ class Meta:
+ db_table = "bug_report"
+ base_manager_name = "objects"
+ verbose_name = _("bug report")
+ verbose_name_plural = _("bug reports")
+ ordering = ["pk"]
+ constraints = [
+ models.CheckConstraint(
+ check=(
+ (models.Q(fixed_by__isnull=True) & models.Q(fixed_at__isnull=True))
+ | (models.Q(fixed_by__isnull=False) & models.Q(fixed_at__isnull=False))
+ ),
+ name="must_set_both_fixed_by_and_fixed_at_together",
+ violation_error_message="Bug report fixed by and fixed at must be set together.",
+ ),
+ ]
+
+ def __str__(self) -> str:
+ return f"{str(_('bug report')).capitalize()} {self.pk}: {self.name}"
+
+ @cached_property
+ def actions(self) -> BugReportActions:
+ # Import actions inline to defer loading them.
+ # This allows us to avoid circular imports.
+ return BugReportActions(self)
diff --git a/tilavarauspalvelu/models/bug_report/queryset.py b/tilavarauspalvelu/models/bug_report/queryset.py
new file mode 100644
index 000000000..0ab768dfc
--- /dev/null
+++ b/tilavarauspalvelu/models/bug_report/queryset.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from django.db import models
+
+__all__ = [
+ "BugReportManager",
+ "BugReportQuerySet",
+]
+
+
+class BugReportQuerySet(models.QuerySet): ...
+
+
+class BugReportManager(models.Manager.from_queryset(BugReportQuerySet)): ...