diff --git a/backend/locale/fi/LC_MESSAGES/django.po b/backend/locale/fi/LC_MESSAGES/django.po
index 1dd73990d8..34e4c9b717 100644
--- a/backend/locale/fi/LC_MESSAGES/django.po
+++ b/backend/locale/fi/LC_MESSAGES/django.po
@@ -145,6 +145,7 @@ msgstr "Varausyksikkövaihtoehto tälle allokoinnille."
#: tilavarauspalvelu/admin/allocated_timeslot/form.py
#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/admin.py
#: tilavarauspalvelu/admin/reservation/form.py
msgid "Begin time"
@@ -152,6 +153,7 @@ msgstr "Aloitusaika"
#: tilavarauspalvelu/admin/allocated_timeslot/form.py
#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/form.py
msgid "End time"
msgstr "Lopetusaika"
@@ -182,6 +184,7 @@ msgstr "Luotu"
#: tilavarauspalvelu/admin/application/admin.py
#: tilavarauspalvelu/admin/application_round/admin.py
#: tilavarauspalvelu/admin/application_section/admin.py
+#: tilavarauspalvelu/admin/recurring_reservation/admin.py
#: tilavarauspalvelu/admin/reservation/admin.py
#: tilavarauspalvelu/admin/reservation_unit/admin.py
#: tilavarauspalvelu/admin/unit/admin.py tilavarauspalvelu/admin/user/admin.py
@@ -196,6 +199,7 @@ msgstr "Hakija"
#: tilavarauspalvelu/admin/application/admin.py
#: tilavarauspalvelu/admin/application_round/admin.py
#: tilavarauspalvelu/admin/application_section/admin.py
+#: tilavarauspalvelu/admin/recurring_reservation/admin.py
#: tilavarauspalvelu/admin/reservation/admin.py
msgid "Time"
msgstr "Aika"
@@ -419,6 +423,7 @@ msgstr ""
#: tilavarauspalvelu/admin/bug_report/admin.py
#: tilavarauspalvelu/admin/city/admin.py
#: tilavarauspalvelu/admin/organisation/admin.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/form.py
#: tilavarauspalvelu/admin/reservation_unit/form.py
#: tilavarauspalvelu/admin/unit/form.py
@@ -616,8 +621,16 @@ msgstr "Onko varausyksikkö suljettu tänä viikonpäivänä?"
msgid "Search by name, application user's first name or last name"
msgstr "Etsi nimellä, hakijan etu- tai sukunimellä"
+#: tilavarauspalvelu/admin/application_section/admin.py
+#: tilavarauspalvelu/admin/recurring_reservation/admin.py
+#: tilavarauspalvelu/admin/reservation/admin.py
+#: tilavarauspalvelu/admin/reservation_unit/admin.py
+msgid "Pindora information"
+msgstr "Pindora tiedot"
+
#: tilavarauspalvelu/admin/application_section/filters.py
#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/form.py
msgid "Age group"
msgstr "Ikäryhmä"
@@ -629,6 +642,7 @@ msgstr "Varaustarkoitukset"
#: tilavarauspalvelu/admin/application_section/form.py
#: tilavarauspalvelu/admin/payment_order/admin.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
msgid "Reservation unit"
msgstr "Varausyksikkö"
@@ -711,6 +725,21 @@ msgstr ""
"toistokerrat tälle hakemuksen osalle ovat lukittu tai hylätty.
"
#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+#: tilavarauspalvelu/admin/reservation/form.py
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid "Pindora API response"
+msgstr "Pindora rajapinnan vastaus"
+
+#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+#: tilavarauspalvelu/admin/reservation/form.py
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid "Response from Pindora API"
+msgstr "Vastaus Pindora rajapinnasta"
+
+#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/form.py
#: tilavarauspalvelu/admin/reservation_unit/form.py
msgid "External UUID"
@@ -734,6 +763,13 @@ msgid "Purpose"
msgstr "Käyttötarkoitus"
#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/filters.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Should have active access code"
+msgstr "Pitäisi olla aktiivinen pääsykoodi"
+
+#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/form.py
#: tilavarauspalvelu/admin/reservation_unit/form.py
msgid "ID for external systems to use"
@@ -768,6 +804,10 @@ msgstr "Hakemuksen osan ikäryhmä."
msgid "Purpose for this section."
msgstr "Hakemuksen osan käyttötarkoitus."
+#: tilavarauspalvelu/admin/application_section/form.py
+msgid "Should this application section have an active access code?"
+msgstr "Tulisiko tässä hakemuksen osassa olla aktiivista pääsykoodia?"
+
#: tilavarauspalvelu/admin/banner_notification/admin.py
msgid "Name of the notification. Should be unique."
msgstr "Ilmoituksen nimi. Tulee olla uniikki."
@@ -807,6 +847,7 @@ msgstr ""
"aktiivisia."
#: tilavarauspalvelu/admin/bug_report/admin.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/form.py
#: tilavarauspalvelu/admin/reservation_unit/form.py
#: tilavarauspalvelu/admin/unit/form.py
@@ -1196,6 +1237,130 @@ msgstr "Henkilön puhelinnumero"
msgid "Search by first name or last name."
msgstr "Etsi etu- tai sukunimellä."
+#: tilavarauspalvelu/admin/recurring_reservation/filters.py
+#: tilavarauspalvelu/admin/reservation/filters.py
+msgid "Yes"
+msgstr "Kyllä"
+
+#: tilavarauspalvelu/admin/recurring_reservation/filters.py
+#: tilavarauspalvelu/admin/reservation/filters.py
+msgid "No"
+msgstr "Ei"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Weekdays"
+msgstr "Viikonpäivät"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid ""
+"Comma separated list of weekday integers (0-6) on which reservations exists "
+"on this recurring reservation"
+msgstr ""
+"Pilkuin eroteltu lista viikonpäiviä (0-6) jolloin varauksia on olemassa "
+"tässä toistuvassa varauksessa"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Begin date"
+msgstr "Aloituspäivä"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "End date"
+msgstr "Päättymispäivä"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Recurrence in days"
+msgstr "Toistoväli päivissä"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+#: tilavarauspalvelu/admin/request_log/admin.py
+msgid "Created"
+msgstr "Luotu"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+#: tilavarauspalvelu/admin/reservation/form.py
+msgid "User"
+msgstr "Käyttäjä"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Allocated time slot"
+msgstr "Jaettu vuoro"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+#: tilavarauspalvelu/admin/reservation/form.py
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid "Access type"
+msgstr "Kulkuoikeus"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Used access types"
+msgstr "Käytetyt kulkuoikeudet"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Name of the recurring reservation"
+msgstr "Toistuvan varauksen nimi"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Description of the recurring reservation"
+msgstr "Toistuvan varauksen kuvaus"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Begin date of the recurring reservation"
+msgstr "Toistuvan varauksen aloituspäivä"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Begin time of reservations in this recurring reservation"
+msgstr "Varauksen aloitusaika tässä toistuvassa varauksessa"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "End date of the recurring reservation"
+msgstr "Toistuvan varauksen päättymispäivä"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "End time of reservations in this recurring reservation"
+msgstr "Varauksen päättymisaika tässä toistuvassa varauksessa"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Interval between reservations in this recurring reservation"
+msgstr "Intervalli varausten välillä tässä toistuvassa varauksessa"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "When this recurring reservation was created"
+msgstr "Milloin tämä toistuva varaus luotiin"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "User that created this recurring reservation"
+msgstr "Toistuvan varauksen tehnyt käyttäjä"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Reservation unit for this recurring reservation"
+msgstr "Varausyksikkö tässä toistuvassa varauksessa."
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Allocated time slot this recurring reservation is for"
+msgstr "Jaettu vuoro jota tämä toituva varaus koskee "
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Age group for this recurring reservation"
+msgstr "Toistuvan varauksen ikäryhmä."
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Should this recurring reservation have an active access code?"
+msgstr "Tulisiko tässä toistuvassa varauksessa olla aktiivista pääsykoodia?"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid ""
+"Access type for the reservations in this recurring reservation (if "
+"unambiguous), otherwise access type will be 'multi-valued'"
+msgstr ""
+"Kulkutapa tässä toistuvassa varauksessa oleville varaukselle (jos "
+"yksiselitteinen), muussa tapauksessa kulkutapa on 'moniarvoinen'"
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid ""
+"All unique access types used in the reservations of this recurring "
+"reservation"
+msgstr "Kaikki varausten kulkutavat tässä toistuvassa varauksessa"
+
#: tilavarauspalvelu/admin/request_log/admin.py
#: tilavarauspalvelu/admin/sql_log/admin.py
msgid "Request ID"
@@ -1215,10 +1380,6 @@ msgstr "Sisältö"
msgid "Duration (ms)"
msgstr "Kesto (ms)"
-#: tilavarauspalvelu/admin/request_log/admin.py
-msgid "Created"
-msgstr "Luotu"
-
#: tilavarauspalvelu/admin/request_log/admin.py
msgid "Random ID for grouping this log with other logs from the same request."
msgstr ""
@@ -1278,10 +1439,6 @@ msgstr "Varaajan tiedot"
msgid "Billing information"
msgstr "Laskutustiedot"
-#: tilavarauspalvelu/admin/reservation/admin.py
-msgid "Pindora information"
-msgstr "Pindora tiedot"
-
#: tilavarauspalvelu/admin/reservation/admin.py
msgid "None of the selected reservations can be denied."
msgstr "Yhtään valittua varausta ei voida hylätä."
@@ -1315,14 +1472,6 @@ msgstr "Ei varauksia hyvitettävillä maksetuilla tilauksilla."
msgid "Recurring reservation"
msgstr "Toistuva varaus"
-#: tilavarauspalvelu/admin/reservation/filters.py
-msgid "Yes"
-msgstr "Kyllä"
-
-#: tilavarauspalvelu/admin/reservation/filters.py
-msgid "No"
-msgstr "Ei"
-
#: tilavarauspalvelu/admin/reservation/filters.py
msgid "Paid reservation"
msgstr "Maksullinen varaus"
@@ -1366,6 +1515,18 @@ msgstr "Käsitelty"
msgid "Confirmed at"
msgstr "Vahvistettu"
+#: tilavarauspalvelu/admin/reservation/form.py
+msgid "Access code is active"
+msgstr "Ovikoodi on aktiivinen"
+
+#: tilavarauspalvelu/admin/reservation/form.py
+msgid "Access code should be active"
+msgstr "Ovikoodi tulisi olla aktiivinen"
+
+#: tilavarauspalvelu/admin/reservation/form.py
+msgid "Access code generated at"
+msgstr "Ovikoodi generoitu"
+
#: tilavarauspalvelu/admin/reservation/form.py
msgid "Price net"
msgstr "Nettohinta"
@@ -1466,15 +1627,6 @@ msgstr "Varauksen maksajan postitoimipaikka"
msgid "Billing address zip code"
msgstr "Varauksen maksajan postinumero"
-#: tilavarauspalvelu/admin/reservation/form.py
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid "Pindora API response"
-msgstr "Pindora rajapinnan vastaus"
-
-#: tilavarauspalvelu/admin/reservation/form.py
-msgid "User"
-msgstr "Käyttäjä"
-
#: tilavarauspalvelu/admin/reservation/form.py
msgid "Reason for deny"
msgstr "Syy hylkäämiselle"
@@ -1617,11 +1769,6 @@ msgstr "Varaajan kaupunki"
msgid "Reservee's zip code"
msgstr "Varaajan postinumero"
-#: tilavarauspalvelu/admin/reservation/form.py
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid "Response from Pindora API"
-msgstr "Vastaus Pindora rajapinnasta"
-
#: tilavarauspalvelu/admin/reservation/form.py
msgid "User who made the reservation"
msgstr "Varauksen tehnyt käyttäjä"
@@ -1675,11 +1822,6 @@ msgstr "Käyttöehdot"
msgid "Instructions"
msgstr "Ohjeet"
-#: tilavarauspalvelu/admin/reservation_unit/admin.py
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid "Access type"
-msgstr "Kulkuoikeus"
-
#: tilavarauspalvelu/admin/reservation_unit/admin.py
#: tilavarauspalvelu/admin/reservation_unit/form.py
#: tilavarauspalvelu/admin/unit/form.py
@@ -1694,6 +1836,18 @@ msgstr "Julkaisun tila"
msgid "Reservation state"
msgstr "Varattavuuden tila"
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid "Access type begin date"
+msgstr "Kulkutavan alkamispäivä"
+
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid "How is the reservee able to enter the space in their reservation unit?"
+msgstr "Millä tavalla varaaja varaaja pääsee sisään varausyksikön tilaan?"
+
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid "Begin date of this access type"
+msgstr "Päivä jolloin kulkutavan käyttö alkaa"
+
#: tilavarauspalvelu/admin/reservation_unit/form.py
msgid ""
"Additional search terms that will bring up this reservation unit when making "
@@ -1945,14 +2099,6 @@ msgstr "Kirjanpidon tiliöintitieto"
msgid "Payment product"
msgstr "Maksutuote"
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid "Access type start date"
-msgstr "Kulkuoikeuden alkamispäivä"
-
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid "Access type end date"
-msgstr "Kulkuoikeuden päättymispäivä"
-
#: tilavarauspalvelu/admin/reservation_unit/form.py
msgid "Payment terms for the reservation unit."
msgstr "Varausyksikön maksuehdot."
@@ -2137,26 +2283,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 "How is the reservee able to enter the space in their reservation unit?"
-msgstr "Millä tavalla varaaja varaaja pääsee sisään varausyksikön tilaan?"
-
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid ""
-"If set, this is the date from which the access type is used. If current date "
-"is before this date, the access type is 'unrestricted'."
-msgstr ""
-"Jos asetettu, kulkuoikeutta käytetään tästä päivästä eteenpäin. Jos nykyinen "
-"päivä on ennen tätä päivää, kulkuoikeus on 'rajoittamaton'."
-
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid ""
-"If set, this is the date before which the access type is used. If current "
-"date is after this date, the access type is 'unrestricted'."
-msgstr ""
-"Jos asetettu, kulkuoikeutta käytetään tähän päivään asti. Jos nykyinen päivä "
-"on tämän päivän jälkeen, kulkuoikeus on 'rajoittamaton'."
-
#: tilavarauspalvelu/admin/space/admin.py
msgid "Search by name or unit name"
msgstr "Etsi nimellä tai toimipisteen nimellä"
@@ -3124,6 +3250,11 @@ msgctxt "AccessType"
msgid "unrestricted"
msgstr "rajoittamaton"
+#: tilavarauspalvelu/enums.py
+msgctxt "AccessType"
+msgid "multi-valued"
+msgstr "moniarvoinen"
+
#: tilavarauspalvelu/integrations/email/template_context/application.py
msgctxt "Email"
msgid "'My applications' page"
@@ -3940,10 +4071,17 @@ msgstr "varausyksikkö"
msgid "reservation units"
msgstr "varausyksiköt"
-#: tilavarauspalvelu/models/reservation_unit/model.py
-msgid "Access type start date must be the same or before its end date"
-msgstr ""
-"Kulkuoikeuden alkamispäivän tulee olla sama tai ennen sen päättymispäivää"
+#: tilavarauspalvelu/models/reservation_unit_access_type/model.py
+msgid "reservation unit access type"
+msgstr "varausyksikön kulkutapa"
+
+#: tilavarauspalvelu/models/reservation_unit_access_type/model.py
+msgid "reservation unit access types"
+msgstr "varausyksikön kulkutavat"
+
+#: tilavarauspalvelu/models/reservation_unit_access_type/model.py
+msgid "Access type already exists for this reservation unit and date"
+msgstr "Kulkutapa tälle varausyksikölle on jo olemassa alkaen tältä päivältä"
#: tilavarauspalvelu/models/reservation_unit_cancellation_rule/model.py
msgid "reservation unit cancellation rule"
diff --git a/backend/locale/sv/LC_MESSAGES/django.po b/backend/locale/sv/LC_MESSAGES/django.po
index 2d3a0fc39b..db25c58407 100644
--- a/backend/locale/sv/LC_MESSAGES/django.po
+++ b/backend/locale/sv/LC_MESSAGES/django.po
@@ -143,6 +143,7 @@ msgstr ""
#: tilavarauspalvelu/admin/allocated_timeslot/form.py
#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/admin.py
#: tilavarauspalvelu/admin/reservation/form.py
msgid "Begin time"
@@ -150,6 +151,7 @@ msgstr ""
#: tilavarauspalvelu/admin/allocated_timeslot/form.py
#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/form.py
msgid "End time"
msgstr ""
@@ -180,6 +182,7 @@ msgstr ""
#: tilavarauspalvelu/admin/application/admin.py
#: tilavarauspalvelu/admin/application_round/admin.py
#: tilavarauspalvelu/admin/application_section/admin.py
+#: tilavarauspalvelu/admin/recurring_reservation/admin.py
#: tilavarauspalvelu/admin/reservation/admin.py
#: tilavarauspalvelu/admin/reservation_unit/admin.py
#: tilavarauspalvelu/admin/unit/admin.py tilavarauspalvelu/admin/user/admin.py
@@ -194,6 +197,7 @@ msgstr ""
#: tilavarauspalvelu/admin/application/admin.py
#: tilavarauspalvelu/admin/application_round/admin.py
#: tilavarauspalvelu/admin/application_section/admin.py
+#: tilavarauspalvelu/admin/recurring_reservation/admin.py
#: tilavarauspalvelu/admin/reservation/admin.py
msgid "Time"
msgstr ""
@@ -408,6 +412,7 @@ msgstr ""
#: tilavarauspalvelu/admin/bug_report/admin.py
#: tilavarauspalvelu/admin/city/admin.py
#: tilavarauspalvelu/admin/organisation/admin.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/form.py
#: tilavarauspalvelu/admin/reservation_unit/form.py
#: tilavarauspalvelu/admin/unit/form.py
@@ -599,8 +604,16 @@ msgstr ""
msgid "Search by name, application user's first name or last name"
msgstr ""
+#: tilavarauspalvelu/admin/application_section/admin.py
+#: tilavarauspalvelu/admin/recurring_reservation/admin.py
+#: tilavarauspalvelu/admin/reservation/admin.py
+#: tilavarauspalvelu/admin/reservation_unit/admin.py
+msgid "Pindora information"
+msgstr ""
+
#: tilavarauspalvelu/admin/application_section/filters.py
#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/form.py
msgid "Age group"
msgstr ""
@@ -612,6 +625,7 @@ msgstr ""
#: tilavarauspalvelu/admin/application_section/form.py
#: tilavarauspalvelu/admin/payment_order/admin.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
msgid "Reservation unit"
msgstr ""
@@ -685,6 +699,21 @@ msgid ""
msgstr ""
#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+#: tilavarauspalvelu/admin/reservation/form.py
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid "Pindora API response"
+msgstr ""
+
+#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+#: tilavarauspalvelu/admin/reservation/form.py
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid "Response from Pindora API"
+msgstr ""
+
+#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/form.py
#: tilavarauspalvelu/admin/reservation_unit/form.py
msgid "External UUID"
@@ -708,6 +737,13 @@ msgid "Purpose"
msgstr ""
#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/filters.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Should have active access code"
+msgstr ""
+
+#: tilavarauspalvelu/admin/application_section/form.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/form.py
#: tilavarauspalvelu/admin/reservation_unit/form.py
msgid "ID for external systems to use"
@@ -741,6 +777,10 @@ msgstr ""
msgid "Purpose for this section."
msgstr ""
+#: tilavarauspalvelu/admin/application_section/form.py
+msgid "Should this application section have an active access code?"
+msgstr ""
+
#: tilavarauspalvelu/admin/banner_notification/admin.py
msgid "Name of the notification. Should be unique."
msgstr ""
@@ -774,6 +814,7 @@ msgid ""
msgstr ""
#: tilavarauspalvelu/admin/bug_report/admin.py
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
#: tilavarauspalvelu/admin/reservation/form.py
#: tilavarauspalvelu/admin/reservation_unit/form.py
#: tilavarauspalvelu/admin/unit/form.py
@@ -1154,6 +1195,126 @@ msgstr ""
msgid "Search by first name or last name."
msgstr ""
+#: tilavarauspalvelu/admin/recurring_reservation/filters.py
+#: tilavarauspalvelu/admin/reservation/filters.py
+msgid "Yes"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/filters.py
+#: tilavarauspalvelu/admin/reservation/filters.py
+msgid "No"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Weekdays"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid ""
+"Comma separated list of weekday integers (0-6) on which reservations exists "
+"on this recurring reservation"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Begin date"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "End date"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Recurrence in days"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+#: tilavarauspalvelu/admin/request_log/admin.py
+msgid "Created"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+#: tilavarauspalvelu/admin/reservation/form.py
+msgid "User"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Allocated time slot"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+#: tilavarauspalvelu/admin/reservation/form.py
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid "Access type"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Used access types"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Name of the recurring reservation"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Description of the recurring reservation"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Begin date of the recurring reservation"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Begin time of reservations in this recurring reservation"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "End date of the recurring reservation"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "End time of reservations in this recurring reservation"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Interval between reservations in this recurring reservation"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "When this recurring reservation was created"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "User that created this recurring reservation"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Reservation unit for this recurring reservation"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Allocated time slot this recurring reservation is for"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Age group for this recurring reservation"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid "Should this recurring reservation have an active access code?"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid ""
+"Access type for the reservations in this recurring reservation (if "
+"unambiguous), otherwise access type will be 'multi-valued'"
+msgstr ""
+
+#: tilavarauspalvelu/admin/recurring_reservation/form.py
+msgid ""
+"All unique access types used in the reservations of this recurring "
+"reservation"
+msgstr ""
+
#: tilavarauspalvelu/admin/request_log/admin.py
#: tilavarauspalvelu/admin/sql_log/admin.py
msgid "Request ID"
@@ -1173,10 +1334,6 @@ msgstr ""
msgid "Duration (ms)"
msgstr ""
-#: tilavarauspalvelu/admin/request_log/admin.py
-msgid "Created"
-msgstr ""
-
#: tilavarauspalvelu/admin/request_log/admin.py
msgid "Random ID for grouping this log with other logs from the same request."
msgstr ""
@@ -1234,10 +1391,6 @@ msgstr ""
msgid "Billing information"
msgstr ""
-#: tilavarauspalvelu/admin/reservation/admin.py
-msgid "Pindora information"
-msgstr ""
-
#: tilavarauspalvelu/admin/reservation/admin.py
msgid "None of the selected reservations can be denied."
msgstr ""
@@ -1271,14 +1424,6 @@ msgstr ""
msgid "Recurring reservation"
msgstr ""
-#: tilavarauspalvelu/admin/reservation/filters.py
-msgid "Yes"
-msgstr ""
-
-#: tilavarauspalvelu/admin/reservation/filters.py
-msgid "No"
-msgstr ""
-
#: tilavarauspalvelu/admin/reservation/filters.py
msgid "Paid reservation"
msgstr ""
@@ -1322,6 +1467,18 @@ msgstr ""
msgid "Confirmed at"
msgstr ""
+#: tilavarauspalvelu/admin/reservation/form.py
+msgid "Access code is active"
+msgstr ""
+
+#: tilavarauspalvelu/admin/reservation/form.py
+msgid "Access code should be active"
+msgstr ""
+
+#: tilavarauspalvelu/admin/reservation/form.py
+msgid "Access code generated at"
+msgstr ""
+
#: tilavarauspalvelu/admin/reservation/form.py
msgid "Price net"
msgstr ""
@@ -1422,15 +1579,6 @@ msgstr ""
msgid "Billing address zip code"
msgstr ""
-#: tilavarauspalvelu/admin/reservation/form.py
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid "Pindora API response"
-msgstr ""
-
-#: tilavarauspalvelu/admin/reservation/form.py
-msgid "User"
-msgstr ""
-
#: tilavarauspalvelu/admin/reservation/form.py
msgid "Reason for deny"
msgstr ""
@@ -1573,11 +1721,6 @@ msgstr ""
msgid "Reservee's zip code"
msgstr ""
-#: tilavarauspalvelu/admin/reservation/form.py
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid "Response from Pindora API"
-msgstr ""
-
#: tilavarauspalvelu/admin/reservation/form.py
msgid "User who made the reservation"
msgstr ""
@@ -1631,11 +1774,6 @@ msgstr ""
msgid "Instructions"
msgstr ""
-#: tilavarauspalvelu/admin/reservation_unit/admin.py
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid "Access type"
-msgstr ""
-
#: tilavarauspalvelu/admin/reservation_unit/admin.py
#: tilavarauspalvelu/admin/reservation_unit/form.py
#: tilavarauspalvelu/admin/unit/form.py
@@ -1650,6 +1788,18 @@ msgstr ""
msgid "Reservation state"
msgstr ""
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid "Access type begin date"
+msgstr ""
+
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid "How is the reservee able to enter the space in their reservation unit?"
+msgstr ""
+
+#: tilavarauspalvelu/admin/reservation_unit/form.py
+msgid "Begin date of this access type"
+msgstr ""
+
#: tilavarauspalvelu/admin/reservation_unit/form.py
msgid ""
"Additional search terms that will bring up this reservation unit when making "
@@ -1896,14 +2046,6 @@ msgstr ""
msgid "Payment product"
msgstr ""
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid "Access type start date"
-msgstr ""
-
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid "Access type end date"
-msgstr ""
-
#: tilavarauspalvelu/admin/reservation_unit/form.py
msgid "Payment terms for the reservation unit."
msgstr ""
@@ -2074,22 +2216,6 @@ msgstr ""
msgid "Product used for payments"
msgstr ""
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid "How is the reservee able to enter the space in their reservation unit?"
-msgstr ""
-
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid ""
-"If set, this is the date from which the access type is used. If current date "
-"is before this date, the access type is 'unrestricted'."
-msgstr ""
-
-#: tilavarauspalvelu/admin/reservation_unit/form.py
-msgid ""
-"If set, this is the date before which the access type is used. If current "
-"date is after this date, the access type is 'unrestricted'."
-msgstr ""
-
#: tilavarauspalvelu/admin/space/admin.py
msgid "Search by name or unit name"
msgstr ""
@@ -3048,6 +3174,11 @@ msgctxt "AccessType"
msgid "unrestricted"
msgstr ""
+#: tilavarauspalvelu/enums.py
+msgctxt "AccessType"
+msgid "multi-valued"
+msgstr ""
+
#: tilavarauspalvelu/integrations/email/template_context/application.py
msgctxt "Email"
msgid "'My applications' page"
@@ -3857,8 +3988,16 @@ msgstr ""
msgid "reservation units"
msgstr ""
-#: tilavarauspalvelu/models/reservation_unit/model.py
-msgid "Access type start date must be the same or before its end date"
+#: tilavarauspalvelu/models/reservation_unit_access_type/model.py
+msgid "reservation unit access type"
+msgstr ""
+
+#: tilavarauspalvelu/models/reservation_unit_access_type/model.py
+msgid "reservation unit access types"
+msgstr ""
+
+#: tilavarauspalvelu/models/reservation_unit_access_type/model.py
+msgid "Access type already exists for this reservation unit and date"
msgstr ""
#: tilavarauspalvelu/models/reservation_unit_cancellation_rule/model.py
diff --git a/backend/poetry.lock b/backend/poetry.lock
index a6e0a80976..137d324980 100644
--- a/backend/poetry.lock
+++ b/backend/poetry.lock
@@ -59,14 +59,14 @@ files = [
[[package]]
name = "attrs"
-version = "24.3.0"
+version = "25.1.0"
description = "Classes Without Boilerplate"
optional = false
python-versions = ">=3.8"
groups = ["lint"]
files = [
- {file = "attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308"},
- {file = "attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff"},
+ {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"},
+ {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"},
]
[package.extras]
@@ -79,14 +79,14 @@ tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"]
[[package]]
name = "authlib"
-version = "1.4.0"
+version = "1.4.1"
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "Authlib-1.4.0-py2.py3-none-any.whl", hash = "sha256:4bb20b978c8b636222b549317c1815e1fe62234fc1c5efe8855d84aebf3a74e3"},
- {file = "authlib-1.4.0.tar.gz", hash = "sha256:1c1e6608b5ed3624aeeee136ca7f8c120d6f51f731aa152b153d54741840e1f2"},
+ {file = "Authlib-1.4.1-py2.py3-none-any.whl", hash = "sha256:edc29c3f6a3e72cd9e9f45fff67fc663a2c364022eb0371c003f22d5405915c1"},
+ {file = "authlib-1.4.1.tar.gz", hash = "sha256:30ead9ea4993cdbab821dc6e01e818362f92da290c04c7f6a1940f86507a790d"},
]
[package.dependencies]
@@ -106,14 +106,14 @@ files = [
[[package]]
name = "cachetools"
-version = "5.5.0"
+version = "5.5.1"
description = "Extensible memoizing collections and decorators"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
- {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"},
- {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"},
+ {file = "cachetools-5.5.1-py3-none-any.whl", hash = "sha256:b76651fdc3b24ead3c648bbdeeb940c1b04d365b38b4af66788f9ec4a81d42bb"},
+ {file = "cachetools-5.5.1.tar.gz", hash = "sha256:70f238fbba50383ef62e55c6aff6d9673175fe59f7c6782c7a0b9e38f4a9df95"},
]
[[package]]
@@ -201,14 +201,14 @@ zstd = ["zstandard (==0.22.0)"]
[[package]]
name = "certifi"
-version = "2024.12.14"
+version = "2025.1.31"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
- {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"},
- {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"},
+ {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"},
+ {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"},
]
[[package]]
@@ -500,74 +500,75 @@ markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"",
[[package]]
name = "coverage"
-version = "7.6.10"
+version = "7.6.12"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
- {file = "coverage-7.6.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5c912978f7fbf47ef99cec50c4401340436d200d41d714c7a4766f377c5b7b78"},
- {file = "coverage-7.6.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a01ec4af7dfeb96ff0078ad9a48810bb0cc8abcb0115180c6013a6b26237626c"},
- {file = "coverage-7.6.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b204c11e2b2d883946fe1d97f89403aa1811df28ce0447439178cc7463448a"},
- {file = "coverage-7.6.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32ee6d8491fcfc82652a37109f69dee9a830e9379166cb73c16d8dc5c2915165"},
- {file = "coverage-7.6.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675cefc4c06e3b4c876b85bfb7c59c5e2218167bbd4da5075cbe3b5790a28988"},
- {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f4f620668dbc6f5e909a0946a877310fb3d57aea8198bde792aae369ee1c23b5"},
- {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4eea95ef275de7abaef630c9b2c002ffbc01918b726a39f5a4353916ec72d2f3"},
- {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e2f0280519e42b0a17550072861e0bc8a80a0870de260f9796157d3fca2733c5"},
- {file = "coverage-7.6.10-cp310-cp310-win32.whl", hash = "sha256:bc67deb76bc3717f22e765ab3e07ee9c7a5e26b9019ca19a3b063d9f4b874244"},
- {file = "coverage-7.6.10-cp310-cp310-win_amd64.whl", hash = "sha256:0f460286cb94036455e703c66988851d970fdfd8acc2a1122ab7f4f904e4029e"},
- {file = "coverage-7.6.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea3c8f04b3e4af80e17bab607c386a830ffc2fb88a5484e1df756478cf70d1d3"},
- {file = "coverage-7.6.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:507a20fc863cae1d5720797761b42d2d87a04b3e5aeb682ef3b7332e90598f43"},
- {file = "coverage-7.6.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d37a84878285b903c0fe21ac8794c6dab58150e9359f1aaebbeddd6412d53132"},
- {file = "coverage-7.6.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a534738b47b0de1995f85f582d983d94031dffb48ab86c95bdf88dc62212142f"},
- {file = "coverage-7.6.10-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d7a2bf79378d8fb8afaa994f91bfd8215134f8631d27eba3e0e2c13546ce994"},
- {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6713ba4b4ebc330f3def51df1d5d38fad60b66720948112f114968feb52d3f99"},
- {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab32947f481f7e8c763fa2c92fd9f44eeb143e7610c4ca9ecd6a36adab4081bd"},
- {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7bbd8c8f1b115b892e34ba66a097b915d3871db7ce0e6b9901f462ff3a975377"},
- {file = "coverage-7.6.10-cp311-cp311-win32.whl", hash = "sha256:299e91b274c5c9cdb64cbdf1b3e4a8fe538a7a86acdd08fae52301b28ba297f8"},
- {file = "coverage-7.6.10-cp311-cp311-win_amd64.whl", hash = "sha256:489a01f94aa581dbd961f306e37d75d4ba16104bbfa2b0edb21d29b73be83609"},
- {file = "coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853"},
- {file = "coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078"},
- {file = "coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0"},
- {file = "coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50"},
- {file = "coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022"},
- {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b"},
- {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0"},
- {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852"},
- {file = "coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359"},
- {file = "coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247"},
- {file = "coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9"},
- {file = "coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b"},
- {file = "coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690"},
- {file = "coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18"},
- {file = "coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c"},
- {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd"},
- {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e"},
- {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694"},
- {file = "coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6"},
- {file = "coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e"},
- {file = "coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe"},
- {file = "coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273"},
- {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8"},
- {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098"},
- {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb"},
- {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0"},
- {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf"},
- {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2"},
- {file = "coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312"},
- {file = "coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d"},
- {file = "coverage-7.6.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:656c82b8a0ead8bba147de9a89bda95064874c91a3ed43a00e687f23cc19d53a"},
- {file = "coverage-7.6.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ccc2b70a7ed475c68ceb548bf69cec1e27305c1c2606a5eb7c3afff56a1b3b27"},
- {file = "coverage-7.6.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5e37dc41d57ceba70956fa2fc5b63c26dba863c946ace9705f8eca99daecdc4"},
- {file = "coverage-7.6.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0aa9692b4fdd83a4647eeb7db46410ea1322b5ed94cd1715ef09d1d5922ba87f"},
- {file = "coverage-7.6.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa744da1820678b475e4ba3dfd994c321c5b13381d1041fe9c608620e6676e25"},
- {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c0b1818063dc9e9d838c09e3a473c1422f517889436dd980f5d721899e66f315"},
- {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:59af35558ba08b758aec4d56182b222976330ef8d2feacbb93964f576a7e7a90"},
- {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7ed2f37cfce1ce101e6dffdfd1c99e729dd2ffc291d02d3e2d0af8b53d13840d"},
- {file = "coverage-7.6.10-cp39-cp39-win32.whl", hash = "sha256:4bcc276261505d82f0ad426870c3b12cb177752834a633e737ec5ee79bbdff18"},
- {file = "coverage-7.6.10-cp39-cp39-win_amd64.whl", hash = "sha256:457574f4599d2b00f7f637a0700a6422243b3565509457b2dbd3f50703e11f59"},
- {file = "coverage-7.6.10-pp39.pp310-none-any.whl", hash = "sha256:fd34e7b3405f0cc7ab03d54a334c17a9e802897580d964bd8c2001f4b9fd488f"},
- {file = "coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23"},
+ {file = "coverage-7.6.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704c8c8c6ce6569286ae9622e534b4f5b9759b6f2cd643f1c1a61f666d534fe8"},
+ {file = "coverage-7.6.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad7525bf0241e5502168ae9c643a2f6c219fa0a283001cee4cf23a9b7da75879"},
+ {file = "coverage-7.6.12-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06097c7abfa611c91edb9e6920264e5be1d6ceb374efb4986f38b09eed4cb2fe"},
+ {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220fa6c0ad7d9caef57f2c8771918324563ef0d8272c94974717c3909664e674"},
+ {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3688b99604a24492bcfe1c106278c45586eb819bf66a654d8a9a1433022fb2eb"},
+ {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d1a987778b9c71da2fc8948e6f2656da6ef68f59298b7e9786849634c35d2c3c"},
+ {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cec6b9ce3bd2b7853d4a4563801292bfee40b030c05a3d29555fd2a8ee9bd68c"},
+ {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ace9048de91293e467b44bce0f0381345078389814ff6e18dbac8fdbf896360e"},
+ {file = "coverage-7.6.12-cp310-cp310-win32.whl", hash = "sha256:ea31689f05043d520113e0552f039603c4dd71fa4c287b64cb3606140c66f425"},
+ {file = "coverage-7.6.12-cp310-cp310-win_amd64.whl", hash = "sha256:676f92141e3c5492d2a1596d52287d0d963df21bf5e55c8b03075a60e1ddf8aa"},
+ {file = "coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015"},
+ {file = "coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45"},
+ {file = "coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702"},
+ {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0"},
+ {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f"},
+ {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f"},
+ {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d"},
+ {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba"},
+ {file = "coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f"},
+ {file = "coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558"},
+ {file = "coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad"},
+ {file = "coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3"},
+ {file = "coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574"},
+ {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985"},
+ {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750"},
+ {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea"},
+ {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3"},
+ {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a"},
+ {file = "coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95"},
+ {file = "coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288"},
+ {file = "coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1"},
+ {file = "coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd"},
+ {file = "coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9"},
+ {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e"},
+ {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4"},
+ {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6"},
+ {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3"},
+ {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc"},
+ {file = "coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3"},
+ {file = "coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef"},
+ {file = "coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e"},
+ {file = "coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703"},
+ {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0"},
+ {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924"},
+ {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b"},
+ {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d"},
+ {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827"},
+ {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9"},
+ {file = "coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3"},
+ {file = "coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f"},
+ {file = "coverage-7.6.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e7575ab65ca8399c8c4f9a7d61bbd2d204c8b8e447aab9d355682205c9dd948d"},
+ {file = "coverage-7.6.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8161d9fbc7e9fe2326de89cd0abb9f3599bccc1287db0aba285cb68d204ce929"},
+ {file = "coverage-7.6.12-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a1e465f398c713f1b212400b4e79a09829cd42aebd360362cd89c5bdc44eb87"},
+ {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25d8b92a4e31ff1bd873654ec367ae811b3a943583e05432ea29264782dc32c"},
+ {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a936309a65cc5ca80fa9f20a442ff9e2d06927ec9a4f54bcba9c14c066323f2"},
+ {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa6f302a3a0b5f240ee201297fff0bbfe2fa0d415a94aeb257d8b461032389bd"},
+ {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f973643ef532d4f9be71dd88cf7588936685fdb576d93a79fe9f65bc337d9d73"},
+ {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78f5243bb6b1060aed6213d5107744c19f9571ec76d54c99cc15938eb69e0e86"},
+ {file = "coverage-7.6.12-cp39-cp39-win32.whl", hash = "sha256:69e62c5034291c845fc4df7f8155e8544178b6c774f97a99e2734b05eb5bed31"},
+ {file = "coverage-7.6.12-cp39-cp39-win_amd64.whl", hash = "sha256:b01a840ecc25dce235ae4c1b6a0daefb2a203dba0e6e980637ee9c2f6ee0df57"},
+ {file = "coverage-7.6.12-pp39.pp310-none-any.whl", hash = "sha256:7e39e845c4d764208e7b8f6a21c541ade741e2c41afabdfa1caa28687a3c98cf"},
+ {file = "coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953"},
+ {file = "coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2"},
]
[package.extras]
@@ -590,41 +591,43 @@ dev = ["polib"]
[[package]]
name = "cryptography"
-version = "44.0.0"
+version = "44.0.1"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.7"
groups = ["main"]
files = [
- {file = "cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123"},
- {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092"},
- {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f"},
- {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb"},
- {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b"},
- {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543"},
- {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385"},
- {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e"},
- {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e"},
- {file = "cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053"},
- {file = "cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd"},
- {file = "cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591"},
- {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7"},
- {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc"},
- {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289"},
- {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7"},
- {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c"},
- {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba"},
- {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64"},
- {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285"},
- {file = "cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417"},
- {file = "cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede"},
- {file = "cryptography-44.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:37d76e6863da3774cd9db5b409a9ecfd2c71c981c38788d3fcfaf177f447b731"},
- {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f677e1268c4e23420c3acade68fac427fffcb8d19d7df95ed7ad17cdef8404f4"},
- {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f5e7cb1e5e56ca0933b4873c0220a78b773b24d40d186b6738080b73d3d0a756"},
- {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:8b3e6eae66cf54701ee7d9c83c30ac0a1e3fa17be486033000f2a73a12ab507c"},
- {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:be4ce505894d15d5c5037167ffb7f0ae90b7be6f2a98f9a5c3442395501c32fa"},
- {file = "cryptography-44.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:62901fb618f74d7d81bf408c8719e9ec14d863086efe4185afd07c352aee1d2c"},
- {file = "cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02"},
+ {file = "cryptography-44.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009"},
+ {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f"},
+ {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2"},
+ {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911"},
+ {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69"},
+ {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026"},
+ {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd"},
+ {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0"},
+ {file = "cryptography-44.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf"},
+ {file = "cryptography-44.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864"},
+ {file = "cryptography-44.0.1-cp37-abi3-win32.whl", hash = "sha256:24979e9f2040c953a94bf3c6782e67795a4c260734e5264dceea65c8f4bae64a"},
+ {file = "cryptography-44.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:fd0ee90072861e276b0ff08bd627abec29e32a53b2be44e41dbcdf87cbee2b00"},
+ {file = "cryptography-44.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a2d8a7045e1ab9b9f803f0d9531ead85f90c5f2859e653b61497228b18452008"},
+ {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862"},
+ {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3"},
+ {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7"},
+ {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a"},
+ {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c"},
+ {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62"},
+ {file = "cryptography-44.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41"},
+ {file = "cryptography-44.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b"},
+ {file = "cryptography-44.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7"},
+ {file = "cryptography-44.0.1-cp39-abi3-win32.whl", hash = "sha256:9b336599e2cb77b1008cb2ac264b290803ec5e8e89d618a5e978ff5eb6f715d9"},
+ {file = "cryptography-44.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:e403f7f766ded778ecdb790da786b418a9f2394f36e8cc8b796cc056ab05f44f"},
+ {file = "cryptography-44.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1f9a92144fa0c877117e9748c74501bea842f93d21ee00b0cf922846d9d0b183"},
+ {file = "cryptography-44.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:610a83540765a8d8ce0f351ce42e26e53e1f774a6efb71eb1b41eb01d01c3d12"},
+ {file = "cryptography-44.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5fed5cd6102bb4eb843e3315d2bf25fede494509bddadb81e03a859c1bc17b83"},
+ {file = "cryptography-44.0.1-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f4daefc971c2d1f82f03097dc6f216744a6cd2ac0f04c68fb935ea2ba2a0d420"},
+ {file = "cryptography-44.0.1-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94f99f2b943b354a5b6307d7e8d19f5c423a794462bde2bf310c770ba052b1c4"},
+ {file = "cryptography-44.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d9c5b9f698a83c8bd71e0f4d3f9f839ef244798e5ffe96febfa9714717db7af7"},
+ {file = "cryptography-44.0.1.tar.gz", hash = "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14"},
]
[package.dependencies]
@@ -637,7 +640,7 @@ nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"]
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
-test = ["certifi (>=2024)", "cryptography-vectors (==44.0.0)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
+test = ["certifi (>=2024)", "cryptography-vectors (==44.0.1)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
@@ -1063,14 +1066,14 @@ tests = ["coverage"]
[[package]]
name = "django-lookup-property"
-version = "0.1.7"
+version = "0.1.8"
description = "Django model properties that are also lookup expressions."
optional = false
python-versions = "<4,>=3.11"
groups = ["main"]
files = [
- {file = "django_lookup_property-0.1.7-py3-none-any.whl", hash = "sha256:46e8dd2c5700624168b8c7cb390f098435bac43ea89918d1a7be2d3625be11a9"},
- {file = "django_lookup_property-0.1.7.tar.gz", hash = "sha256:95d920ffc8e975f1487d74d3530c302065e9871d45e5e99e1002a835c4f58c49"},
+ {file = "django_lookup_property-0.1.8-py3-none-any.whl", hash = "sha256:f31d8ae63d8e072e9350551e43a2514130f4dc868f859e16fc3eda6c9bd23e80"},
+ {file = "django_lookup_property-0.1.8.tar.gz", hash = "sha256:692138225e4d95bcc31b4d7641757c0a368184f69a65db70e10e1591603f6f57"},
]
[package.dependencies]
@@ -1172,14 +1175,14 @@ Django = ">=4.2"
[[package]]
name = "django-timezone-field"
-version = "7.0"
+version = "7.1"
description = "A Django app providing DB, form, and REST framework fields for zoneinfo and pytz timezone objects."
optional = false
python-versions = "<4.0,>=3.8"
groups = ["celery"]
files = [
- {file = "django_timezone_field-7.0-py3-none-any.whl", hash = "sha256:3232e7ecde66ba4464abb6f9e6b8cc739b914efb9b29dc2cf2eee451f7cc2acb"},
- {file = "django_timezone_field-7.0.tar.gz", hash = "sha256:aa6f4965838484317b7f08d22c0d91a53d64e7bbbd34264468ae83d4023898a7"},
+ {file = "django_timezone_field-7.1-py3-none-any.whl", hash = "sha256:93914713ed882f5bccda080eda388f7006349f25930b6122e9b07bf8db49c4b4"},
+ {file = "django_timezone_field-7.1.tar.gz", hash = "sha256:b3ef409d88a2718b566fabe10ea996f2838bc72b22d3a2900c0aa905c761380c"},
]
[package.dependencies]
@@ -1324,19 +1327,19 @@ typing-extensions = "*"
[[package]]
name = "filelock"
-version = "3.16.1"
+version = "3.17.0"
description = "A platform independent file lock."
optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
groups = ["lint"]
files = [
- {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
- {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
+ {file = "filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338"},
+ {file = "filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e"},
]
[package.extras]
-docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
-testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"]
+docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"]
+testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"]
typing = ["typing-extensions (>=4.12.2)"]
[[package]]
@@ -1427,34 +1430,32 @@ translation = ["django-modeltranslation (>=0.18.13)"]
[[package]]
name = "graphene-django-query-optimizer"
-version = "0.10.5"
+version = "0.10.6"
description = "Automatically optimize SQL queries in Graphene-Django schemas."
optional = false
python-versions = "<4,>=3.10"
groups = ["main"]
files = [
- {file = "graphene_django_query_optimizer-0.10.5-py3-none-any.whl", hash = "sha256:3439697d4306a2461a5eadea37d7595c4bf5672cfe25838a03968a27acfc50f1"},
- {file = "graphene_django_query_optimizer-0.10.5.tar.gz", hash = "sha256:1bd40c9147c6b9be4b77762faa12a8ebaff2ed4bfad8289d3bf6e5be9ebea937"},
+ {file = "graphene_django_query_optimizer-0.10.6-py3-none-any.whl", hash = "sha256:d93aff953440a287bd2e1075afec64349d909c1ff6ad04ea808725c106703eb6"},
+ {file = "graphene_django_query_optimizer-0.10.6.tar.gz", hash = "sha256:43e6b30b7c51430ec370ebd791cee935027b6477a3df3d14a8f0b84f574049ac"},
]
[package.dependencies]
Django = ">=4.2"
+django-filter = ">=21.1"
django-settings-holder = ">=0.1.2"
graphene-django = ">=3.0.0"
-[package.extras]
-filter = ["django-filter (>=21.1)"]
-
[[package]]
name = "graphql-core"
-version = "3.2.5"
+version = "3.2.6"
description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL."
optional = false
python-versions = "<4,>=3.6"
groups = ["main", "test"]
files = [
- {file = "graphql_core-3.2.5-py3-none-any.whl", hash = "sha256:2f150d5096448aa4f8ab26268567bbfeef823769893b39c1a2e1409590939c8a"},
- {file = "graphql_core-3.2.5.tar.gz", hash = "sha256:e671b90ed653c808715645e3998b7ab67d382d55467b7e2978549111bbabf8d5"},
+ {file = "graphql_core-3.2.6-py3-none-any.whl", hash = "sha256:78b016718c161a6fb20a7d97bbf107f331cd1afe53e45566c59f776ed7f0b45f"},
+ {file = "graphql_core-3.2.6.tar.gz", hash = "sha256:c08eec22f9e40f0bd61d805907e3b3b1b9a320bc606e23dc145eebca07c8fbab"},
]
[[package]]
@@ -1663,14 +1664,14 @@ test = ["coverage", "hypothesis", "pytest", "pytz"]
[[package]]
name = "identify"
-version = "2.6.5"
+version = "2.6.7"
description = "File identification library for Python"
optional = false
python-versions = ">=3.9"
groups = ["lint"]
files = [
- {file = "identify-2.6.5-py2.py3-none-any.whl", hash = "sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566"},
- {file = "identify-2.6.5.tar.gz", hash = "sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc"},
+ {file = "identify-2.6.7-py2.py3-none-any.whl", hash = "sha256:155931cb617a401807b09ecec6635d6c692d180090a1cedca8ef7d58ba5b6aa0"},
+ {file = "identify-2.6.7.tar.gz", hash = "sha256:3fa266b42eba321ee0b2bb0936a6a6b9e36a1351cbb69055b3082f4193035684"},
]
[package.extras]
@@ -1773,158 +1774,158 @@ cattrs = "!=23.2.1"
[[package]]
name = "lxml"
-version = "5.3.0"
+version = "5.3.1"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
- {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656"},
- {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d"},
- {file = "lxml-5.3.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a"},
- {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8"},
- {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330"},
- {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965"},
- {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22"},
- {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b"},
- {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7"},
- {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8"},
- {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32"},
- {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86"},
- {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5"},
- {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03"},
- {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7"},
- {file = "lxml-5.3.0-cp310-cp310-win32.whl", hash = "sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80"},
- {file = "lxml-5.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3"},
- {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b"},
- {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18"},
- {file = "lxml-5.3.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442"},
- {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4"},
- {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f"},
- {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e"},
- {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c"},
- {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16"},
- {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79"},
- {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080"},
- {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654"},
- {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d"},
- {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763"},
- {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec"},
- {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be"},
- {file = "lxml-5.3.0-cp311-cp311-win32.whl", hash = "sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9"},
- {file = "lxml-5.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1"},
- {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859"},
- {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e"},
- {file = "lxml-5.3.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f"},
- {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e"},
- {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179"},
- {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a"},
- {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3"},
- {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1"},
- {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d"},
- {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c"},
- {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99"},
- {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff"},
- {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a"},
- {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8"},
- {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d"},
- {file = "lxml-5.3.0-cp312-cp312-win32.whl", hash = "sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30"},
- {file = "lxml-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f"},
- {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a"},
- {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd"},
- {file = "lxml-5.3.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51"},
- {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b"},
- {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002"},
- {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4"},
- {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492"},
- {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3"},
- {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4"},
- {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367"},
- {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832"},
- {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff"},
- {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd"},
- {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb"},
- {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b"},
- {file = "lxml-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957"},
- {file = "lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d"},
- {file = "lxml-5.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e"},
- {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f"},
- {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94"},
- {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99"},
- {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237"},
- {file = "lxml-5.3.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577"},
- {file = "lxml-5.3.0-cp36-cp36m-win32.whl", hash = "sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70"},
- {file = "lxml-5.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c"},
- {file = "lxml-5.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033"},
- {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391"},
- {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6"},
- {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d"},
- {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512"},
- {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b"},
- {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5"},
- {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11"},
- {file = "lxml-5.3.0-cp37-cp37m-win32.whl", hash = "sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84"},
- {file = "lxml-5.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e"},
- {file = "lxml-5.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753"},
- {file = "lxml-5.3.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040"},
- {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22"},
- {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22"},
- {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15"},
- {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920"},
- {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945"},
- {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42"},
- {file = "lxml-5.3.0-cp38-cp38-win32.whl", hash = "sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e"},
- {file = "lxml-5.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903"},
- {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de"},
- {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc"},
- {file = "lxml-5.3.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be"},
- {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a"},
- {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540"},
- {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70"},
- {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa"},
- {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf"},
- {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229"},
- {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe"},
- {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2"},
- {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71"},
- {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3"},
- {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727"},
- {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a"},
- {file = "lxml-5.3.0-cp39-cp39-win32.whl", hash = "sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff"},
- {file = "lxml-5.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2"},
- {file = "lxml-5.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c"},
- {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a"},
- {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005"},
- {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce"},
- {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83"},
- {file = "lxml-5.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba"},
- {file = "lxml-5.3.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27"},
- {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b"},
- {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce"},
- {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e"},
- {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f"},
- {file = "lxml-5.3.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875"},
- {file = "lxml-5.3.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19"},
- {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2"},
- {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab"},
- {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469"},
- {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8"},
- {file = "lxml-5.3.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1"},
- {file = "lxml-5.3.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21"},
- {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2"},
- {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f"},
- {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab"},
- {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9"},
- {file = "lxml-5.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c"},
- {file = "lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f"},
+ {file = "lxml-5.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a4058f16cee694577f7e4dd410263cd0ef75644b43802a689c2b3c2a7e69453b"},
+ {file = "lxml-5.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:364de8f57d6eda0c16dcfb999af902da31396949efa0e583e12675d09709881b"},
+ {file = "lxml-5.3.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:528f3a0498a8edc69af0559bdcf8a9f5a8bf7c00051a6ef3141fdcf27017bbf5"},
+ {file = "lxml-5.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db4743e30d6f5f92b6d2b7c86b3ad250e0bad8dee4b7ad8a0c44bfb276af89a3"},
+ {file = "lxml-5.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b5d7f8acf809465086d498d62a981fa6a56d2718135bb0e4aa48c502055f5c"},
+ {file = "lxml-5.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:928e75a7200a4c09e6efc7482a1337919cc61fe1ba289f297827a5b76d8969c2"},
+ {file = "lxml-5.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a997b784a639e05b9d4053ef3b20c7e447ea80814a762f25b8ed5a89d261eac"},
+ {file = "lxml-5.3.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:7b82e67c5feb682dbb559c3e6b78355f234943053af61606af126df2183b9ef9"},
+ {file = "lxml-5.3.1-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:f1de541a9893cf8a1b1db9bf0bf670a2decab42e3e82233d36a74eda7822b4c9"},
+ {file = "lxml-5.3.1-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:de1fc314c3ad6bc2f6bd5b5a5b9357b8c6896333d27fdbb7049aea8bd5af2d79"},
+ {file = "lxml-5.3.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:7c0536bd9178f754b277a3e53f90f9c9454a3bd108b1531ffff720e082d824f2"},
+ {file = "lxml-5.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:68018c4c67d7e89951a91fbd371e2e34cd8cfc71f0bb43b5332db38497025d51"},
+ {file = "lxml-5.3.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aa826340a609d0c954ba52fd831f0fba2a4165659ab0ee1a15e4aac21f302406"},
+ {file = "lxml-5.3.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:796520afa499732191e39fc95b56a3b07f95256f2d22b1c26e217fb69a9db5b5"},
+ {file = "lxml-5.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3effe081b3135237da6e4c4530ff2a868d3f80be0bda027e118a5971285d42d0"},
+ {file = "lxml-5.3.1-cp310-cp310-win32.whl", hash = "sha256:a22f66270bd6d0804b02cd49dae2b33d4341015545d17f8426f2c4e22f557a23"},
+ {file = "lxml-5.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:0bcfadea3cdc68e678d2b20cb16a16716887dd00a881e16f7d806c2138b8ff0c"},
+ {file = "lxml-5.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e220f7b3e8656ab063d2eb0cd536fafef396829cafe04cb314e734f87649058f"},
+ {file = "lxml-5.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f2cfae0688fd01f7056a17367e3b84f37c545fb447d7282cf2c242b16262607"},
+ {file = "lxml-5.3.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:67d2f8ad9dcc3a9e826bdc7802ed541a44e124c29b7d95a679eeb58c1c14ade8"},
+ {file = "lxml-5.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db0c742aad702fd5d0c6611a73f9602f20aec2007c102630c06d7633d9c8f09a"},
+ {file = "lxml-5.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:198bb4b4dd888e8390afa4f170d4fa28467a7eaf857f1952589f16cfbb67af27"},
+ {file = "lxml-5.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2a3e412ce1849be34b45922bfef03df32d1410a06d1cdeb793a343c2f1fd666"},
+ {file = "lxml-5.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b8969dbc8d09d9cd2ae06362c3bad27d03f433252601ef658a49bd9f2b22d79"},
+ {file = "lxml-5.3.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5be8f5e4044146a69c96077c7e08f0709c13a314aa5315981185c1f00235fe65"},
+ {file = "lxml-5.3.1-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:133f3493253a00db2c870d3740bc458ebb7d937bd0a6a4f9328373e0db305709"},
+ {file = "lxml-5.3.1-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:52d82b0d436edd6a1d22d94a344b9a58abd6c68c357ed44f22d4ba8179b37629"},
+ {file = "lxml-5.3.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1b6f92e35e2658a5ed51c6634ceb5ddae32053182851d8cad2a5bc102a359b33"},
+ {file = "lxml-5.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:203b1d3eaebd34277be06a3eb880050f18a4e4d60861efba4fb946e31071a295"},
+ {file = "lxml-5.3.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:155e1a5693cf4b55af652f5c0f78ef36596c7f680ff3ec6eb4d7d85367259b2c"},
+ {file = "lxml-5.3.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22ec2b3c191f43ed21f9545e9df94c37c6b49a5af0a874008ddc9132d49a2d9c"},
+ {file = "lxml-5.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7eda194dd46e40ec745bf76795a7cccb02a6a41f445ad49d3cf66518b0bd9cff"},
+ {file = "lxml-5.3.1-cp311-cp311-win32.whl", hash = "sha256:fb7c61d4be18e930f75948705e9718618862e6fc2ed0d7159b2262be73f167a2"},
+ {file = "lxml-5.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:c809eef167bf4a57af4b03007004896f5c60bd38dc3852fcd97a26eae3d4c9e6"},
+ {file = "lxml-5.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e69add9b6b7b08c60d7ff0152c7c9a6c45b4a71a919be5abde6f98f1ea16421c"},
+ {file = "lxml-5.3.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4e52e1b148867b01c05e21837586ee307a01e793b94072d7c7b91d2c2da02ffe"},
+ {file = "lxml-5.3.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4b382e0e636ed54cd278791d93fe2c4f370772743f02bcbe431a160089025c9"},
+ {file = "lxml-5.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e49dc23a10a1296b04ca9db200c44d3eb32c8d8ec532e8c1fd24792276522a"},
+ {file = "lxml-5.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4399b4226c4785575fb20998dc571bc48125dc92c367ce2602d0d70e0c455eb0"},
+ {file = "lxml-5.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5412500e0dc5481b1ee9cf6b38bb3b473f6e411eb62b83dc9b62699c3b7b79f7"},
+ {file = "lxml-5.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c93ed3c998ea8472be98fb55aed65b5198740bfceaec07b2eba551e55b7b9ae"},
+ {file = "lxml-5.3.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:63d57fc94eb0bbb4735e45517afc21ef262991d8758a8f2f05dd6e4174944519"},
+ {file = "lxml-5.3.1-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:b450d7cabcd49aa7ab46a3c6aa3ac7e1593600a1a0605ba536ec0f1b99a04322"},
+ {file = "lxml-5.3.1-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:4df0ec814b50275ad6a99bc82a38b59f90e10e47714ac9871e1b223895825468"},
+ {file = "lxml-5.3.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d184f85ad2bb1f261eac55cddfcf62a70dee89982c978e92b9a74a1bfef2e367"},
+ {file = "lxml-5.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b725e70d15906d24615201e650d5b0388b08a5187a55f119f25874d0103f90dd"},
+ {file = "lxml-5.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a31fa7536ec1fb7155a0cd3a4e3d956c835ad0a43e3610ca32384d01f079ea1c"},
+ {file = "lxml-5.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c3c8b55c7fc7b7e8877b9366568cc73d68b82da7fe33d8b98527b73857a225f"},
+ {file = "lxml-5.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d61ec60945d694df806a9aec88e8f29a27293c6e424f8ff91c80416e3c617645"},
+ {file = "lxml-5.3.1-cp312-cp312-win32.whl", hash = "sha256:f4eac0584cdc3285ef2e74eee1513a6001681fd9753b259e8159421ed28a72e5"},
+ {file = "lxml-5.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:29bfc8d3d88e56ea0a27e7c4897b642706840247f59f4377d81be8f32aa0cfbf"},
+ {file = "lxml-5.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c093c7088b40d8266f57ed71d93112bd64c6724d31f0794c1e52cc4857c28e0e"},
+ {file = "lxml-5.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0884e3f22d87c30694e625b1e62e6f30d39782c806287450d9dc2fdf07692fd"},
+ {file = "lxml-5.3.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1637fa31ec682cd5760092adfabe86d9b718a75d43e65e211d5931809bc111e7"},
+ {file = "lxml-5.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a364e8e944d92dcbf33b6b494d4e0fb3499dcc3bd9485beb701aa4b4201fa414"},
+ {file = "lxml-5.3.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:779e851fd0e19795ccc8a9bb4d705d6baa0ef475329fe44a13cf1e962f18ff1e"},
+ {file = "lxml-5.3.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c4393600915c308e546dc7003d74371744234e8444a28622d76fe19b98fa59d1"},
+ {file = "lxml-5.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:673b9d8e780f455091200bba8534d5f4f465944cbdd61f31dc832d70e29064a5"},
+ {file = "lxml-5.3.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2e4a570f6a99e96c457f7bec5ad459c9c420ee80b99eb04cbfcfe3fc18ec6423"},
+ {file = "lxml-5.3.1-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:71f31eda4e370f46af42fc9f264fafa1b09f46ba07bdbee98f25689a04b81c20"},
+ {file = "lxml-5.3.1-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:42978a68d3825eaac55399eb37a4d52012a205c0c6262199b8b44fcc6fd686e8"},
+ {file = "lxml-5.3.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8b1942b3e4ed9ed551ed3083a2e6e0772de1e5e3aca872d955e2e86385fb7ff9"},
+ {file = "lxml-5.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85c4f11be9cf08917ac2a5a8b6e1ef63b2f8e3799cec194417e76826e5f1de9c"},
+ {file = "lxml-5.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:231cf4d140b22a923b1d0a0a4e0b4f972e5893efcdec188934cc65888fd0227b"},
+ {file = "lxml-5.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5865b270b420eda7b68928d70bb517ccbe045e53b1a428129bb44372bf3d7dd5"},
+ {file = "lxml-5.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7bebc2275016cddf3c997bf8a0f7044160714c64a9b83975670a04e6d2252"},
+ {file = "lxml-5.3.1-cp313-cp313-win32.whl", hash = "sha256:d0751528b97d2b19a388b302be2a0ee05817097bab46ff0ed76feeec24951f78"},
+ {file = "lxml-5.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:91fb6a43d72b4f8863d21f347a9163eecbf36e76e2f51068d59cd004c506f332"},
+ {file = "lxml-5.3.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:016b96c58e9a4528219bb563acf1aaaa8bc5452e7651004894a973f03b84ba81"},
+ {file = "lxml-5.3.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82a4bb10b0beef1434fb23a09f001ab5ca87895596b4581fd53f1e5145a8934a"},
+ {file = "lxml-5.3.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d68eeef7b4d08a25e51897dac29bcb62aba830e9ac6c4e3297ee7c6a0cf6439"},
+ {file = "lxml-5.3.1-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:f12582b8d3b4c6be1d298c49cb7ae64a3a73efaf4c2ab4e37db182e3545815ac"},
+ {file = "lxml-5.3.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2df7ed5edeb6bd5590914cd61df76eb6cce9d590ed04ec7c183cf5509f73530d"},
+ {file = "lxml-5.3.1-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:585c4dc429deebc4307187d2b71ebe914843185ae16a4d582ee030e6cfbb4d8a"},
+ {file = "lxml-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:06a20d607a86fccab2fc15a77aa445f2bdef7b49ec0520a842c5c5afd8381576"},
+ {file = "lxml-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:057e30d0012439bc54ca427a83d458752ccda725c1c161cc283db07bcad43cf9"},
+ {file = "lxml-5.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4867361c049761a56bd21de507cab2c2a608c55102311d142ade7dab67b34f32"},
+ {file = "lxml-5.3.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3dddf0fb832486cc1ea71d189cb92eb887826e8deebe128884e15020bb6e3f61"},
+ {file = "lxml-5.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bcc211542f7af6f2dfb705f5f8b74e865592778e6cafdfd19c792c244ccce19"},
+ {file = "lxml-5.3.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaca5a812f050ab55426c32177091130b1e49329b3f002a32934cd0245571307"},
+ {file = "lxml-5.3.1-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:236610b77589faf462337b3305a1be91756c8abc5a45ff7ca8f245a71c5dab70"},
+ {file = "lxml-5.3.1-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:aed57b541b589fa05ac248f4cb1c46cbb432ab82cbd467d1c4f6a2bdc18aecf9"},
+ {file = "lxml-5.3.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:75fa3d6946d317ffc7016a6fcc44f42db6d514b7fdb8b4b28cbe058303cb6e53"},
+ {file = "lxml-5.3.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:96eef5b9f336f623ffc555ab47a775495e7e8846dde88de5f941e2906453a1ce"},
+ {file = "lxml-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:ef45f31aec9be01379fc6c10f1d9c677f032f2bac9383c827d44f620e8a88407"},
+ {file = "lxml-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0611da6b07dd3720f492db1b463a4d1175b096b49438761cc9f35f0d9eaaef5"},
+ {file = "lxml-5.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b2aca14c235c7a08558fe0a4786a1a05873a01e86b474dfa8f6df49101853a4e"},
+ {file = "lxml-5.3.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae82fce1d964f065c32c9517309f0c7be588772352d2f40b1574a214bd6e6098"},
+ {file = "lxml-5.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7aae7a3d63b935babfdc6864b31196afd5145878ddd22f5200729006366bc4d5"},
+ {file = "lxml-5.3.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8e0d177b1fe251c3b1b914ab64135475c5273c8cfd2857964b2e3bb0fe196a7"},
+ {file = "lxml-5.3.1-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:6c4dd3bfd0c82400060896717dd261137398edb7e524527438c54a8c34f736bf"},
+ {file = "lxml-5.3.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:f1208c1c67ec9e151d78aa3435aa9b08a488b53d9cfac9b699f15255a3461ef2"},
+ {file = "lxml-5.3.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:c6aacf00d05b38a5069826e50ae72751cb5bc27bdc4d5746203988e429b385bb"},
+ {file = "lxml-5.3.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5881aaa4bf3a2d086c5f20371d3a5856199a0d8ac72dd8d0dbd7a2ecfc26ab73"},
+ {file = "lxml-5.3.1-cp38-cp38-win32.whl", hash = "sha256:45fbb70ccbc8683f2fb58bea89498a7274af1d9ec7995e9f4af5604e028233fc"},
+ {file = "lxml-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:7512b4d0fc5339d5abbb14d1843f70499cab90d0b864f790e73f780f041615d7"},
+ {file = "lxml-5.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5885bc586f1edb48e5d68e7a4b4757b5feb2a496b64f462b4d65950f5af3364f"},
+ {file = "lxml-5.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1b92fe86e04f680b848fff594a908edfa72b31bfc3499ef7433790c11d4c8cd8"},
+ {file = "lxml-5.3.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a091026c3bf7519ab1e64655a3f52a59ad4a4e019a6f830c24d6430695b1cf6a"},
+ {file = "lxml-5.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ffb141361108e864ab5f1813f66e4e1164181227f9b1f105b042729b6c15125"},
+ {file = "lxml-5.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3715cdf0dd31b836433af9ee9197af10e3df41d273c19bb249230043667a5dfd"},
+ {file = "lxml-5.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88b72eb7222d918c967202024812c2bfb4048deeb69ca328363fb8e15254c549"},
+ {file = "lxml-5.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa59974880ab5ad8ef3afaa26f9bda148c5f39e06b11a8ada4660ecc9fb2feb3"},
+ {file = "lxml-5.3.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:3bb8149840daf2c3f97cebf00e4ed4a65a0baff888bf2605a8d0135ff5cf764e"},
+ {file = "lxml-5.3.1-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:0d6b2fa86becfa81f0a0271ccb9eb127ad45fb597733a77b92e8a35e53414914"},
+ {file = "lxml-5.3.1-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:136bf638d92848a939fd8f0e06fcf92d9f2e4b57969d94faae27c55f3d85c05b"},
+ {file = "lxml-5.3.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:89934f9f791566e54c1d92cdc8f8fd0009447a5ecdb1ec6b810d5f8c4955f6be"},
+ {file = "lxml-5.3.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a8ade0363f776f87f982572c2860cc43c65ace208db49c76df0a21dde4ddd16e"},
+ {file = "lxml-5.3.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:bfbbab9316330cf81656fed435311386610f78b6c93cc5db4bebbce8dd146675"},
+ {file = "lxml-5.3.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:172d65f7c72a35a6879217bcdb4bb11bc88d55fb4879e7569f55616062d387c2"},
+ {file = "lxml-5.3.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e3c623923967f3e5961d272718655946e5322b8d058e094764180cdee7bab1af"},
+ {file = "lxml-5.3.1-cp39-cp39-win32.whl", hash = "sha256:ce0930a963ff593e8bb6fda49a503911accc67dee7e5445eec972668e672a0f0"},
+ {file = "lxml-5.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:f7b64fcd670bca8800bc10ced36620c6bbb321e7bc1214b9c0c0df269c1dddc2"},
+ {file = "lxml-5.3.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:afa578b6524ff85fb365f454cf61683771d0170470c48ad9d170c48075f86725"},
+ {file = "lxml-5.3.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f5e80adf0aafc7b5454f2c1cb0cde920c9b1f2cbd0485f07cc1d0497c35c5d"},
+ {file = "lxml-5.3.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dd0b80ac2d8f13ffc906123a6f20b459cb50a99222d0da492360512f3e50f84"},
+ {file = "lxml-5.3.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:422c179022ecdedbe58b0e242607198580804253da220e9454ffe848daa1cfd2"},
+ {file = "lxml-5.3.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:524ccfded8989a6595dbdda80d779fb977dbc9a7bc458864fc9a0c2fc15dc877"},
+ {file = "lxml-5.3.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:48fd46bf7155def2e15287c6f2b133a2f78e2d22cdf55647269977b873c65499"},
+ {file = "lxml-5.3.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:05123fad495a429f123307ac6d8fd6f977b71e9a0b6d9aeeb8f80c017cb17131"},
+ {file = "lxml-5.3.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a243132767150a44e6a93cd1dde41010036e1cbc63cc3e9fe1712b277d926ce3"},
+ {file = "lxml-5.3.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c92ea6d9dd84a750b2bae72ff5e8cf5fdd13e58dda79c33e057862c29a8d5b50"},
+ {file = "lxml-5.3.1-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2f1be45d4c15f237209bbf123a0e05b5d630c8717c42f59f31ea9eae2ad89394"},
+ {file = "lxml-5.3.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:a83d3adea1e0ee36dac34627f78ddd7f093bb9cfc0a8e97f1572a949b695cb98"},
+ {file = "lxml-5.3.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:3edbb9c9130bac05d8c3fe150c51c337a471cc7fdb6d2a0a7d3a88e88a829314"},
+ {file = "lxml-5.3.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2f23cf50eccb3255b6e913188291af0150d89dab44137a69e14e4dcb7be981f1"},
+ {file = "lxml-5.3.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df7e5edac4778127f2bf452e0721a58a1cfa4d1d9eac63bdd650535eb8543615"},
+ {file = "lxml-5.3.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:094b28ed8a8a072b9e9e2113a81fda668d2053f2ca9f2d202c2c8c7c2d6516b1"},
+ {file = "lxml-5.3.1-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:514fe78fc4b87e7a7601c92492210b20a1b0c6ab20e71e81307d9c2e377c64de"},
+ {file = "lxml-5.3.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8fffc08de02071c37865a155e5ea5fce0282e1546fd5bde7f6149fcaa32558ac"},
+ {file = "lxml-5.3.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4b0d5cdba1b655d5b18042ac9c9ff50bda33568eb80feaaca4fc237b9c4fbfde"},
+ {file = "lxml-5.3.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3031e4c16b59424e8d78522c69b062d301d951dc55ad8685736c3335a97fc270"},
+ {file = "lxml-5.3.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb659702a45136c743bc130760c6f137870d4df3a9e14386478b8a0511abcfca"},
+ {file = "lxml-5.3.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a11b16a33656ffc43c92a5343a28dc71eefe460bcc2a4923a96f292692709f6"},
+ {file = "lxml-5.3.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c5ae125276f254b01daa73e2c103363d3e99e3e10505686ac7d9d2442dd4627a"},
+ {file = "lxml-5.3.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c76722b5ed4a31ba103e0dc77ab869222ec36efe1a614e42e9bcea88a36186fe"},
+ {file = "lxml-5.3.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:33e06717c00c788ab4e79bc4726ecc50c54b9bfb55355eae21473c145d83c2d2"},
+ {file = "lxml-5.3.1.tar.gz", hash = "sha256:106b7b5d2977b339f1e97efe2778e2ab20e99994cbb0ec5e55771ed0795920c8"},
]
[package.extras]
cssselect = ["cssselect (>=0.7)"]
-html-clean = ["lxml-html-clean"]
+html-clean = ["lxml_html_clean"]
html5 = ["html5lib"]
htmlsoup = ["BeautifulSoup4"]
-source = ["Cython (>=3.0.11)"]
+source = ["Cython (>=3.0.11,<3.1.0)"]
[[package]]
name = "markupsafe"
@@ -2241,14 +2242,14 @@ test = ["coveralls", "futures", "mock", "pytest (>=2.7.3)", "pytest-benchmark",
[[package]]
name = "prompt-toolkit"
-version = "3.0.48"
+version = "3.0.50"
description = "Library for building powerful interactive command lines in Python"
optional = false
-python-versions = ">=3.7.0"
+python-versions = ">=3.8.0"
groups = ["celery"]
files = [
- {file = "prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"},
- {file = "prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90"},
+ {file = "prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198"},
+ {file = "prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab"},
]
[package.dependencies]
@@ -2475,14 +2476,14 @@ tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
[[package]]
name = "pypdf"
-version = "5.1.0"
+version = "5.3.0"
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
- {file = "pypdf-5.1.0-py3-none-any.whl", hash = "sha256:3bd4f503f4ebc58bae40d81e81a9176c400cbbac2ba2d877367595fb524dfdfc"},
- {file = "pypdf-5.1.0.tar.gz", hash = "sha256:425a129abb1614183fd1aca6982f650b47f8026867c0ce7c4b9f281c443d2740"},
+ {file = "pypdf-5.3.0-py3-none-any.whl", hash = "sha256:d7b6db242f5f8fdb4990ae11815c394b8e1b955feda0befcce862efd8559c181"},
+ {file = "pypdf-5.3.0.tar.gz", hash = "sha256:08393660dfea25b27ec6fe863fb2f2248e6270da5103fae49e9dea8178741951"},
]
[package.extras]
@@ -2935,14 +2936,14 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"
[[package]]
name = "reportlab"
-version = "4.2.5"
+version = "4.3.0"
description = "The Reportlab Toolkit"
optional = false
python-versions = "<4,>=3.7"
groups = ["main"]
files = [
- {file = "reportlab-4.2.5-py3-none-any.whl", hash = "sha256:eb2745525a982d9880babb991619e97ac3f661fae30571b7d50387026ca765ee"},
- {file = "reportlab-4.2.5.tar.gz", hash = "sha256:5cf35b8fd609b68080ac7bbb0ae1e376104f7d5f7b2d3914c7adc63f2593941f"},
+ {file = "reportlab-4.3.0-py3-none-any.whl", hash = "sha256:81e7bb207132c430cdb9d9f41cfdd1e0fbd1b0eb26a0f7def55d39c1680ad345"},
+ {file = "reportlab-4.3.0.tar.gz", hash = "sha256:a90754589bea1c921a745aa981677d2d144f50c690800cda29aafae67c1a8d93"},
]
[package.dependencies]
@@ -3226,14 +3227,14 @@ tinycss2 = ">=0.6.0"
[[package]]
name = "tablib"
-version = "3.7.0"
+version = "3.8.0"
description = "Format agnostic tabular data library (XLS, JSON, YAML, CSV, etc.)"
optional = false
python-versions = ">=3.9"
groups = ["admin"]
files = [
- {file = "tablib-3.7.0-py3-none-any.whl", hash = "sha256:9a6930037cfe0f782377963ca3f2b1dae3fd4cdbf0883848f22f1447e7bb718b"},
- {file = "tablib-3.7.0.tar.gz", hash = "sha256:f9db84ed398df5109bd69c11d46613d16cc572fb9ad3213f10d95e2b5f12c18e"},
+ {file = "tablib-3.8.0-py3-none-any.whl", hash = "sha256:35bdb9d4ec7052232f8803908f9c7a9c3c65807188b70618fa7a7d8ccd560b4d"},
+ {file = "tablib-3.8.0.tar.gz", hash = "sha256:94d8bcdc65a715a0024a6d5b701a5f31e45bd159269e62c73731de79f048db2b"},
]
[package.extras]
@@ -3306,14 +3307,14 @@ files = [
[[package]]
name = "tzdata"
-version = "2024.2"
+version = "2025.1"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
groups = ["main", "admin", "celery", "test"]
files = [
- {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"},
- {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"},
+ {file = "tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639"},
+ {file = "tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694"},
]
markers = {admin = "sys_platform == \"win32\"", test = "sys_platform == \"win32\""}
@@ -3391,14 +3392,14 @@ files = [
[[package]]
name = "virtualenv"
-version = "20.28.1"
+version = "20.29.2"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.8"
groups = ["lint"]
files = [
- {file = "virtualenv-20.28.1-py3-none-any.whl", hash = "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb"},
- {file = "virtualenv-20.28.1.tar.gz", hash = "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329"},
+ {file = "virtualenv-20.29.2-py3-none-any.whl", hash = "sha256:febddfc3d1ea571bdb1dc0f98d7b45d24def7428214d4fb73cc486c9568cce6a"},
+ {file = "virtualenv-20.29.2.tar.gz", hash = "sha256:fdaabebf6d03b5ba83ae0a02cfe96f48a716f4fae556461d180825866f75b728"},
]
[package.dependencies]
@@ -3482,4 +3483,4 @@ test = ["coverage (>=5.3)", "tomli (>=2.0.1)", "tox"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.13,<3.14"
-content-hash = "3a069cad2aa2c82375cdad44c3f051d63a164585285a831647cda9ee314a6cd4"
+content-hash = "51ccf9a6a840c513f202eefcdc85f0b410fbd998e4f69658fbb50d2efec56ab3"
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 8b14a2c44f..b477bd7052 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -22,7 +22,7 @@ django-filter = "24.3"
django-health-check = "3.18.3"
django-helusers = "0.13.0"
django-jinja = "2.11.0"
-django-lookup-property = "0.1.7"
+django-lookup-property = "0.1.8"
django-modeltranslation = "0.19.12"
django-mptt = "0.16.0"
django-redis = "5.4.0"
diff --git a/backend/tests/factories/__init__.py b/backend/tests/factories/__init__.py
index a11a2dd3f7..9beaf6d780 100644
--- a/backend/tests/factories/__init__.py
+++ b/backend/tests/factories/__init__.py
@@ -33,6 +33,7 @@
from .reservation_metadata_set import ReservationMetadataSetFactory
from .reservation_purpose import ReservationPurposeFactory
from .reservation_unit import ReservationUnitFactory
+from .reservation_unit_access_type import ReservationUnitAccessTypeFactory
from .reservation_unit_cancellation_rule import ReservationUnitCancellationRuleFactory
from .reservation_unit_image import ReservationUnitImageFactory
from .reservation_unit_option import ReservationUnitOptionFactory
@@ -87,6 +88,7 @@
"ReservationMetadataFieldFactory",
"ReservationMetadataSetFactory",
"ReservationPurposeFactory",
+ "ReservationUnitAccessTypeFactory",
"ReservationUnitCancellationRuleFactory",
"ReservationUnitFactory",
"ReservationUnitImageFactory",
diff --git a/backend/tests/factories/reservation_unit.py b/backend/tests/factories/reservation_unit.py
index 35b37b841f..05d200b089 100644
--- a/backend/tests/factories/reservation_unit.py
+++ b/backend/tests/factories/reservation_unit.py
@@ -9,7 +9,7 @@
from factory import LazyAttribute
from factory.fuzzy import FuzzyInteger
-from tilavarauspalvelu.enums import AccessType, AuthenticationType, ReservationKind, ReservationStartInterval
+from tilavarauspalvelu.enums import AuthenticationType, ReservationKind, ReservationStartInterval
from tilavarauspalvelu.models import ReservationUnit
from utils.date_utils import local_start_of_day
from utils.utils import as_p_tags
@@ -93,8 +93,6 @@ class Meta:
min_reservation_duration = None
buffer_time_before = factory.LazyFunction(datetime.timedelta)
buffer_time_after = factory.LazyFunction(datetime.timedelta)
- access_type_start_date = None
- access_type_end_date = None
# Booleans
is_draft = False
@@ -109,7 +107,6 @@ class Meta:
authentication = AuthenticationType.WEAK.value
reservation_start_interval = ReservationStartInterval.INTERVAL_15_MINUTES.value
reservation_kind = ReservationKind.DIRECT_AND_SEASON.value
- access_type = AccessType.UNRESTRICTED.value
# Lists
search_terms = LazyAttribute(lambda i: [])
@@ -146,6 +143,7 @@ class Meta:
recurring_reservations = ReverseForeignKeyFactory("tests.factories.RecurringReservationFactory")
application_round_time_slots = ReverseForeignKeyFactory("tests.factories.ApplicationRoundTimeSlotFactory")
reservation_unit_options = ReverseForeignKeyFactory("tests.factories.ReservationUnitOptionFactory")
+ access_types = ReverseForeignKeyFactory("tests.factories.ReservationUnitAccessTypeFactory")
@classmethod
def create_reservable_now(cls, **kwargs: Any) -> ReservationUnit:
diff --git a/backend/tests/factories/reservation_unit_access_type.py b/backend/tests/factories/reservation_unit_access_type.py
new file mode 100644
index 0000000000..5337dba31f
--- /dev/null
+++ b/backend/tests/factories/reservation_unit_access_type.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+import datetime
+
+from tilavarauspalvelu.enums import AccessType
+from tilavarauspalvelu.models import ReservationUnitAccessType, ReservationUnitPricing
+
+from ._base import ForeignKeyFactory, GenericDjangoModelFactory, ModelFactoryBuilder
+
+__all__ = [
+ "ReservationUnitAccessTypeBuilder",
+ "ReservationUnitAccessTypeFactory",
+]
+
+
+class ReservationUnitAccessTypeFactory(GenericDjangoModelFactory[ReservationUnitAccessType]):
+ class Meta:
+ model = ReservationUnitAccessType
+
+ begin_date = datetime.date(2021, 1, 1)
+ access_type = AccessType.UNRESTRICTED
+
+ reservation_unit = ForeignKeyFactory("tests.factories.ReservationUnitFactory")
+
+
+class ReservationUnitAccessTypeBuilder(ModelFactoryBuilder[ReservationUnitPricing]):
+ factory = ReservationUnitAccessTypeFactory
diff --git a/backend/tests/test_actions/test_reservation_unit_actions/test_access_types.py b/backend/tests/test_actions/test_reservation_unit_actions/test_access_types.py
new file mode 100644
index 0000000000..746ac4925a
--- /dev/null
+++ b/backend/tests/test_actions/test_reservation_unit_actions/test_access_types.py
@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+import datetime
+
+import freezegun
+import pytest
+from lookup_property import L
+
+from tilavarauspalvelu.enums import AccessType
+from utils.date_utils import local_datetime
+
+from tests.factories import ReservationUnitAccessTypeFactory, ReservationUnitFactory
+
+pytestmark = [
+ pytest.mark.django_db,
+]
+
+
+@freezegun.freeze_time("2025-01-01")
+def test_reservation_unit__access_types__end_date():
+ now = local_datetime()
+
+ reservation_unit = ReservationUnitFactory.create()
+ ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.UNRESTRICTED,
+ begin_date=now.date(),
+ )
+ ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.ACCESS_CODE,
+ begin_date=now.date() + datetime.timedelta(days=7),
+ )
+
+ qs = reservation_unit.access_types.annotate(end_date=L("end_date")).order_by("begin_date").values("end_date")
+ assert list(qs) == [
+ {"end_date": datetime.date(2025, 1, 8)},
+ {"end_date": datetime.date.max},
+ ]
+
+
+@freezegun.freeze_time("2025-01-01")
+def test_reservation_unit__access_types__active():
+ now = local_datetime()
+
+ reservation_unit = ReservationUnitFactory.create()
+ ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.UNRESTRICTED,
+ begin_date=now.date(),
+ )
+ ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.ACCESS_CODE,
+ begin_date=now.date() + datetime.timedelta(days=7),
+ )
+
+ qs = (
+ reservation_unit.access_types.active()
+ .annotate(end_date=L("end_date"))
+ .order_by("begin_date")
+ .values("access_type", "begin_date", "end_date")
+ )
+ assert list(qs) == [
+ {
+ "access_type": AccessType.UNRESTRICTED,
+ "begin_date": datetime.date(2025, 1, 1),
+ "end_date": datetime.date(2025, 1, 8),
+ },
+ ]
+
+
+@freezegun.freeze_time("2025-01-01")
+@pytest.mark.parametrize(
+ "access_type",
+ [
+ AccessType.UNRESTRICTED,
+ AccessType.ACCESS_CODE,
+ AccessType.PHYSICAL_KEY,
+ ],
+)
+def test_reservation_unit__access_type_at(access_type):
+ now = local_datetime()
+ past = now - datetime.timedelta(days=1)
+ future = now + datetime.timedelta(days=1)
+
+ reservation_unit = ReservationUnitFactory.create(
+ access_types__access_type=access_type,
+ access_types__begin_date=now.date(),
+ )
+
+ assert reservation_unit.actions.get_access_type_at(past) is None
+ assert reservation_unit.actions.get_access_type_at(now) == access_type
+ assert reservation_unit.actions.get_access_type_at(future) == access_type
+
+ assert reservation_unit.current_access_type == access_type
+
+
+@freezegun.freeze_time("2025-01-01")
+@pytest.mark.parametrize(
+ "access_type",
+ [
+ AccessType.UNRESTRICTED,
+ AccessType.ACCESS_CODE,
+ AccessType.PHYSICAL_KEY,
+ ],
+)
+def test_reservation_unit__access_type_at__null(access_type):
+ now = local_datetime()
+ past = now - datetime.timedelta(days=1)
+ future = now + datetime.timedelta(days=1)
+
+ reservation_unit = ReservationUnitFactory.create(
+ access_types__access_type=access_type,
+ access_types__begin_date=future.date(),
+ )
+
+ assert reservation_unit.actions.get_access_type_at(past) is None
+ assert reservation_unit.actions.get_access_type_at(now) is None
+ assert reservation_unit.actions.get_access_type_at(future) == access_type
+
+ assert reservation_unit.current_access_type is None
diff --git a/backend/tests/test_actions/test_reservation_unit_actions/test_method_of_entry_at.py b/backend/tests/test_actions/test_reservation_unit_actions/test_method_of_entry_at.py
deleted file mode 100644
index 2f0ad54889..0000000000
--- a/backend/tests/test_actions/test_reservation_unit_actions/test_method_of_entry_at.py
+++ /dev/null
@@ -1,117 +0,0 @@
-from __future__ import annotations
-
-import datetime
-
-import freezegun
-import pytest
-
-from tilavarauspalvelu.enums import AccessType
-from utils.date_utils import local_datetime
-
-from tests.factories import ReservationUnitFactory
-
-pytestmark = [
- pytest.mark.django_db,
-]
-
-
-@freezegun.freeze_time("2025-01-01")
-@pytest.mark.parametrize(
- "access_type",
- [
- AccessType.UNRESTRICTED,
- AccessType.ACCESS_CODE,
- AccessType.PHYSICAL_KEY,
- ],
-)
-def test_reservation_unit__access_type_at__no_bounds(access_type):
- now = local_datetime()
- past = now - datetime.timedelta(days=1)
- future = now + datetime.timedelta(days=1)
-
- reservation_unit = ReservationUnitFactory.create(access_type=access_type)
-
- assert reservation_unit.actions.get_access_type_at(past) == access_type
- assert reservation_unit.actions.get_access_type_at(now) == access_type
- assert reservation_unit.actions.get_access_type_at(future) == access_type
-
- assert reservation_unit.current_access_type == access_type
-
-
-@freezegun.freeze_time("2025-01-01")
-@pytest.mark.parametrize(
- "access_type",
- [
- AccessType.UNRESTRICTED,
- AccessType.ACCESS_CODE,
- AccessType.PHYSICAL_KEY,
- ],
-)
-def test_reservation_unit__access_type_at__starts(access_type):
- now = local_datetime()
- past = now - datetime.timedelta(days=1)
- future = now + datetime.timedelta(days=1)
-
- reservation_unit = ReservationUnitFactory.create(
- access_type=access_type,
- access_type_start_date=future.date(),
- )
-
- assert reservation_unit.actions.get_access_type_at(past) == AccessType.UNRESTRICTED
- assert reservation_unit.actions.get_access_type_at(now) == AccessType.UNRESTRICTED
- assert reservation_unit.actions.get_access_type_at(future) == access_type
-
- assert reservation_unit.current_access_type == AccessType.UNRESTRICTED
-
-
-@freezegun.freeze_time("2025-01-01")
-@pytest.mark.parametrize(
- "access_type",
- [
- AccessType.UNRESTRICTED,
- AccessType.ACCESS_CODE,
- AccessType.PHYSICAL_KEY,
- ],
-)
-def test_reservation_unit__access_type_at__ends(access_type):
- now = local_datetime()
- past = now - datetime.timedelta(days=1)
- future = now + datetime.timedelta(days=1)
-
- reservation_unit = ReservationUnitFactory.create(
- access_type=access_type,
- access_type_end_date=now.date(),
- )
-
- assert reservation_unit.actions.get_access_type_at(past) == access_type
- assert reservation_unit.actions.get_access_type_at(now) == access_type
- assert reservation_unit.actions.get_access_type_at(future) == AccessType.UNRESTRICTED
-
- assert reservation_unit.current_access_type == access_type
-
-
-@freezegun.freeze_time("2025-01-01")
-@pytest.mark.parametrize(
- "access_type",
- [
- AccessType.UNRESTRICTED,
- AccessType.ACCESS_CODE,
- AccessType.PHYSICAL_KEY,
- ],
-)
-def test_reservation_unit__access_type_at__period(access_type):
- now = local_datetime()
- past = now - datetime.timedelta(days=1)
- future = now + datetime.timedelta(days=1)
-
- reservation_unit = ReservationUnitFactory.create(
- access_type=access_type,
- access_type_start_date=now.date(),
- access_type_end_date=now.date(),
- )
-
- assert reservation_unit.actions.get_access_type_at(past) == AccessType.UNRESTRICTED
- assert reservation_unit.actions.get_access_type_at(now) == access_type
- assert reservation_unit.actions.get_access_type_at(future) == AccessType.UNRESTRICTED
-
- assert reservation_unit.current_access_type == access_type
diff --git a/backend/tests/test_admin/conftest.py b/backend/tests/test_admin/conftest.py
new file mode 100644
index 0000000000..2116bb86fc
--- /dev/null
+++ b/backend/tests/test_admin/conftest.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def _language_fix(settings):
+ # Override languages, since TinyMCE can't handle lazy language name translations in tests ¯\_(ツ)_/¯
+ settings.LANGUAGES = [("fi", "Finnish"), ("en", "English"), ("sv", "Swedish")]
diff --git a/backend/tests/test_admin/helpers.py b/backend/tests/test_admin/helpers.py
new file mode 100644
index 0000000000..3de4c6fef1
--- /dev/null
+++ b/backend/tests/test_admin/helpers.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+from contextlib import contextmanager
+from typing import TYPE_CHECKING, Any
+from unittest.mock import patch
+
+from django.template.response import TemplateResponse
+
+if TYPE_CHECKING:
+ from collections.abc import Generator
+
+ from tilavarauspalvelu.models import ReservationUnit
+ from tilavarauspalvelu.typing import WSGIRequest
+
+
+@contextmanager
+def collect_admin_form_errors(errors: list[dict[str, Any]]) -> Generator[None, Any]:
+ """Collect errors from the admin form to the given list of errors."""
+
+ def hook(request: WSGIRequest, template: list[str], context: dict[str, Any], *args, **kwargs) -> TemplateResponse:
+ errors.extend(context["errors"].get_json_data())
+ return TemplateResponse(request, template, context, *args, **kwargs)
+
+ path = "django.contrib.admin.options.TemplateResponse"
+ with patch(path, side_effect=hook):
+ yield
+
+
+def management_form_data(name: str, *, total_forms: int = 0, initial_forms: int = 0) -> dict[str, Any]:
+ """
+ Required formset "management form" data for inline forms.
+
+ :params name: Name of the one-to-many or many-to-many relationship the inline form is for.
+ :params total_forms: Number of forms in the formset after the form has been submitted.
+ :params initial_forms: Number of forms in the formset before the form has been submitted.
+ """
+ return {
+ f"{name}-TOTAL_FORMS": total_forms,
+ f"{name}-INITIAL_FORMS": initial_forms,
+ f"{name}-MIN_NUM_FORMS": 0,
+ f"{name}-MAX_NUM_FORMS": 1000,
+ }
+
+
+def required_reservation_unit_form_data(reservation_unit: ReservationUnit) -> dict[str, Any]:
+ """Required fields for a new reservation unit."""
+ return {
+ #
+ # Required fields
+ "name": reservation_unit.name,
+ "name_fi": reservation_unit.name_fi,
+ "reservation_kind": reservation_unit.reservation_kind,
+ "authentication": reservation_unit.authentication,
+ "reservation_start_interval": reservation_unit.reservation_start_interval,
+ #
+ # Inline form metadata
+ **management_form_data("images"),
+ **management_form_data("pricings"),
+ **management_form_data("application_round_time_slots"),
+ **management_form_data("access_types"),
+ }
diff --git a/backend/tests/test_admin/test_django_admin_site.py b/backend/tests/test_admin/test_django_admin_site.py
index 4aa42d722f..f78eb92d4c 100644
--- a/backend/tests/test_admin/test_django_admin_site.py
+++ b/backend/tests/test_admin/test_django_admin_site.py
@@ -5,7 +5,7 @@
import pytest
from django.contrib import admin
-from django.test import Client, override_settings
+from django.test import Client
from django.urls import reverse
from tilavarauspalvelu.enums import EmailType
@@ -30,6 +30,11 @@
from tests.factories._base import GenericDjangoModelFactory
+pytestmark = [
+ pytest.mark.django_db,
+]
+
+
@pytest.fixture
def create_all_models():
"""Create a model instance for each factory in tests.factories."""
@@ -64,14 +69,11 @@ def create_all_models():
create_or_update_reservation_statistics(Reservation.objects.values_list("pk", flat=True))
-@pytest.mark.django_db
-# Override languages, since TinyMCE can't handle lazy language name translations in tests ¯\_(ツ)_/¯
-@override_settings(LANGUAGES=[("fi", "Finnish"), ("en", "English"), ("sv", "Swedish")])
-@pytest.mark.slow
@patch_method(
VerkkokauppaAPIClient.request,
return_value=ResponseMock(status_code=200, json_data=get_merchant_response),
)
+@pytest.mark.slow
def test_django_admin_site__pages_load__model_admins(create_all_models):
"""Test that all Django admin pages load without errors."""
user = factories.UserFactory.create_superuser()
@@ -118,7 +120,6 @@ def test_django_admin_site__pages_load__model_admins(create_all_models):
pytest.fail(f"Unknown lookup_str = {url_pattern}")
-@pytest.mark.django_db
@pytest.mark.slow
def test_django_admin_site__pages_load__data_views():
"""Test that all Django Admin data views load"""
diff --git a/backend/tests/test_admin/test_reservation_unit_access_type.py b/backend/tests/test_admin/test_reservation_unit_access_type.py
new file mode 100644
index 0000000000..e725003dfd
--- /dev/null
+++ b/backend/tests/test_admin/test_reservation_unit_access_type.py
@@ -0,0 +1,596 @@
+from __future__ import annotations
+
+import datetime
+from typing import Any
+
+import pytest
+from django.urls import reverse
+from freezegun import freeze_time
+
+from tilavarauspalvelu.enums import AccessType
+from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
+from tilavarauspalvelu.models import ReservationUnit
+from utils.date_utils import combine, local_date, local_datetime, local_time
+
+from tests.factories import (
+ ReservationFactory,
+ ReservationUnitAccessTypeFactory,
+ ReservationUnitFactory,
+ TaxPercentageFactory,
+ UserFactory,
+)
+from tests.helpers import patch_method
+from tests.test_admin.helpers import (
+ collect_admin_form_errors,
+ management_form_data,
+ required_reservation_unit_form_data,
+)
+
+# Applied to all tests
+pytestmark = [
+ pytest.mark.django_db,
+]
+
+
+def test_reservation_unit_admin__access_types(api_client):
+ user = UserFactory.create_superuser()
+ api_client.force_login(user)
+
+ reservation_unit = ReservationUnitFactory.create()
+
+ url = reverse("admin:tilavarauspalvelu_reservationunit_change", args=[reservation_unit.pk])
+ data = {
+ #
+ # Inline form data
+ "access_types-0-access_type": AccessType.UNRESTRICTED.value,
+ "access_types-0-begin_date": local_date().isoformat(),
+ #
+ # Required fields
+ **required_reservation_unit_form_data(reservation_unit),
+ **management_form_data("access_types", total_forms=1),
+ }
+ response = api_client.post(path=url, data=data)
+
+ # Response is a redirect to the list view
+ assert response.status_code == 302
+ assert response.url == reverse("admin:tilavarauspalvelu_reservationunit_changelist")
+
+ reservation_unit.refresh_from_db()
+
+ access_types = reservation_unit.access_types.all()
+ assert len(access_types) == 1
+ assert access_types[0].access_type == AccessType.UNRESTRICTED
+ assert access_types[0].begin_date == local_date()
+
+
+def test_reservation_unit_admin__access_types__set_to_past(api_client):
+ user = UserFactory.create_superuser()
+ api_client.force_login(user)
+
+ # Needed to get default tax percentage
+ TaxPercentageFactory.create()
+
+ reservation_unit = ReservationUnitFactory.create()
+
+ url = reverse("admin:tilavarauspalvelu_reservationunit_change", args=[reservation_unit.pk])
+ data = {
+ #
+ # Inline form data
+ "access_types-0-access_type": AccessType.UNRESTRICTED.value,
+ "access_types-0-begin_date": (local_date() - datetime.timedelta(days=1)).isoformat(),
+ #
+ # Required fields
+ **required_reservation_unit_form_data(reservation_unit),
+ **management_form_data("access_types", total_forms=1),
+ }
+
+ errors: list[dict[str, Any]] = []
+
+ with collect_admin_form_errors(errors):
+ response = api_client.post(path=url, data=data)
+
+ # There are errors in the form, so we stay on the form page
+ assert response.status_code == 200
+ assert errors == [
+ {
+ "code": "",
+ "message": "Access type cannot be created in the past.",
+ },
+ ]
+
+
+def test_reservation_unit_admin__access_types__move_past_begin_date(api_client):
+ user = UserFactory.create_superuser()
+ api_client.force_login(user)
+ today = local_date()
+
+ # Needed to get default tax percentage
+ TaxPercentageFactory.create()
+
+ reservation_unit = ReservationUnitFactory.create()
+
+ access_type_1 = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.PHYSICAL_KEY,
+ begin_date=today,
+ )
+ access_type_2 = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.OPENED_BY_STAFF,
+ begin_date=today - datetime.timedelta(days=1),
+ )
+
+ url = reverse("admin:tilavarauspalvelu_reservationunit_change", args=[reservation_unit.pk])
+ data = {
+ #
+ # Inline form data
+ "access_types-0-id": access_type_1.id,
+ "access_types-0-access_type": AccessType.PHYSICAL_KEY.value,
+ "access_types-0-begin_date": today.isoformat(),
+ "access_types-1-id": access_type_2.id,
+ "access_types-1-access_type": AccessType.UNRESTRICTED.value,
+ "access_types-1-begin_date": (today - datetime.timedelta(days=7)).isoformat(),
+ #
+ # Required fields
+ **required_reservation_unit_form_data(reservation_unit),
+ **management_form_data("access_types", total_forms=2, initial_forms=2),
+ }
+
+ errors: list[dict[str, Any]] = []
+
+ with collect_admin_form_errors(errors):
+ response = api_client.post(path=url, data=data)
+
+ # There are errors in the form, so we stay on the form page
+ assert response.status_code == 200
+ assert errors == [
+ {
+ "code": "",
+ "message": "Past of active access type begin date cannot be changed.",
+ },
+ ]
+
+
+def test_reservation_unit_admin__access_types__move_active_begin_date(api_client):
+ user = UserFactory.create_superuser()
+ api_client.force_login(user)
+ today = local_date()
+
+ # Needed to get default tax percentage
+ TaxPercentageFactory.create()
+
+ reservation_unit = ReservationUnitFactory.create()
+
+ access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.PHYSICAL_KEY,
+ begin_date=today,
+ )
+
+ url = reverse("admin:tilavarauspalvelu_reservationunit_change", args=[reservation_unit.pk])
+ data = {
+ #
+ # Inline form data
+ "access_types-0-id": access_type.id,
+ "access_types-0-access_type": AccessType.OPENED_BY_STAFF.value,
+ "access_types-0-begin_date": (today - datetime.timedelta(days=1)).isoformat(),
+ #
+ # Required fields
+ **required_reservation_unit_form_data(reservation_unit),
+ **management_form_data("access_types", total_forms=1, initial_forms=1),
+ }
+
+ errors: list[dict[str, Any]] = []
+
+ with collect_admin_form_errors(errors):
+ response = api_client.post(path=url, data=data)
+
+ # There are errors in the form, so we stay on the form page
+ assert response.status_code == 200
+ assert errors == [
+ {
+ "code": "",
+ "message": "Past of active access type begin date cannot be changed.",
+ },
+ ]
+
+
+def test_reservation_unit_admin__access_types__move_begin_date_to_past(api_client):
+ user = UserFactory.create_superuser()
+ api_client.force_login(user)
+
+ # Needed to get default tax percentage
+ TaxPercentageFactory.create()
+
+ reservation_unit = ReservationUnitFactory.create()
+
+ access_type_1 = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.OPENED_BY_STAFF,
+ begin_date=local_date(),
+ )
+
+ access_type_2 = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.PHYSICAL_KEY,
+ begin_date=(local_date() + datetime.timedelta(days=1)),
+ )
+
+ url = reverse("admin:tilavarauspalvelu_reservationunit_change", args=[reservation_unit.pk])
+ data = {
+ #
+ # Inline form data
+ "access_types-0-id": access_type_1.id,
+ "access_types-0-access_type": access_type_1.access_type.value,
+ "access_types-0-begin_date": access_type_1.begin_date.isoformat(),
+ "access_types-1-id": access_type_2.id,
+ "access_types-1-access_type": access_type_2.access_type.value,
+ "access_types-1-begin_date": (local_date() - datetime.timedelta(days=1)).isoformat(),
+ #
+ # Required fields
+ **required_reservation_unit_form_data(reservation_unit),
+ **management_form_data("access_types", total_forms=2, initial_forms=2),
+ }
+
+ errors: list[dict[str, Any]] = []
+
+ with collect_admin_form_errors(errors):
+ response = api_client.post(path=url, data=data)
+
+ # There are errors in the form, so we stay on the form page
+ assert response.status_code == 200
+ assert errors == [
+ {
+ "code": "",
+ "message": "Access type cannot be moved to the past.",
+ },
+ ]
+
+
+def test_reservation_unit_admin__access_types__no_active_access_type(api_client):
+ user = UserFactory.create_superuser()
+ api_client.force_login(user)
+ today = local_date()
+
+ # Needed to get default tax percentage
+ TaxPercentageFactory.create()
+
+ reservation_unit = ReservationUnitFactory.create()
+
+ access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.PHYSICAL_KEY,
+ begin_date=today + datetime.timedelta(days=1),
+ )
+
+ url = reverse("admin:tilavarauspalvelu_reservationunit_change", args=[reservation_unit.pk])
+ data = {
+ #
+ # Inline form data
+ "access_types-0-id": access_type.id,
+ "access_types-0-access_type": AccessType.PHYSICAL_KEY.value,
+ "access_types-0-begin_date": (today + datetime.timedelta(days=1)).isoformat(),
+ #
+ # Required fields
+ **required_reservation_unit_form_data(reservation_unit),
+ **management_form_data("access_types", total_forms=1, initial_forms=1),
+ }
+
+ errors: list[dict[str, Any]] = []
+
+ with collect_admin_form_errors(errors):
+ response = api_client.post(path=url, data=data)
+
+ # There are errors in the form, so we stay on the form page
+ assert response.status_code == 200
+ assert errors == [
+ {
+ "code": "RESERVATION_UNIT_MISSING_ACTIVE_ACCESS_TYPE",
+ "message": "At least one active access type is required.",
+ },
+ ]
+
+
+@patch_method(PindoraClient.get_reservation_unit)
+def test_reservation_unit_admin__access_types__access_code_checks_pindora(api_client):
+ user = UserFactory.create_superuser()
+ api_client.force_login(user)
+
+ reservation_unit = ReservationUnitFactory.create()
+
+ access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.OPENED_BY_STAFF,
+ begin_date=local_date(),
+ )
+
+ url = reverse("admin:tilavarauspalvelu_reservationunit_change", args=[reservation_unit.pk])
+ data = {
+ #
+ # Inline form data
+ "access_types-0-id": access_type.id,
+ "access_types-0-access_type": AccessType.ACCESS_CODE.value,
+ "access_types-0-begin_date": local_date().isoformat(),
+ #
+ # Required fields
+ **required_reservation_unit_form_data(reservation_unit),
+ **management_form_data("access_types", total_forms=1, initial_forms=1),
+ }
+ response = api_client.post(path=url, data=data)
+
+ # Response is a redirect to the list view
+ assert response.status_code == 302
+ assert response.url == reverse("admin:tilavarauspalvelu_reservationunit_changelist")
+
+ reservation_unit.refresh_from_db()
+
+ access_types = reservation_unit.access_types.all()
+ assert len(access_types) == 1
+ assert access_types[0].access_type == AccessType.ACCESS_CODE
+ assert access_types[0].begin_date == local_date()
+
+ assert PindoraClient.get_reservation_unit.call_count == 1
+
+
+@patch_method(PindoraClient.get_reservation_unit)
+def test_reservation_unit_admin__access_types__already_access_code_skips_pindora_check(api_client):
+ user = UserFactory.create_superuser()
+ api_client.force_login(user)
+
+ reservation_unit = ReservationUnitFactory.create()
+
+ access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.ACCESS_CODE,
+ begin_date=local_date(),
+ )
+
+ url = reverse("admin:tilavarauspalvelu_reservationunit_change", args=[reservation_unit.pk])
+ data = {
+ #
+ # Inline form data
+ "access_types-0-id": access_type.id,
+ "access_types-0-access_type": AccessType.ACCESS_CODE.value,
+ "access_types-0-begin_date": local_date().isoformat(),
+ #
+ # Required fields
+ **required_reservation_unit_form_data(reservation_unit),
+ **management_form_data("access_types", total_forms=1, initial_forms=1),
+ }
+ response = api_client.post(path=url, data=data)
+
+ # Response is a redirect to the list view
+ assert response.status_code == 302
+ assert response.url == reverse("admin:tilavarauspalvelu_reservationunit_changelist")
+
+ reservation_unit.refresh_from_db()
+
+ access_types = reservation_unit.access_types.all()
+ assert len(access_types) == 1
+ assert access_types[0].access_type == AccessType.ACCESS_CODE
+
+ # Pindora still called in form init...
+ assert PindoraClient.get_reservation_unit.call_count == 1
+
+
+@freeze_time(local_datetime(2023, 1, 1, hour=0))
+def test_reservation_unit_admin__access_types__set_new_access_type_to_reservations(api_client):
+ user = UserFactory.create_superuser()
+ api_client.force_login(user)
+
+ today = local_date(2023, 1, 1)
+
+ # Needed to get default tax percentage
+ TaxPercentageFactory.create()
+
+ reservation_unit = ReservationUnitFactory.create()
+
+ access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.PHYSICAL_KEY,
+ begin_date=today,
+ )
+
+ past_reservation = ReservationFactory.create(
+ begin=combine(today - datetime.timedelta(days=1), local_time(12)),
+ end=combine(today - datetime.timedelta(days=1), local_time(13)),
+ access_type=AccessType.PHYSICAL_KEY,
+ reservation_units=[reservation_unit],
+ )
+ todays_reservation = ReservationFactory.create(
+ begin=combine(today, local_time(12)),
+ end=combine(today, local_time(13)),
+ access_type=AccessType.PHYSICAL_KEY,
+ reservation_units=[reservation_unit],
+ )
+ future_reservation = ReservationFactory.create(
+ begin=combine(today + datetime.timedelta(days=1), local_time(12)),
+ end=combine(today + datetime.timedelta(days=1), local_time(13)),
+ access_type=AccessType.PHYSICAL_KEY,
+ reservation_units=[reservation_unit],
+ )
+
+ url = reverse("admin:tilavarauspalvelu_reservationunit_change", args=[reservation_unit.pk])
+ data = {
+ #
+ # Inline form data
+ "access_types-0-id": access_type.id,
+ "access_types-0-access_type": AccessType.UNRESTRICTED.value,
+ "access_types-0-begin_date": today.isoformat(),
+ "access_types-1-access_type": AccessType.OPENED_BY_STAFF.value,
+ "access_types-1-begin_date": (today + datetime.timedelta(days=1)).isoformat(),
+ #
+ # Required fields
+ **required_reservation_unit_form_data(reservation_unit),
+ **management_form_data("access_types", total_forms=2, initial_forms=1),
+ }
+
+ errors: list[dict[str, Any]] = []
+
+ with collect_admin_form_errors(errors):
+ response = api_client.post(path=url, data=data)
+
+ # Response is a redirect to the list view
+ assert response.status_code == 302
+ assert response.url == reverse("admin:tilavarauspalvelu_reservationunit_changelist")
+
+ past_reservation.refresh_from_db()
+ assert past_reservation.access_type == AccessType.PHYSICAL_KEY
+
+ todays_reservation.refresh_from_db()
+ assert todays_reservation.access_type == AccessType.UNRESTRICTED
+
+ future_reservation.refresh_from_db()
+ assert future_reservation.access_type == AccessType.OPENED_BY_STAFF
+
+
+def test_reservation_unit_admin__access_types__cannot_delete_active(api_client):
+ user = UserFactory.create_superuser()
+ api_client.force_login(user)
+
+ # Needed to get default tax percentage
+ TaxPercentageFactory.create()
+
+ reservation_unit = ReservationUnitFactory.create()
+
+ today = local_date()
+
+ access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.PHYSICAL_KEY,
+ begin_date=today,
+ )
+
+ url = reverse("admin:tilavarauspalvelu_reservationunit_change", args=[reservation_unit.pk])
+ data = {
+ #
+ # Inline form data
+ "access_types-0-id": access_type.id,
+ "access_types-0-access_type": access_type.access_type.value,
+ "access_types-0-begin_date": access_type.begin_date.isoformat(),
+ "access_types-0-DELETE": True,
+ #
+ # Required fields
+ **required_reservation_unit_form_data(reservation_unit),
+ **management_form_data("access_types", total_forms=1, initial_forms=1),
+ }
+
+ errors: list[dict[str, Any]] = []
+
+ with collect_admin_form_errors(errors):
+ response = api_client.post(path=url, data=data)
+
+ # There are errors in the form, so we stay on the form page
+ assert response.status_code == 200
+ assert errors == [
+ {
+ "code": "",
+ "message": "Cannot delete past or active access type.",
+ },
+ ]
+
+
+def test_reservation_unit_admin__access_types__new(api_client):
+ user = UserFactory.create_superuser()
+ api_client.force_login(user)
+
+ reservation_unit = ReservationUnitFactory.build()
+
+ url = reverse("admin:tilavarauspalvelu_reservationunit_add")
+ data = {
+ #
+ # Inline form data
+ "access_types-0-access_type": AccessType.UNRESTRICTED.value,
+ "access_types-0-begin_date": local_date().isoformat(),
+ #
+ # Required fields
+ **required_reservation_unit_form_data(reservation_unit),
+ **management_form_data("access_types", total_forms=1),
+ }
+ response = api_client.post(path=url, data=data)
+
+ # Response is a redirect to the list view
+ assert response.status_code == 302
+ assert response.url == reverse("admin:tilavarauspalvelu_reservationunit_changelist")
+
+ reservation_unit = ReservationUnit.objects.first()
+
+ access_types = reservation_unit.access_types.all()
+ assert len(access_types) == 1
+ assert access_types[0].access_type == AccessType.UNRESTRICTED
+ assert access_types[0].begin_date == local_date()
+
+
+def test_reservation_unit_admin__access_types__new__access_code(api_client):
+ user = UserFactory.create_superuser()
+ api_client.force_login(user)
+
+ # Needed to get default tax percentage
+ TaxPercentageFactory.create()
+
+ reservation_unit = ReservationUnitFactory.build()
+
+ url = reverse("admin:tilavarauspalvelu_reservationunit_add")
+ data = {
+ #
+ # Inline form data
+ "access_types-0-access_type": AccessType.ACCESS_CODE.value,
+ "access_types-0-begin_date": local_date().isoformat(),
+ #
+ # Required fields
+ **required_reservation_unit_form_data(reservation_unit),
+ **management_form_data("access_types", total_forms=1),
+ }
+
+ errors: list[dict[str, Any]] = []
+
+ with collect_admin_form_errors(errors):
+ response = api_client.post(path=url, data=data)
+
+ # There are errors in the form, so we stay on the form page
+ assert response.status_code == 200
+
+ assert errors == [
+ {
+ "code": "",
+ "message": "Cannot set access type to access code on reservation unit create.",
+ },
+ ]
+
+
+@patch_method(PindoraClient.get_reservation_unit)
+def test_reservation_unit_admin__access_types__new__cannot_be_access_type_on_create(api_client):
+ user = UserFactory.create_superuser()
+ api_client.force_login(user)
+
+ # Needed to get default tax percentage
+ TaxPercentageFactory.create()
+
+ reservation_unit = ReservationUnitFactory.build()
+
+ url = reverse("admin:tilavarauspalvelu_reservationunit_add")
+ data = {
+ #
+ # Inline form data
+ "access_types-0-access_type": AccessType.ACCESS_CODE.value,
+ "access_types-0-begin_date": local_date().isoformat(),
+ #
+ # Required fields
+ **required_reservation_unit_form_data(reservation_unit),
+ **management_form_data("access_types", total_forms=1),
+ }
+
+ errors: list[dict[str, Any]] = []
+
+ with collect_admin_form_errors(errors):
+ response = api_client.post(path=url, data=data)
+
+ # There are errors in the form, so we stay on the form page
+ assert response.status_code == 200
+
+ assert errors == [
+ {
+ "code": "",
+ "message": "Cannot set access type to access code on reservation unit create.",
+ },
+ ]
diff --git a/backend/tests/test_graphql_api/test_application_section/helpers.py b/backend/tests/test_graphql_api/test_application_section/helpers.py
index 1b717ef2a7..133515d1ea 100644
--- a/backend/tests/test_graphql_api/test_application_section/helpers.py
+++ b/backend/tests/test_graphql_api/test_application_section/helpers.py
@@ -13,6 +13,7 @@
if TYPE_CHECKING:
from tilavarauspalvelu.models import Application, ApplicationSection
+section_query = partial(build_query, "applicationSection")
sections_query = partial(build_query, "applicationSections", connection=True, order_by="pkAsc")
CREATE_MUTATION = build_mutation(
diff --git a/backend/tests/test_graphql_api/test_application_section/test_query.py b/backend/tests/test_graphql_api/test_application_section/test_query.py
index 3b1937eb8b..8f15041b1b 100644
--- a/backend/tests/test_graphql_api/test_application_section/test_query.py
+++ b/backend/tests/test_graphql_api/test_application_section/test_query.py
@@ -1,9 +1,32 @@
from __future__ import annotations
+from typing import TYPE_CHECKING
+
import pytest
+from freezegun import freeze_time
+from graphql_relay import to_global_id
+
+from tilavarauspalvelu.enums import AccessType, ReservationStateChoice, ReservationTypeChoice
+from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
+from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraAPIError
+from tilavarauspalvelu.integrations.keyless_entry.typing import (
+ PindoraSeasonalBookingAccessCodeValidity,
+ PindoraSeasonalBookingResponse,
+)
+from utils.date_utils import local_date, local_datetime
+
+from tests.factories import (
+ ApplicationSectionFactory,
+ RecurringReservationFactory,
+ ReservationFactory,
+ SuitableTimeRangeFactory,
+)
+from tests.helpers import patch_method
+
+from .helpers import section_query, sections_query
-from tests.factories import ApplicationSectionFactory, SuitableTimeRangeFactory
-from tests.test_graphql_api.test_application_section.helpers import sections_query
+if TYPE_CHECKING:
+ from tilavarauspalvelu.models import ApplicationSection
# Applied to all tests
pytestmark = [
@@ -11,14 +34,8 @@
]
-def test_application_section__query__all_fields(graphql):
- # given:
- # - There is draft application in an open application round
- # - A superuser is using the system
+def test_application_section__query(graphql):
section = ApplicationSectionFactory.create_in_status_unallocated()
- option = section.reservation_unit_options.first()
- suitable_time_range = SuitableTimeRangeFactory.create(application_section=section)
- ApplicationSectionFactory.create_in_status_unallocated()
graphql.login_with_superuser()
fields = """
@@ -31,6 +48,38 @@ def test_application_section__query__all_fields(graphql):
reservationMinDuration
reservationMaxDuration
appliedReservationsPerWeek
+ status
+ shouldHaveActiveAccessCode
+ """
+ query = sections_query(fields=fields)
+ response = graphql(query)
+
+ assert response.has_errors is False, response.errors
+ assert len(response.edges) == 1, response
+ assert response.node(0) == {
+ "pk": section.pk,
+ "extUuid": str(section.ext_uuid),
+ "name": section.name,
+ "numPersons": section.num_persons,
+ "reservationsBeginDate": section.reservations_begin_date.isoformat(),
+ "reservationsEndDate": section.reservations_end_date.isoformat(),
+ "reservationMinDuration": int(section.reservation_min_duration.total_seconds()),
+ "reservationMaxDuration": int(section.reservation_max_duration.total_seconds()),
+ "appliedReservationsPerWeek": section.applied_reservations_per_week,
+ "status": section.status.value,
+ "shouldHaveActiveAccessCode": False,
+ }
+
+
+def test_application_section__query__relations(graphql):
+ section = ApplicationSectionFactory.create_in_status_unallocated()
+ option = section.reservation_unit_options.first()
+ suitable_time_range = SuitableTimeRangeFactory.create(application_section=section)
+ ApplicationSectionFactory.create_in_status_unallocated()
+ graphql.login_with_superuser()
+
+ fields = """
+ pk
ageGroup {
minimum
maximum
@@ -51,29 +100,14 @@ def test_application_section__query__all_fields(graphql):
endTime
fulfilled
}
- status
"""
-
- # when:
- # - User tries to search for application events with all fields
query = sections_query(fields=fields)
response = graphql(query)
- # then:
- # - The response contains the selected fields from both application events
assert response.has_errors is False, response.errors
assert len(response.edges) == 2, response
assert response.node(0) == {
"pk": section.pk,
- "extUuid": str(section.ext_uuid),
- "name": section.name,
- "numPersons": section.num_persons,
- "reservationsBeginDate": section.reservations_begin_date.isoformat(),
- "reservationsEndDate": section.reservations_end_date.isoformat(),
- "reservationMinDuration": int(section.reservation_min_duration.total_seconds()),
- "reservationMaxDuration": int(section.reservation_max_duration.total_seconds()),
- "appliedReservationsPerWeek": section.applied_reservations_per_week,
- #
"ageGroup": {
"minimum": section.age_group.minimum,
"maximum": section.age_group.maximum,
@@ -98,11 +132,10 @@ def test_application_section__query__all_fields(graphql):
"fulfilled": suitable_time_range.fulfilled,
},
],
- "status": section.status.value,
}
-def test_all_statuses(graphql):
+def test_application_section__all_statuses(graphql):
ApplicationSectionFactory.create_in_status_handled()
ApplicationSectionFactory.create_in_status_in_allocation()
ApplicationSectionFactory.create_in_status_unallocated()
@@ -145,3 +178,206 @@ def test_all_statuses(graphql):
# 1 query to fetch application rounds with their status annotations
# 1 query to fetch units for permission checks for application rounds
assert len(response.queries) in {8, 9}, response.query_log
+
+
+def pindora_response(section: ApplicationSection) -> PindoraSeasonalBookingResponse:
+ return PindoraSeasonalBookingResponse(
+ access_code="1234",
+ access_code_generated_at=local_datetime(2022, 1, 1, 12),
+ access_code_is_active=True,
+ access_code_keypad_url="https://keypad.url",
+ access_code_phone_number="123456789",
+ access_code_sms_number="123456789",
+ access_code_sms_message="123456789",
+ reservation_unit_code_validity=[
+ PindoraSeasonalBookingAccessCodeValidity(
+ reservation_unit_id=section.reservation_unit_options.first().reservation_unit.uuid,
+ access_code_valid_minutes_before=10,
+ access_code_valid_minutes_after=5,
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ )
+ ],
+ )
+
+
+def pindora_query(section: ApplicationSection) -> str:
+ fields = """
+ pindoraInfo {
+ accessCode
+ accessCodeGeneratedAt
+ accessCodeIsActive
+ accessCodeKeypadUrl
+ accessCodePhoneNumber
+ accessCodeSmsNumber
+ accessCodeSmsMessage
+ accessCodeValidity {
+ reservationId
+ reservationSeriesId
+ accessCodeBeginsAt
+ accessCodeEndsAt
+ }
+ }
+ """
+ global_id = to_global_id("ApplicationSectionNode", section.pk)
+ return section_query(fields=fields, id=global_id)
+
+
+@freeze_time(local_datetime(2022, 1, 1))
+def test_application_section__query__pindora_info(graphql):
+ section = ApplicationSectionFactory.create_in_status_unallocated(
+ reservations_begin_date=local_date(2022, 1, 1),
+ reservations_end_date=local_date(2022, 1, 1),
+ )
+ series = RecurringReservationFactory.create(
+ allocated_time_slot__reservation_unit_option__application_section=section,
+ )
+ reservation = ReservationFactory.create(
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ recurring_reservation=series,
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ )
+
+ query = pindora_query(section)
+
+ graphql.login_with_superuser()
+
+ with patch_method(PindoraClient.get_seasonal_booking, return_value=pindora_response(section)):
+ response = graphql(query)
+
+ assert response.has_errors is False, response.errors
+
+ assert response.first_query_object["pindoraInfo"] == {
+ "accessCode": "1234",
+ "accessCodeGeneratedAt": "2022-01-01T12:00:00+02:00",
+ "accessCodeIsActive": True,
+ "accessCodeKeypadUrl": "https://keypad.url",
+ "accessCodePhoneNumber": "123456789",
+ "accessCodeSmsMessage": "123456789",
+ "accessCodeSmsNumber": "123456789",
+ "accessCodeValidity": [
+ {
+ "reservationId": reservation.pk,
+ "reservationSeriesId": series.pk,
+ "accessCodeBeginsAt": "2022-01-01T11:50:00+02:00",
+ "accessCodeEndsAt": "2022-01-01T13:05:00+02:00",
+ }
+ ],
+ }
+
+
+@freeze_time(local_datetime(2022, 1, 1))
+@pytest.mark.parametrize("as_reservee", [True, False])
+def test_application_section__query__pindora_info__access_code_not_active(graphql, as_reservee):
+ section = ApplicationSectionFactory.create_in_status_unallocated(
+ reservations_begin_date=local_date(2022, 1, 1),
+ reservations_end_date=local_date(2022, 1, 1),
+ )
+
+ ReservationFactory.create(
+ recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=section,
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ )
+
+ query = pindora_query(section)
+
+ if as_reservee:
+ graphql.force_login(section.application.user)
+ else:
+ graphql.login_with_superuser()
+
+ response = pindora_response(section)
+ response["access_code_is_active"] = False
+
+ with patch_method(PindoraClient.get_seasonal_booking, return_value=response):
+ response = graphql(query)
+
+ assert response.has_errors is False, response.errors
+
+ if as_reservee:
+ assert response.first_query_object["pindoraInfo"] is None
+ else:
+ assert response.first_query_object["pindoraInfo"] is not None
+
+
+@freeze_time(local_datetime(2022, 1, 1))
+def test_application_section__query__pindora_info__access_type_not_access_code(graphql):
+ section = ApplicationSectionFactory.create_in_status_unallocated(
+ reservations_begin_date=local_date(2022, 1, 1),
+ reservations_end_date=local_date(2022, 1, 1),
+ )
+
+ ReservationFactory.create(
+ recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=section,
+ access_type=AccessType.PHYSICAL_KEY,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ )
+
+ query = pindora_query(section)
+
+ graphql.login_with_superuser()
+
+ with patch_method(PindoraClient.get_seasonal_booking, return_value=pindora_response(section)):
+ response = graphql(query)
+
+ assert response.has_errors is False, response.errors
+
+ assert response.first_query_object["pindoraInfo"] is None
+
+
+@freeze_time(local_datetime(2022, 1, 1))
+def test_application_section__query__pindora_info__pindora_call_fails(graphql):
+ section = ApplicationSectionFactory.create_in_status_unallocated(
+ reservations_begin_date=local_date(2022, 1, 1),
+ reservations_end_date=local_date(2022, 1, 1),
+ )
+
+ ReservationFactory.create(
+ recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=section,
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ )
+
+ query = pindora_query(section)
+
+ graphql.login_with_superuser()
+
+ with patch_method(PindoraClient.get_seasonal_booking, side_effect=PindoraAPIError("Error")):
+ response = graphql(query)
+
+ assert response.has_errors is False, response.errors
+
+ assert response.first_query_object["pindoraInfo"] is None
+
+
+@freeze_time(local_datetime(2022, 1, 3))
+def test_application_section__query__pindora_info__section_past(graphql):
+ section = ApplicationSectionFactory.create_in_status_unallocated(
+ reservations_begin_date=local_date(2022, 1, 1),
+ reservations_end_date=local_date(2022, 1, 1),
+ )
+
+ ReservationFactory.create(
+ recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=section,
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ )
+
+ query = pindora_query(section)
+
+ graphql.login_with_superuser()
+
+ with patch_method(PindoraClient.get_seasonal_booking, return_value=pindora_response(section)):
+ response = graphql(query)
+
+ assert response.has_errors is False, response.errors
+
+ assert response.first_query_object["pindoraInfo"] is None
diff --git a/backend/tests/test_graphql_api/test_recurring_reservation/helpers.py b/backend/tests/test_graphql_api/test_recurring_reservation/helpers.py
index 27c574d00c..43ce80d713 100644
--- a/backend/tests/test_graphql_api/test_recurring_reservation/helpers.py
+++ b/backend/tests/test_graphql_api/test_recurring_reservation/helpers.py
@@ -1,20 +1,26 @@
from __future__ import annotations
import datetime
+import uuid
from functools import partial
from typing import TYPE_CHECKING, Any
from graphene_django_extensions.testing import build_mutation, build_query
from tilavarauspalvelu.enums import ReservationTypeChoice, WeekdayChoice
+from tilavarauspalvelu.integrations.keyless_entry.typing import (
+ PindoraReservationSeriesAccessCodeValidity,
+ PindoraReservationSeriesResponse,
+)
from tilavarauspalvelu.models import AffectingTimeSpan, ReservationUnitHierarchy
-from utils.date_utils import DEFAULT_TIMEZONE, local_date, local_time
+from utils.date_utils import DEFAULT_TIMEZONE, local_date, local_datetime, local_time
from tests.factories import RecurringReservationFactory
if TYPE_CHECKING:
from tilavarauspalvelu.models import RecurringReservation, ReservationUnit, User
+recurring_reservation_query = partial(build_query, "recurringReservation")
recurring_reservations_query = partial(build_query, "recurringReservations", connection=True, order_by="nameAsc")
CREATE_SERIES_MUTATION = build_mutation("createReservationSeries", "ReservationSeriesCreateMutation")
@@ -92,3 +98,24 @@ def create_reservation_series(**kwargs: Any) -> RecurringReservation:
AffectingTimeSpan.refresh()
return recurring_reservation
+
+
+def pindora_response() -> PindoraReservationSeriesResponse:
+ return PindoraReservationSeriesResponse(
+ reservation_unit_id=uuid.uuid4(),
+ access_code="123456",
+ access_code_keypad_url="https://example.com/keypad",
+ access_code_phone_number="123456789",
+ access_code_sms_number="123456789",
+ access_code_sms_message="msg",
+ access_code_generated_at=local_datetime(2022, 1, 1),
+ access_code_is_active=True,
+ reservation_unit_code_validity=[
+ PindoraReservationSeriesAccessCodeValidity(
+ access_code_valid_minutes_before=10,
+ access_code_valid_minutes_after=5,
+ begin=local_datetime(2022, 1, 1, 10),
+ end=local_datetime(2022, 1, 1, 12),
+ )
+ ],
+ )
diff --git a/backend/tests/test_graphql_api/test_recurring_reservation/test_create_series.py b/backend/tests/test_graphql_api/test_recurring_reservation/test_create_series.py
index 27d30d8197..d2059248a1 100644
--- a/backend/tests/test_graphql_api/test_recurring_reservation/test_create_series.py
+++ b/backend/tests/test_graphql_api/test_recurring_reservation/test_create_series.py
@@ -5,13 +5,16 @@
import pytest
from tilavarauspalvelu.enums import (
+ AccessType,
CustomerTypeChoice,
ReservationStateChoice,
ReservationTypeChoice,
ReservationTypeStaffChoice,
)
+from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
+from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraAPIError
from tilavarauspalvelu.models import AffectingTimeSpan, RecurringReservation, Reservation, ReservationUnitHierarchy
-from utils.date_utils import DEFAULT_TIMEZONE, combine, local_date, local_end_of_day, local_start_of_day
+from utils.date_utils import DEFAULT_TIMEZONE, combine, local_date, local_datetime, local_end_of_day, local_start_of_day
from tests.factories import (
AbilityGroupFactory,
@@ -24,8 +27,9 @@
SpaceFactory,
UserFactory,
)
+from tests.helpers import patch_method
-from .helpers import CREATE_SERIES_MUTATION, get_minimal_series_data
+from .helpers import CREATE_SERIES_MUTATION, get_minimal_series_data, pindora_response
# Applied to all tests
pytestmark = [
@@ -592,3 +596,81 @@ def test_recurring_reservations__create_series__block_whole_day(graphql):
assert reservations[0].buffer_time_before == datetime.timedelta(hours=10)
assert reservations[0].buffer_time_after == datetime.timedelta(hours=12)
+
+
+@patch_method(PindoraClient.create_reservation_series, return_value=pindora_response())
+def test_recurring_reservations__create_series__access_type_access_code(graphql):
+ reservation_unit = ReservationUnitFactory.create(access_types__access_type=AccessType.ACCESS_CODE)
+ user = graphql.login_with_superuser()
+
+ data = get_minimal_series_data(reservation_unit, user)
+ data["reservationDetails"]["state"] = ReservationStateChoice.CONFIRMED.value
+
+ response = graphql(CREATE_SERIES_MUTATION, input_data=data)
+
+ assert response.has_errors is False, response.errors
+
+ assert PindoraClient.create_reservation_series.called is True
+
+ pk = response.first_query_object["pk"]
+ reservations: list[Reservation] = list(Reservation.objects.filter(recurring_reservation=pk))
+ assert len(reservations) == 1
+
+ assert reservations[0].access_type == AccessType.ACCESS_CODE
+ assert reservations[0].access_code_generated_at == local_datetime(2022, 1, 1)
+ assert reservations[0].access_code_is_active is True
+
+
+@patch_method(PindoraClient.create_reservation_series, return_value=pindora_response())
+def test_recurring_reservations__create_series__access_type_access_code__only_some_reservations(graphql):
+ reservation_unit = ReservationUnitFactory.create(
+ access_types__access_type=AccessType.ACCESS_CODE,
+ access_types__begin_date=datetime.date(2024, 1, 5),
+ )
+ user = graphql.login_with_superuser()
+
+ data = get_minimal_series_data(reservation_unit, user)
+ data["endDate"] = datetime.date(2024, 1, 8).isoformat()
+ data["reservationDetails"]["state"] = ReservationStateChoice.CONFIRMED.value
+
+ response = graphql(CREATE_SERIES_MUTATION, input_data=data)
+
+ assert response.has_errors is False, response.errors
+
+ assert PindoraClient.create_reservation_series.called is True
+
+ pk = response.first_query_object["pk"]
+ reservations: list[Reservation] = list(Reservation.objects.order_by("begin").filter(recurring_reservation=pk))
+ assert len(reservations) == 2
+
+ assert reservations[0].access_type == AccessType.UNRESTRICTED
+ assert reservations[0].access_code_generated_at is None
+ assert reservations[0].access_code_is_active is False
+
+ assert reservations[1].access_type == AccessType.ACCESS_CODE
+ assert reservations[1].access_code_generated_at == local_datetime(2022, 1, 1)
+ assert reservations[1].access_code_is_active is True
+
+
+@patch_method(PindoraClient.create_reservation_series, side_effect=PindoraAPIError("Pindora Error"))
+def test_recurring_reservations__create_series__access_type_access_code__pindora_call_fails(graphql):
+ reservation_unit = ReservationUnitFactory.create(access_types__access_type=AccessType.ACCESS_CODE)
+ user = graphql.login_with_superuser()
+
+ data = get_minimal_series_data(reservation_unit, user)
+ data["reservationDetails"]["state"] = ReservationStateChoice.CONFIRMED.value
+
+ response = graphql(CREATE_SERIES_MUTATION, input_data=data)
+
+ assert response.error_message() == "Pindora Error"
+
+ assert PindoraClient.create_reservation_series.called is True
+
+ # Reservation series is still created, but access codes hae not been generated.
+ recurring_reservation: RecurringReservation | None = RecurringReservation.objects.first()
+ assert recurring_reservation is not None
+ reservations: list[Reservation] = list(recurring_reservation.reservations.all())
+ assert len(reservations) == 1
+ assert reservations[0].access_type == AccessType.ACCESS_CODE
+ assert reservations[0].access_code_generated_at is None
+ assert reservations[0].access_code_is_active is False
diff --git a/backend/tests/test_graphql_api/test_recurring_reservation/test_filtering.py b/backend/tests/test_graphql_api/test_recurring_reservation/test_filtering.py
new file mode 100644
index 0000000000..0380461431
--- /dev/null
+++ b/backend/tests/test_graphql_api/test_recurring_reservation/test_filtering.py
@@ -0,0 +1,195 @@
+from __future__ import annotations
+
+import pytest
+
+from tests.factories import RecurringReservationFactory
+
+from .helpers import recurring_reservations_query
+
+# Applied to all tests
+pytestmark = [
+ pytest.mark.django_db,
+]
+
+
+def test_recurring_reservations__filter__by_user(graphql):
+ recurring_reservation = RecurringReservationFactory.create()
+ RecurringReservationFactory.create()
+ graphql.login_with_superuser()
+
+ query = recurring_reservations_query(user=recurring_reservation.user.pk)
+ response = graphql(query)
+
+ assert response.has_errors is False
+
+ assert len(response.edges) == 1
+ assert response.node(0) == {"pk": recurring_reservation.pk}
+
+
+@pytest.mark.parametrize(
+ ("field", "value"),
+ [
+ ("reservationUnitNameFi", "FI"),
+ ("reservationUnitNameEn", "EN"),
+ ("reservationUnitNameSv", "SV"),
+ ],
+)
+def test_recurring_reservations__filter__by_reservation_unit_name(graphql, field, value):
+ recurring_reservation = RecurringReservationFactory.create(
+ name="1",
+ reservation_unit__name_fi="FI",
+ reservation_unit__name_en="EN",
+ reservation_unit__name_sv="SV",
+ )
+ RecurringReservationFactory.create(
+ name="2",
+ reservation_unit__name_fi="foo",
+ reservation_unit__name_en="bar",
+ reservation_unit__name_sv="baz",
+ )
+ graphql.login_with_superuser()
+
+ query = recurring_reservations_query(**{field: value})
+ response = graphql(query)
+
+ assert response.has_errors is False
+
+ assert len(response.edges) == 1
+ assert response.node(0) == {"pk": recurring_reservation.pk}
+
+
+@pytest.mark.parametrize(
+ ("field", "value"),
+ [
+ ("reservationUnitNameFi", "FI, foo"),
+ ("reservationUnitNameEn", "EN, bar"),
+ ("reservationUnitNameSv", "SV, baz"),
+ ],
+)
+def test_recurring_reservations__filter__by_reservation_unit_name__multiple(graphql, field, value):
+ recurring_reservation_1 = RecurringReservationFactory.create(
+ name="1",
+ reservation_unit__name_fi="FI",
+ reservation_unit__name_en="EN",
+ reservation_unit__name_sv="SV",
+ )
+ recurring_reservation_2 = RecurringReservationFactory.create(
+ name="2",
+ reservation_unit__name_fi="foo",
+ reservation_unit__name_en="bar",
+ reservation_unit__name_sv="baz",
+ )
+ graphql.login_with_superuser()
+
+ query = recurring_reservations_query(**{field: value})
+ response = graphql(query)
+
+ assert response.has_errors is False
+
+ assert len(response.edges) == 2
+ assert response.node(0) == {"pk": recurring_reservation_1.pk}
+ assert response.node(1) == {"pk": recurring_reservation_2.pk}
+
+
+def test_recurring_reservations__filter__by_reservation_unit(graphql):
+ recurring_reservation = RecurringReservationFactory.create()
+ RecurringReservationFactory.create()
+ graphql.login_with_superuser()
+
+ query = recurring_reservations_query(reservationUnit=recurring_reservation.reservation_unit.pk)
+ response = graphql(query)
+
+ assert response.has_errors is False
+
+ assert len(response.edges) == 1
+ assert response.node(0) == {"pk": recurring_reservation.pk}
+
+
+def test_recurring_reservations__filter__by_reservation_unit__multiple(graphql):
+ recurring_reservation_1 = RecurringReservationFactory.create(name="1")
+ recurring_reservation_2 = RecurringReservationFactory.create(name="2")
+ graphql.login_with_superuser()
+
+ query = recurring_reservations_query(
+ reservationUnit=[recurring_reservation_1.reservation_unit.pk, recurring_reservation_2.reservation_unit.pk],
+ )
+ response = graphql(query)
+
+ assert response.has_errors is False
+
+ assert len(response.edges) == 2
+ assert response.node(0) == {"pk": recurring_reservation_1.pk}
+ assert response.node(1) == {"pk": recurring_reservation_2.pk}
+
+
+def test_recurring_reservations__filter__by_unit(graphql):
+ recurring_reservation = RecurringReservationFactory.create()
+ RecurringReservationFactory.create()
+ graphql.login_with_superuser()
+
+ query = recurring_reservations_query(unit=recurring_reservation.reservation_unit.unit.pk)
+ response = graphql(query)
+
+ assert response.has_errors is False
+
+ assert len(response.edges) == 1
+ assert response.node(0) == {"pk": recurring_reservation.pk}
+
+
+def test_recurring_reservations__filter__by_unit__multiple(graphql):
+ recurring_reservation_1 = RecurringReservationFactory.create(name="1")
+ recurring_reservation_2 = RecurringReservationFactory.create(name="2")
+ graphql.login_with_superuser()
+
+ query = recurring_reservations_query(
+ unit=[recurring_reservation_1.reservation_unit.unit.pk, recurring_reservation_2.reservation_unit.unit.pk],
+ )
+ response = graphql(query)
+
+ assert response.has_errors is False
+
+ assert len(response.edges) == 2
+ assert response.node(0) == {"pk": recurring_reservation_1.pk}
+ assert response.node(1) == {"pk": recurring_reservation_2.pk}
+
+
+def test_recurring_reservations__filter__by_reservation_unit_type(graphql):
+ recurring_reservation = RecurringReservationFactory.create(reservation_unit__reservation_unit_type__name="foo")
+ RecurringReservationFactory.create(reservation_unit__reservation_unit_type__name="bar")
+ graphql.login_with_superuser()
+
+ query = recurring_reservations_query(
+ reservation_unit_type=recurring_reservation.reservation_unit.reservation_unit_type.pk,
+ )
+ response = graphql(query)
+
+ assert response.has_errors is False
+
+ assert len(response.edges) == 1
+ assert response.node(0) == {"pk": recurring_reservation.pk}
+
+
+def test_recurring_reservations__filter__by_reservation_unit_type__multiple(graphql):
+ recurring_reservation_1 = RecurringReservationFactory.create(
+ name="1",
+ reservation_unit__reservation_unit_type__name="foo",
+ )
+ recurring_reservation_2 = RecurringReservationFactory.create(
+ name="2",
+ reservation_unit__reservation_unit_type__name="bar",
+ )
+ graphql.login_with_superuser()
+
+ query = recurring_reservations_query(
+ reservation_unit_type=[
+ recurring_reservation_1.reservation_unit.reservation_unit_type.pk,
+ recurring_reservation_2.reservation_unit.reservation_unit_type.pk,
+ ],
+ )
+ response = graphql(query)
+
+ assert response.has_errors is False
+
+ assert len(response.edges) == 2
+ assert response.node(0) == {"pk": recurring_reservation_1.pk}
+ assert response.node(1) == {"pk": recurring_reservation_2.pk}
diff --git a/backend/tests/test_graphql_api/test_recurring_reservation/test_ordering.py b/backend/tests/test_graphql_api/test_recurring_reservation/test_ordering.py
new file mode 100644
index 0000000000..25874f4841
--- /dev/null
+++ b/backend/tests/test_graphql_api/test_recurring_reservation/test_ordering.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+import freezegun
+import pytest
+
+from tests.factories import RecurringReservationFactory
+
+from .helpers import recurring_reservations_query
+
+# Applied to all tests
+pytestmark = [
+ pytest.mark.django_db,
+]
+
+
+@pytest.mark.parametrize(
+ "field",
+ [
+ "reservationUnitNameFiAsc",
+ "reservationUnitNameEnAsc",
+ "reservationUnitNameSvAsc",
+ ],
+)
+def test_recurring_reservations__order__by_reservation_unit_name(graphql, field):
+ recurring_reservation_1 = RecurringReservationFactory.create(
+ reservation_unit__name_fi="1",
+ reservation_unit__name_en="3",
+ reservation_unit__name_sv="2",
+ )
+ recurring_reservation_2 = RecurringReservationFactory.create(
+ reservation_unit__name_fi="4",
+ reservation_unit__name_en="6",
+ reservation_unit__name_sv="5",
+ )
+ graphql.login_with_superuser()
+
+ query = recurring_reservations_query(order_by=field)
+ response = graphql(query)
+
+ assert response.has_errors is False
+
+ assert len(response.edges) == 2
+ assert response.node(0) == {"pk": recurring_reservation_1.pk}
+ assert response.node(1) == {"pk": recurring_reservation_2.pk}
+
+
+@pytest.mark.parametrize(
+ "field",
+ [
+ "unitNameFiAsc",
+ "unitNameEnAsc",
+ "unitNameSvAsc",
+ ],
+)
+def test_recurring_reservations__order__by_unit_name(graphql, field):
+ recurring_reservation_1 = RecurringReservationFactory.create(
+ reservation_unit__unit__name_fi="1",
+ reservation_unit__unit__name_en="3",
+ reservation_unit__unit__name_sv="2",
+ )
+ recurring_reservation_2 = RecurringReservationFactory.create(
+ reservation_unit__unit__name_fi="4",
+ reservation_unit__unit__name_en="6",
+ reservation_unit__unit__name_sv="5",
+ )
+ graphql.login_with_superuser()
+
+ query = recurring_reservations_query(order_by=field)
+ response = graphql(query)
+
+ assert response.has_errors is False
+
+ assert len(response.edges) == 2
+ assert response.node(0) == {"pk": recurring_reservation_1.pk}
+ assert response.node(1) == {"pk": recurring_reservation_2.pk}
+
+
+def test_recurring_reservations__order__by_crated(graphql):
+ with freezegun.freeze_time("2023-01-02T12:00:00Z") as frozen_time:
+ recurring_reservation_1 = RecurringReservationFactory.create()
+ frozen_time.move_to("2023-01-03T12:00:00Z")
+ recurring_reservation_2 = RecurringReservationFactory.create()
+ frozen_time.move_to("2023-01-01T12:00:00Z")
+ recurring_reservation_3 = RecurringReservationFactory.create()
+
+ graphql.login_with_superuser()
+
+ query = recurring_reservations_query(order_by="createdAsc")
+ response = graphql(query)
+
+ assert response.has_errors is False
+
+ assert len(response.edges) == 3
+ assert response.node(0) == {"pk": recurring_reservation_3.pk}
+ assert response.node(1) == {"pk": recurring_reservation_1.pk}
+ assert response.node(2) == {"pk": recurring_reservation_2.pk}
diff --git a/backend/tests/test_graphql_api/test_recurring_reservation/test_query.py b/backend/tests/test_graphql_api/test_recurring_reservation/test_query.py
index c87f0877a1..006e8c6e1b 100644
--- a/backend/tests/test_graphql_api/test_recurring_reservation/test_query.py
+++ b/backend/tests/test_graphql_api/test_recurring_reservation/test_query.py
@@ -1,13 +1,40 @@
from __future__ import annotations
-import freezegun
+from typing import TYPE_CHECKING
+
import pytest
+from freezegun import freeze_time
+from graphql_relay import to_global_id
+
+from tilavarauspalvelu.enums import (
+ AccessType,
+ RejectionReadinessChoice,
+ ReservationStateChoice,
+ ReservationTypeChoice,
+ Weekday,
+)
+from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
+from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraAPIError
+from tilavarauspalvelu.integrations.keyless_entry.typing import (
+ PindoraReservationSeriesAccessCodeValidity,
+ PindoraReservationSeriesResponse,
+ PindoraSeasonalBookingAccessCodeValidity,
+ PindoraSeasonalBookingResponse,
+)
+from utils.date_utils import local_datetime
-from tilavarauspalvelu.enums import RejectionReadinessChoice, Weekday
+from tests.factories import (
+ ApplicationSectionFactory,
+ RecurringReservationFactory,
+ ReservationFactory,
+ ReservationUnitFactory,
+)
+from tests.helpers import patch_method
-from tests.factories import RecurringReservationFactory
+from .helpers import recurring_reservation_query, recurring_reservations_query
-from .helpers import recurring_reservations_query
+if TYPE_CHECKING:
+ from tilavarauspalvelu.models import RecurringReservation
# Applied to all tests
pytestmark = [
@@ -16,14 +43,7 @@
def test_recurring_reservations__query(graphql):
- recurring_reservation = RecurringReservationFactory.create(
- reservations__name="foo",
- age_group__minimum=18,
- age_group__maximum=30,
- ability_group__name="foo",
- rejected_occurrences__rejection_reason=RejectionReadinessChoice.INTERVAL_NOT_ALLOWED,
- allocated_time_slot__day_of_the_week=Weekday.MONDAY,
- )
+ recurring_reservation = RecurringReservationFactory.create()
graphql.login_with_superuser()
fields = """
@@ -38,6 +58,45 @@ def test_recurring_reservations__query(graphql):
recurrenceInDays
weekdays
created
+ shouldHaveActiveAccessCode
+ accessType
+ """
+ query = recurring_reservations_query(fields=fields)
+ response = graphql(query)
+
+ assert response.has_errors is False
+
+ assert len(response.edges) == 1
+ assert response.node(0) == {
+ "pk": recurring_reservation.pk,
+ "extUuid": str(recurring_reservation.ext_uuid),
+ "name": recurring_reservation.name,
+ "description": recurring_reservation.description,
+ "beginDate": recurring_reservation.begin_date.isoformat(),
+ "endDate": recurring_reservation.end_date.isoformat(),
+ "beginTime": recurring_reservation.begin_time.isoformat(),
+ "endTime": recurring_reservation.end_time.isoformat(),
+ "recurrenceInDays": recurring_reservation.recurrence_in_days,
+ "weekdays": [0],
+ "created": recurring_reservation.created.isoformat(),
+ "shouldHaveActiveAccessCode": False,
+ "accessType": AccessType.UNRESTRICTED.value,
+ }
+
+
+def test_recurring_reservations__query__relations(graphql):
+ recurring_reservation = RecurringReservationFactory.create(
+ reservations__name="foo",
+ age_group__minimum=18,
+ age_group__maximum=30,
+ ability_group__name="foo",
+ rejected_occurrences__rejection_reason=RejectionReadinessChoice.INTERVAL_NOT_ALLOWED,
+ allocated_time_slot__day_of_the_week=Weekday.MONDAY,
+ )
+ graphql.login_with_superuser()
+
+ fields = """
+ pk
user {
email
}
@@ -79,16 +138,6 @@ def test_recurring_reservations__query(graphql):
assert len(response.edges) == 1
assert response.node(0) == {
"pk": recurring_reservation.pk,
- "extUuid": str(recurring_reservation.ext_uuid),
- "name": recurring_reservation.name,
- "description": recurring_reservation.description,
- "beginDate": recurring_reservation.begin_date.isoformat(),
- "endDate": recurring_reservation.end_date.isoformat(),
- "beginTime": recurring_reservation.begin_time.isoformat(),
- "endTime": recurring_reservation.end_time.isoformat(),
- "recurrenceInDays": recurring_reservation.recurrence_in_days,
- "weekdays": [0],
- "created": recurring_reservation.created.isoformat(),
"user": {
"email": recurring_reservation.user.email,
},
@@ -125,267 +174,317 @@ def test_recurring_reservations__query(graphql):
}
-def test_recurring_reservations__filter__by_user(graphql):
- recurring_reservation = RecurringReservationFactory.create()
- RecurringReservationFactory.create()
- graphql.login_with_superuser()
-
- query = recurring_reservations_query(user=recurring_reservation.user.pk)
- response = graphql(query)
+def pindora_response(series: RecurringReservation) -> PindoraReservationSeriesResponse:
+ return PindoraReservationSeriesResponse(
+ reservation_unit_id=series.reservation_unit.uuid,
+ access_code="1234",
+ access_code_generated_at=local_datetime(2022, 1, 1, 12),
+ access_code_is_active=True,
+ access_code_keypad_url="https://keypad.url",
+ access_code_phone_number="123456789",
+ access_code_sms_number="123456789",
+ access_code_sms_message="123456789",
+ reservation_unit_code_validity=[
+ PindoraReservationSeriesAccessCodeValidity(
+ access_code_valid_minutes_before=10,
+ access_code_valid_minutes_after=5,
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ )
+ ],
+ )
- assert response.has_errors is False
- assert len(response.edges) == 1
- assert response.node(0) == {"pk": recurring_reservation.pk}
+def pindora_query(series: RecurringReservation) -> str:
+ fields = """
+ pindoraInfo {
+ accessCode
+ accessCodeGeneratedAt
+ accessCodeIsActive
+ accessCodeKeypadUrl
+ accessCodePhoneNumber
+ accessCodeSmsNumber
+ accessCodeSmsMessage
+ accessCodeValidity {
+ reservationId
+ reservationSeriesId
+ accessCodeBeginsAt
+ accessCodeEndsAt
+ }
+ }
+ """
+ global_id = to_global_id("RecurringReservationNode", series.pk)
+ return recurring_reservation_query(fields=fields, id=global_id)
-@pytest.mark.parametrize(
- ("field", "value"),
- [
- ("reservationUnitNameFi", "FI"),
- ("reservationUnitNameEn", "EN"),
- ("reservationUnitNameSv", "SV"),
- ],
-)
-def test_recurring_reservations__filter__by_reservation_unit_name(graphql, field, value):
- recurring_reservation = RecurringReservationFactory.create(
- name="1",
- reservation_unit__name_fi="FI",
- reservation_unit__name_en="EN",
- reservation_unit__name_sv="SV",
+@freeze_time(local_datetime(2022, 1, 1))
+def test_recurring_reservations__query__pindora_info(graphql):
+ series = RecurringReservationFactory.create(
+ begin=local_datetime(2022, 1, 1, 10),
+ end=local_datetime(2022, 1, 1, 12),
)
- RecurringReservationFactory.create(
- name="2",
- reservation_unit__name_fi="foo",
- reservation_unit__name_en="bar",
- reservation_unit__name_sv="baz",
+ reservation = ReservationFactory.create(
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ recurring_reservation=series,
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
)
+
graphql.login_with_superuser()
- query = recurring_reservations_query(**{field: value})
- response = graphql(query)
+ query = pindora_query(series)
- assert response.has_errors is False
+ with patch_method(PindoraClient.get_reservation_series, return_value=pindora_response(series)):
+ response = graphql(query)
- assert len(response.edges) == 1
- assert response.node(0) == {"pk": recurring_reservation.pk}
+ assert response.has_errors is False, response
+
+ assert response.first_query_object["pindoraInfo"] == {
+ "accessCode": "1234",
+ "accessCodeGeneratedAt": "2022-01-01T12:00:00+02:00",
+ "accessCodeIsActive": True,
+ "accessCodeKeypadUrl": "https://keypad.url",
+ "accessCodePhoneNumber": "123456789",
+ "accessCodeSmsMessage": "123456789",
+ "accessCodeSmsNumber": "123456789",
+ "accessCodeValidity": [
+ {
+ "reservationId": reservation.pk,
+ "reservationSeriesId": series.pk,
+ "accessCodeBeginsAt": "2022-01-01T11:50:00+02:00",
+ "accessCodeEndsAt": "2022-01-01T13:05:00+02:00",
+ }
+ ],
+ }
-@pytest.mark.parametrize(
- ("field", "value"),
- [
- ("reservationUnitNameFi", "FI, foo"),
- ("reservationUnitNameEn", "EN, bar"),
- ("reservationUnitNameSv", "SV, baz"),
- ],
-)
-def test_recurring_reservations__filter__by_reservation_unit_name__multiple(graphql, field, value):
- recurring_reservation_1 = RecurringReservationFactory.create(
- name="1",
- reservation_unit__name_fi="FI",
- reservation_unit__name_en="EN",
- reservation_unit__name_sv="SV",
+@freeze_time(local_datetime(2022, 1, 1))
+@pytest.mark.parametrize("as_reservee", [True, False])
+def test_recurring_reservations__query__pindora_info__access_code_not_active(graphql, as_reservee):
+ series = RecurringReservationFactory.create(
+ begin=local_datetime(2022, 1, 1, 10),
+ end=local_datetime(2022, 1, 1, 12),
)
- recurring_reservation_2 = RecurringReservationFactory.create(
- name="2",
- reservation_unit__name_fi="foo",
- reservation_unit__name_en="bar",
- reservation_unit__name_sv="baz",
+ ReservationFactory.create(
+ recurring_reservation=series,
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
)
- graphql.login_with_superuser()
- query = recurring_reservations_query(**{field: value})
- response = graphql(query)
+ if as_reservee:
+ graphql.force_login(series.user)
+ else:
+ graphql.login_with_superuser()
- assert response.has_errors is False
+ query = pindora_query(series)
- assert len(response.edges) == 2
- assert response.node(0) == {"pk": recurring_reservation_1.pk}
- assert response.node(1) == {"pk": recurring_reservation_2.pk}
+ response = pindora_response(series)
+ response["access_code_is_active"] = False
+ with patch_method(PindoraClient.get_reservation_series, return_value=response):
+ response = graphql(query)
-def test_recurring_reservations__filter__by_reservation_unit(graphql):
- recurring_reservation = RecurringReservationFactory.create()
- RecurringReservationFactory.create()
- graphql.login_with_superuser()
+ assert response.has_errors is False, response
- query = recurring_reservations_query(reservationUnit=recurring_reservation.reservation_unit.pk)
- response = graphql(query)
-
- assert response.has_errors is False
+ if as_reservee:
+ assert response.first_query_object["pindoraInfo"] is None
+ else:
+ assert response.first_query_object["pindoraInfo"] is not None
- assert len(response.edges) == 1
- assert response.node(0) == {"pk": recurring_reservation.pk}
-
-
-def test_recurring_reservations__filter__by_reservation_unit__multiple(graphql):
- recurring_reservation_1 = RecurringReservationFactory.create(name="1")
- recurring_reservation_2 = RecurringReservationFactory.create(name="2")
- graphql.login_with_superuser()
- query = recurring_reservations_query(
- reservationUnit=[recurring_reservation_1.reservation_unit.pk, recurring_reservation_2.reservation_unit.pk],
+@freeze_time(local_datetime(2022, 1, 1))
+def test_recurring_reservations__query__pindora_info__access_type_not_access_code(graphql):
+ series = RecurringReservationFactory.create(
+ begin=local_datetime(2022, 1, 1, 10),
+ end=local_datetime(2022, 1, 1, 12),
+ )
+ ReservationFactory.create(
+ recurring_reservation=series,
+ access_type=AccessType.PHYSICAL_KEY,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
)
- response = graphql(query)
-
- assert response.has_errors is False
-
- assert len(response.edges) == 2
- assert response.node(0) == {"pk": recurring_reservation_1.pk}
- assert response.node(1) == {"pk": recurring_reservation_2.pk}
-
-def test_recurring_reservations__filter__by_unit(graphql):
- recurring_reservation = RecurringReservationFactory.create()
- RecurringReservationFactory.create()
graphql.login_with_superuser()
- query = recurring_reservations_query(unit=recurring_reservation.reservation_unit.unit.pk)
- response = graphql(query)
+ query = pindora_query(series)
- assert response.has_errors is False
+ with patch_method(PindoraClient.get_reservation_series, return_value=pindora_response(series)):
+ response = graphql(query)
- assert len(response.edges) == 1
- assert response.node(0) == {"pk": recurring_reservation.pk}
+ assert response.has_errors is False, response
+ assert response.first_query_object["pindoraInfo"] is None
-def test_recurring_reservations__filter__by_unit__multiple(graphql):
- recurring_reservation_1 = RecurringReservationFactory.create(name="1")
- recurring_reservation_2 = RecurringReservationFactory.create(name="2")
- graphql.login_with_superuser()
- query = recurring_reservations_query(
- unit=[recurring_reservation_1.reservation_unit.unit.pk, recurring_reservation_2.reservation_unit.unit.pk],
+@freeze_time(local_datetime(2022, 1, 1))
+def test_recurring_reservations__query__pindora_info__pindora_call_fails(graphql):
+ series = RecurringReservationFactory.create(
+ begin=local_datetime(2022, 1, 1, 10),
+ end=local_datetime(2022, 1, 1, 12),
+ )
+ ReservationFactory.create(
+ recurring_reservation=series,
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
)
- response = graphql(query)
-
- assert response.has_errors is False
-
- assert len(response.edges) == 2
- assert response.node(0) == {"pk": recurring_reservation_1.pk}
- assert response.node(1) == {"pk": recurring_reservation_2.pk}
-
-def test_recurring_reservations__filter__by_reservation_unit_type(graphql):
- recurring_reservation = RecurringReservationFactory.create(reservation_unit__reservation_unit_type__name="foo")
- RecurringReservationFactory.create(reservation_unit__reservation_unit_type__name="bar")
graphql.login_with_superuser()
- query = recurring_reservations_query(
- reservation_unit_type=recurring_reservation.reservation_unit.reservation_unit_type.pk,
- )
- response = graphql(query)
+ query = pindora_query(series)
- assert response.has_errors is False
+ with patch_method(PindoraClient.get_reservation_series, side_effect=PindoraAPIError("Error")):
+ response = graphql(query)
- assert len(response.edges) == 1
- assert response.node(0) == {"pk": recurring_reservation.pk}
+ assert response.has_errors is False, response
+ assert response.first_query_object["pindoraInfo"] is None
-def test_recurring_reservations__filter__by_reservation_unit_type__multiple(graphql):
- recurring_reservation_1 = RecurringReservationFactory.create(
- name="1",
- reservation_unit__reservation_unit_type__name="foo",
+
+@freeze_time(local_datetime(2022, 1, 3))
+def test_recurring_reservations__query__pindora_info__reservation_past(graphql):
+ series = RecurringReservationFactory.create(
+ begin=local_datetime(2022, 1, 1, 10),
+ end=local_datetime(2022, 1, 1, 12),
)
- recurring_reservation_2 = RecurringReservationFactory.create(
- name="2",
- reservation_unit__reservation_unit_type__name="bar",
+ ReservationFactory.create(
+ recurring_reservation=series,
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
)
+
graphql.login_with_superuser()
- query = recurring_reservations_query(
- reservation_unit_type=[
- recurring_reservation_1.reservation_unit.reservation_unit_type.pk,
- recurring_reservation_2.reservation_unit.reservation_unit_type.pk,
- ],
- )
- response = graphql(query)
+ query = pindora_query(series)
- assert response.has_errors is False
+ with patch_method(PindoraClient.get_reservation_series, return_value=pindora_response(series)):
+ response = graphql(query)
- assert len(response.edges) == 2
- assert response.node(0) == {"pk": recurring_reservation_1.pk}
- assert response.node(1) == {"pk": recurring_reservation_2.pk}
+ assert response.has_errors is False, response
+ assert response.first_query_object["pindoraInfo"] is None
-@pytest.mark.parametrize(
- "field",
- [
- "reservationUnitNameFiAsc",
- "reservationUnitNameEnAsc",
- "reservationUnitNameSvAsc",
- ],
-)
-def test_recurring_reservations__order__by_reservation_unit_name(graphql, field):
- recurring_reservation_1 = RecurringReservationFactory.create(
- reservation_unit__name_fi="1",
- reservation_unit__name_en="3",
- reservation_unit__name_sv="2",
+
+@freeze_time(local_datetime(2022, 1, 1))
+def test_recurring_reservations__query__pindora_info__in_application_section(graphql):
+ section = ApplicationSectionFactory.create(
+ application__application_round__sent_date=local_datetime(2022, 1, 1),
)
- recurring_reservation_2 = RecurringReservationFactory.create(
- reservation_unit__name_fi="4",
- reservation_unit__name_en="6",
- reservation_unit__name_sv="5",
+ reservation_unit = ReservationUnitFactory.create()
+ series = RecurringReservationFactory.create(
+ begin=local_datetime(2022, 1, 1, 10),
+ end=local_datetime(2022, 1, 1, 12),
+ allocated_time_slot__reservation_unit_option__application_section=section,
+ reservation_unit=reservation_unit,
)
- graphql.login_with_superuser()
-
- query = recurring_reservations_query(order_by=field)
- response = graphql(query)
-
- assert response.has_errors is False
-
- assert len(response.edges) == 2
- assert response.node(0) == {"pk": recurring_reservation_1.pk}
- assert response.node(1) == {"pk": recurring_reservation_2.pk}
-
-
-@pytest.mark.parametrize(
- "field",
- [
- "unitNameFiAsc",
- "unitNameEnAsc",
- "unitNameSvAsc",
- ],
-)
-def test_recurring_reservations__order__by_unit_name(graphql, field):
- recurring_reservation_1 = RecurringReservationFactory.create(
- reservation_unit__unit__name_fi="1",
- reservation_unit__unit__name_en="3",
- reservation_unit__unit__name_sv="2",
+ reservation = ReservationFactory.create(
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ recurring_reservation=series,
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
)
- recurring_reservation_2 = RecurringReservationFactory.create(
- reservation_unit__unit__name_fi="4",
- reservation_unit__unit__name_en="6",
- reservation_unit__unit__name_sv="5",
+
+ graphql.force_login(series.user)
+
+ query = pindora_query(series)
+
+ response = PindoraSeasonalBookingResponse(
+ access_code="12345",
+ access_code_keypad_url="https://keypad.test.ovaa.fi/hel/list/kannelmaen_leikkipuisto",
+ access_code_phone_number="+358407089833",
+ access_code_sms_number="+358407089834",
+ access_code_sms_message="a12345",
+ access_code_generated_at=local_datetime(2022, 1, 1),
+ access_code_is_active=True,
+ reservation_unit_code_validity=[
+ PindoraSeasonalBookingAccessCodeValidity(
+ reservation_unit_id=reservation_unit.uuid,
+ access_code_valid_minutes_before=10,
+ access_code_valid_minutes_after=5,
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ ),
+ ],
)
- graphql.login_with_superuser()
- query = recurring_reservations_query(order_by=field)
- response = graphql(query)
+ with patch_method(PindoraClient.get_seasonal_booking, return_value=response):
+ response = graphql(query)
- assert response.has_errors is False
+ assert response.has_errors is False, response.errors
- assert len(response.edges) == 2
- assert response.node(0) == {"pk": recurring_reservation_1.pk}
- assert response.node(1) == {"pk": recurring_reservation_2.pk}
+ assert response.first_query_object["pindoraInfo"] == {
+ "accessCode": "12345",
+ "accessCodeGeneratedAt": "2022-01-01T00:00:00+02:00",
+ "accessCodeIsActive": True,
+ "accessCodeKeypadUrl": "https://keypad.test.ovaa.fi/hel/list/kannelmaen_leikkipuisto",
+ "accessCodePhoneNumber": "+358407089833",
+ "accessCodeSmsMessage": "a12345",
+ "accessCodeSmsNumber": "+358407089834",
+ "accessCodeValidity": [
+ {
+ "reservationId": reservation.pk,
+ "reservationSeriesId": series.pk,
+ "accessCodeBeginsAt": "2022-01-01T11:50:00+02:00",
+ "accessCodeEndsAt": "2022-01-01T13:05:00+02:00",
+ }
+ ],
+ }
-def test_recurring_reservations__order__by_crated(graphql):
- with freezegun.freeze_time("2023-01-02T12:00:00Z") as frozen_time:
- recurring_reservation_1 = RecurringReservationFactory.create()
- frozen_time.move_to("2023-01-03T12:00:00Z")
- recurring_reservation_2 = RecurringReservationFactory.create()
- frozen_time.move_to("2023-01-01T12:00:00Z")
- recurring_reservation_3 = RecurringReservationFactory.create()
+@freeze_time(local_datetime(2022, 1, 1))
+def test_recurring_reservations__query__pindora_info__in_application_section__not_sent(graphql):
+ section = ApplicationSectionFactory.create(
+ application__application_round__sent_date=None,
+ )
+ reservation_unit = ReservationUnitFactory.create()
+ series = RecurringReservationFactory.create(
+ begin=local_datetime(2022, 1, 1, 10),
+ end=local_datetime(2022, 1, 1, 12),
+ allocated_time_slot__reservation_unit_option__application_section=section,
+ reservation_unit=reservation_unit,
+ )
+ ReservationFactory.create(
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ recurring_reservation=series,
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ )
- graphql.login_with_superuser()
+ graphql.force_login(series.user)
+
+ query = pindora_query(series)
+
+ response = PindoraSeasonalBookingResponse(
+ access_code="12345",
+ access_code_keypad_url="https://keypad.test.ovaa.fi/hel/list/kannelmaen_leikkipuisto",
+ access_code_phone_number="+358407089833",
+ access_code_sms_number="+358407089834",
+ access_code_sms_message="a12345",
+ access_code_generated_at=local_datetime(2022, 1, 1),
+ access_code_is_active=True,
+ reservation_unit_code_validity=[
+ PindoraSeasonalBookingAccessCodeValidity(
+ reservation_unit_id=reservation_unit.uuid,
+ access_code_valid_minutes_before=10,
+ access_code_valid_minutes_after=5,
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ ),
+ ],
+ )
- query = recurring_reservations_query(order_by="createdAsc")
- response = graphql(query)
+ with patch_method(PindoraClient.get_seasonal_booking, return_value=response):
+ response = graphql(query)
- assert response.has_errors is False
+ assert response.has_errors is False, response.errors
- assert len(response.edges) == 3
- assert response.node(0) == {"pk": recurring_reservation_3.pk}
- assert response.node(1) == {"pk": recurring_reservation_1.pk}
- assert response.node(2) == {"pk": recurring_reservation_2.pk}
+ assert response.first_query_object["pindoraInfo"] is None
diff --git a/backend/tests/test_graphql_api/test_reservation/test_adjust_time.py b/backend/tests/test_graphql_api/test_reservation/test_adjust_time.py
index 89c3203b8d..2eea36cde8 100644
--- a/backend/tests/test_graphql_api/test_reservation/test_adjust_time.py
+++ b/backend/tests/test_graphql_api/test_reservation/test_adjust_time.py
@@ -509,7 +509,7 @@ def test_reservation__adjust_time__update_reservation_buffer_on_adjust(graphql):
def test_reservation__adjust_time__same_access_type(graphql):
reservation = ReservationFactory.create_for_time_adjustment(
access_type=AccessType.ACCESS_CODE,
- reservation_units__access_type=AccessType.ACCESS_CODE,
+ reservation_units__access_types__access_type=AccessType.ACCESS_CODE,
access_code_is_active=True,
)
@@ -531,7 +531,7 @@ def test_reservation__adjust_time__same_access_type(graphql):
def test_reservation__adjust_time__same_access_type__requires_handling(graphql):
reservation = ReservationFactory.create_for_time_adjustment(
access_type=AccessType.ACCESS_CODE,
- reservation_units__access_type=AccessType.ACCESS_CODE,
+ reservation_units__access_types__access_type=AccessType.ACCESS_CODE,
reservation_units__require_reservation_handling=True,
access_code_is_active=True,
)
@@ -559,7 +559,7 @@ def test_reservation__adjust_time__same_access_type__requires_handling(graphql):
def test_reservation__adjust_time__change_to_access_code(graphql):
reservation = ReservationFactory.create_for_time_adjustment(
access_type=AccessType.UNRESTRICTED,
- reservation_units__access_type=AccessType.ACCESS_CODE,
+ reservation_units__access_types__access_type=AccessType.ACCESS_CODE,
access_code_is_active=False,
)
@@ -587,7 +587,7 @@ def test_reservation__adjust_time__change_to_access_code(graphql):
def test_reservation__adjust_time__change_to_access_code__requires_handling(graphql):
reservation = ReservationFactory.create_for_time_adjustment(
access_type=AccessType.UNRESTRICTED,
- reservation_units__access_type=AccessType.ACCESS_CODE,
+ reservation_units__access_types__access_type=AccessType.ACCESS_CODE,
reservation_units__require_reservation_handling=True,
access_code_is_active=False,
)
@@ -610,7 +610,7 @@ def test_reservation__adjust_time__change_to_access_code__requires_handling(grap
def test_reservation__adjust_time__change_from_access_code(graphql):
reservation = ReservationFactory.create_for_time_adjustment(
access_type=AccessType.ACCESS_CODE,
- reservation_units__access_type=AccessType.UNRESTRICTED,
+ reservation_units__access_types__access_type=AccessType.UNRESTRICTED,
access_code_generated_at=datetime.datetime(2025, 1, 1, tzinfo=DEFAULT_TIMEZONE),
access_code_is_active=True,
)
diff --git a/backend/tests/test_graphql_api/test_reservation/test_approve.py b/backend/tests/test_graphql_api/test_reservation/test_approve.py
index 38bae67de4..e4faf8d35b 100644
--- a/backend/tests/test_graphql_api/test_reservation/test_approve.py
+++ b/backend/tests/test_graphql_api/test_reservation/test_approve.py
@@ -115,7 +115,7 @@ def test_reservation__approve__succeeds_with_empty_handling_details(graphql):
@patch_method(PindoraClient.create_reservation)
def test_reservation__approve__succeeds__pindora_api__call_succeeds(graphql):
reservation = ReservationFactory.create(
- reservation_units__access_type=AccessType.ACCESS_CODE,
+ reservation_units__access_types__access_type=AccessType.ACCESS_CODE,
state=ReservationStateChoice.REQUIRES_HANDLING,
access_type=AccessType.ACCESS_CODE,
access_code_is_active=False,
@@ -140,7 +140,7 @@ def test_reservation__approve__succeeds__pindora_api__call_succeeds(graphql):
@patch_method(PindoraClient.create_reservation)
def test_reservation__approve__succeeds__pindora_api__call_fails(graphql):
reservation = ReservationFactory.create(
- reservation_units__access_type=AccessType.ACCESS_CODE,
+ reservation_units__access_types__access_type=AccessType.ACCESS_CODE,
state=ReservationStateChoice.REQUIRES_HANDLING,
access_type=AccessType.ACCESS_CODE,
access_code_is_active=False,
@@ -172,7 +172,7 @@ def test_reservation__approve__succeeds__pindora_api__call_fails(graphql):
)
def test_reservation__approve__succeeds__pindora_api__create_if_not_generated(graphql):
reservation = ReservationFactory.create(
- reservation_units__access_type=AccessType.ACCESS_CODE,
+ reservation_units__access_types__access_type=AccessType.ACCESS_CODE,
state=ReservationStateChoice.REQUIRES_HANDLING,
access_type=AccessType.ACCESS_CODE,
access_code_is_active=False,
diff --git a/backend/tests/test_graphql_api/test_reservation/test_create.py b/backend/tests/test_graphql_api/test_reservation/test_create.py
index 77256f3428..928d62cfe1 100644
--- a/backend/tests/test_graphql_api/test_reservation/test_create.py
+++ b/backend/tests/test_graphql_api/test_reservation/test_create.py
@@ -1117,7 +1117,9 @@ def test_reservation__create__require_adult_reservee__no_id_token(graphql):
},
)
def test_reservation__create__access_type__access_code(graphql):
- reservation_unit = ReservationUnitFactory.create_reservable_now(access_type=AccessType.ACCESS_CODE)
+ reservation_unit = ReservationUnitFactory.create_reservable_now(
+ access_types__access_type=AccessType.ACCESS_CODE,
+ )
graphql.login_with_regular_user()
@@ -1140,33 +1142,8 @@ def test_reservation__create__access_type__changes_to_access_code_in_the_future(
today = local_date()
reservation_unit = ReservationUnitFactory.create_reservable_now(
- access_type=AccessType.ACCESS_CODE,
- access_type_start_date=today + datetime.timedelta(days=1),
- )
-
- graphql.login_with_regular_user()
-
- data = get_create_data(reservation_unit)
- response = graphql(CREATE_MUTATION, input_data=data)
-
- assert response.has_errors is False, response.errors
-
- reservation: Reservation = Reservation.objects.get(pk=response.first_query_object["pk"])
-
- assert reservation.access_type == AccessType.UNRESTRICTED
- assert reservation.access_code_generated_at is None
- assert reservation.access_code_is_active is False
-
- assert PindoraClient.create_reservation.call_count == 0
-
-
-@patch_method(PindoraClient.create_reservation)
-def test_reservation__create__access_type__access_code_has_ended(graphql):
- today = local_date()
-
- reservation_unit = ReservationUnitFactory.create_reservable_now(
- access_type=AccessType.ACCESS_CODE,
- access_type_end_date=today - datetime.timedelta(days=1),
+ access_types__access_type=AccessType.ACCESS_CODE,
+ access_types__begin_date=today + datetime.timedelta(days=1),
)
graphql.login_with_regular_user()
@@ -1187,7 +1164,9 @@ def test_reservation__create__access_type__access_code_has_ended(graphql):
@patch_method(PindoraClient.create_reservation, side_effect=PindoraAPIError())
def test_reservation__create__access_type__access_code__no_reservation_on_pindora_failure(graphql):
- reservation_unit = ReservationUnitFactory.create_reservable_now(access_type=AccessType.ACCESS_CODE)
+ reservation_unit = ReservationUnitFactory.create_reservable_now(
+ access_types__access_type=AccessType.ACCESS_CODE,
+ )
graphql.login_with_regular_user()
diff --git a/backend/tests/test_graphql_api/test_reservation/test_query.py b/backend/tests/test_graphql_api/test_reservation/test_query.py
index da4e5b5b3e..f827dd0c06 100644
--- a/backend/tests/test_graphql_api/test_reservation/test_query.py
+++ b/backend/tests/test_graphql_api/test_reservation/test_query.py
@@ -12,12 +12,20 @@
from tilavarauspalvelu.enums import AccessType, CustomerTypeChoice, ReservationStateChoice, ReservationTypeChoice
from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraAPIError
-from tilavarauspalvelu.integrations.keyless_entry.typing import PindoraReservationResponse
+from tilavarauspalvelu.integrations.keyless_entry.typing import (
+ PindoraReservationResponse,
+ PindoraReservationSeriesAccessCodeValidity,
+ PindoraReservationSeriesResponse,
+ PindoraSeasonalBookingAccessCodeValidity,
+ PindoraSeasonalBookingResponse,
+)
from tilavarauspalvelu.models import PersonalInfoViewLog
from utils.date_utils import local_datetime
from tests.factories import (
+ ApplicationSectionFactory,
PaymentOrderFactory,
+ RecurringReservationFactory,
ReservationFactory,
ReservationUnitFactory,
UnitFactory,
@@ -446,15 +454,7 @@ def pindora_response() -> PindoraReservationResponse:
)
-@freeze_time(local_datetime(2022, 1, 1))
-def test_reservation__query__pindora_info(graphql):
- reservation = ReservationFactory.create(
- access_type=AccessType.ACCESS_CODE,
- state=ReservationStateChoice.CONFIRMED,
- begin=local_datetime(2022, 1, 1, 12),
- end=local_datetime(2022, 1, 1, 13),
- )
-
+def pindora_query(reservation: Reservation) -> str:
fields = """
pindoraInfo {
accessCode
@@ -469,7 +469,19 @@ def test_reservation__query__pindora_info(graphql):
}
"""
global_id = to_global_id("ReservationNode", reservation.pk)
- query = reservation_query(fields=fields, id=global_id)
+ return reservation_query(fields=fields, id=global_id)
+
+
+@freeze_time(local_datetime(2022, 1, 1))
+def test_reservation__query__pindora_info(graphql):
+ reservation = ReservationFactory.create(
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ )
+
+ query = pindora_query(reservation)
graphql.force_login(reservation.user)
@@ -501,21 +513,7 @@ def test_reservation__query__pindora_info__access_code_not_active(graphql, as_re
end=local_datetime(2022, 1, 1, 13),
)
- fields = """
- pindoraInfo {
- accessCode
- accessCodeGeneratedAt
- accessCodeIsActive
- accessCodeKeypadUrl
- accessCodePhoneNumber
- accessCodeSmsNumber
- accessCodeSmsMessage
- accessCodeBeginsAt
- accessCodeEndsAt
- }
- """
- global_id = to_global_id("ReservationNode", reservation.pk)
- query = reservation_query(fields=fields, id=global_id)
+ query = pindora_query(reservation)
if as_reservee:
graphql.force_login(reservation.user)
@@ -546,21 +544,7 @@ def test_reservation__query__pindora_info__not_confirmed(graphql, as_reservee):
end=local_datetime(2022, 1, 1, 13),
)
- fields = """
- pindoraInfo {
- accessCode
- accessCodeGeneratedAt
- accessCodeIsActive
- accessCodeKeypadUrl
- accessCodePhoneNumber
- accessCodeSmsNumber
- accessCodeSmsMessage
- accessCodeBeginsAt
- accessCodeEndsAt
- }
- """
- global_id = to_global_id("ReservationNode", reservation.pk)
- query = reservation_query(fields=fields, id=global_id)
+ query = pindora_query(reservation)
if as_reservee:
graphql.force_login(reservation.user)
@@ -587,21 +571,7 @@ def test_reservation__query__pindora_info__access_type_not_access_code(graphql):
end=local_datetime(2022, 1, 1, 13),
)
- fields = """
- pindoraInfo {
- accessCode
- accessCodeGeneratedAt
- accessCodeIsActive
- accessCodeKeypadUrl
- accessCodePhoneNumber
- accessCodeSmsNumber
- accessCodeSmsMessage
- accessCodeBeginsAt
- accessCodeEndsAt
- }
- """
- global_id = to_global_id("ReservationNode", reservation.pk)
- query = reservation_query(fields=fields, id=global_id)
+ query = pindora_query(reservation)
graphql.force_login(reservation.user)
@@ -622,21 +592,7 @@ def test_reservation__query__pindora_info__pindora_call_fails(graphql):
end=local_datetime(2022, 1, 1, 13),
)
- fields = """
- pindoraInfo {
- accessCode
- accessCodeGeneratedAt
- accessCodeIsActive
- accessCodeKeypadUrl
- accessCodePhoneNumber
- accessCodeSmsNumber
- accessCodeSmsMessage
- accessCodeBeginsAt
- accessCodeEndsAt
- }
- """
- global_id = to_global_id("ReservationNode", reservation.pk)
- query = reservation_query(fields=fields, id=global_id)
+ query = pindora_query(reservation)
graphql.force_login(reservation.user)
@@ -657,26 +613,12 @@ def test_reservation__query__pindora_info__pindora_data_cached(graphql):
end=local_datetime(2022, 1, 1, 13),
)
- fields = """
- pindoraInfo {
- accessCode
- accessCodeGeneratedAt
- accessCodeIsActive
- accessCodeKeypadUrl
- accessCodePhoneNumber
- accessCodeSmsNumber
- accessCodeSmsMessage
- accessCodeBeginsAt
- accessCodeEndsAt
- }
- """
- global_id = to_global_id("ReservationNode", reservation.pk)
- query = reservation_query(fields=fields, id=global_id)
+ query = pindora_query(reservation)
graphql.force_login(reservation.user)
data = pindora_response()
- PindoraClient.cache_reservation_response(data=data, ext_uuid=reservation.ext_uuid)
+ PindoraClient._cache_reservation_response(data=data, ext_uuid=reservation.ext_uuid)
with patch_method(PindoraClient.get) as pindora_api:
response = graphql(query)
@@ -708,21 +650,7 @@ def test_reservation__query__pindora_info__reservation_past(graphql):
end=local_datetime(2022, 1, 1, 13),
)
- fields = """
- pindoraInfo {
- accessCode
- accessCodeGeneratedAt
- accessCodeIsActive
- accessCodeKeypadUrl
- accessCodePhoneNumber
- accessCodeSmsNumber
- accessCodeSmsMessage
- accessCodeBeginsAt
- accessCodeEndsAt
- }
- """
- global_id = to_global_id("ReservationNode", reservation.pk)
- query = reservation_query(fields=fields, id=global_id)
+ query = pindora_query(reservation)
graphql.force_login(reservation.user)
@@ -732,3 +660,151 @@ def test_reservation__query__pindora_info__reservation_past(graphql):
assert response.has_errors is False, response
assert response.first_query_object["pindoraInfo"] is None
+
+
+@freeze_time(local_datetime(2022, 1, 1))
+def test_reservation__query__pindora_info__in_recurring_reservation(graphql):
+ series = RecurringReservationFactory.create()
+ reservation = ReservationFactory.create(
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ recurring_reservation=series,
+ )
+
+ query = pindora_query(reservation)
+
+ graphql.force_login(reservation.user)
+
+ response = PindoraReservationSeriesResponse(
+ reservation_unit_id=uuid.uuid4(),
+ access_code="12345",
+ access_code_keypad_url="https://keypad.test.ovaa.fi/hel/list/kannelmaen_leikkipuisto",
+ access_code_phone_number="+358407089833",
+ access_code_sms_number="+358407089834",
+ access_code_sms_message="a12345",
+ access_code_generated_at=local_datetime(2022, 1, 1),
+ access_code_is_active=True,
+ reservation_unit_code_validity=[
+ PindoraReservationSeriesAccessCodeValidity(
+ access_code_valid_minutes_before=10,
+ access_code_valid_minutes_after=5,
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ ),
+ ],
+ )
+
+ with patch_method(PindoraClient.get_reservation_series, return_value=response):
+ response = graphql(query)
+
+ assert response.has_errors is False, response
+
+ assert response.first_query_object["pindoraInfo"] == {
+ "accessCode": "12345",
+ "accessCodeIsActive": True,
+ "accessCodeGeneratedAt": "2022-01-01T00:00:00+02:00",
+ "accessCodeKeypadUrl": "https://keypad.test.ovaa.fi/hel/list/kannelmaen_leikkipuisto",
+ "accessCodePhoneNumber": "+358407089833",
+ "accessCodeSmsMessage": "a12345",
+ "accessCodeSmsNumber": "+358407089834",
+ "accessCodeBeginsAt": "2022-01-01T11:50:00+02:00",
+ "accessCodeEndsAt": "2022-01-01T13:05:00+02:00",
+ }
+
+
+@freeze_time(local_datetime(2022, 1, 1))
+def test_reservation__query__pindora_info__in_application_section(graphql):
+ section = ApplicationSectionFactory.create(
+ application__application_round__sent_date=local_datetime(2022, 1, 1),
+ )
+ reservation = ReservationFactory.create(
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=section,
+ )
+
+ query = pindora_query(reservation)
+
+ graphql.force_login(reservation.user)
+
+ response = PindoraSeasonalBookingResponse(
+ access_code="12345",
+ access_code_keypad_url="https://keypad.test.ovaa.fi/hel/list/kannelmaen_leikkipuisto",
+ access_code_phone_number="+358407089833",
+ access_code_sms_number="+358407089834",
+ access_code_sms_message="a12345",
+ access_code_generated_at=local_datetime(2022, 1, 1),
+ access_code_is_active=True,
+ reservation_unit_code_validity=[
+ PindoraSeasonalBookingAccessCodeValidity(
+ reservation_unit_id=uuid.uuid4(),
+ access_code_valid_minutes_before=10,
+ access_code_valid_minutes_after=5,
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ ),
+ ],
+ )
+
+ with patch_method(PindoraClient.get_seasonal_booking, return_value=response):
+ response = graphql(query)
+
+ assert response.has_errors is False, response
+
+ assert response.first_query_object["pindoraInfo"] == {
+ "accessCode": "12345",
+ "accessCodeIsActive": True,
+ "accessCodeGeneratedAt": "2022-01-01T00:00:00+02:00",
+ "accessCodeKeypadUrl": "https://keypad.test.ovaa.fi/hel/list/kannelmaen_leikkipuisto",
+ "accessCodePhoneNumber": "+358407089833",
+ "accessCodeSmsMessage": "a12345",
+ "accessCodeSmsNumber": "+358407089834",
+ "accessCodeBeginsAt": "2022-01-01T11:50:00+02:00",
+ "accessCodeEndsAt": "2022-01-01T13:05:00+02:00",
+ }
+
+
+@freeze_time(local_datetime(2022, 1, 1))
+def test_reservation__query__pindora_info__in_application_section__not_sent(graphql):
+ section = ApplicationSectionFactory.create(application__application_round__sent_date=None)
+ reservation = ReservationFactory.create(
+ access_type=AccessType.ACCESS_CODE,
+ state=ReservationStateChoice.CONFIRMED,
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=section,
+ )
+
+ query = pindora_query(reservation)
+
+ graphql.force_login(reservation.user)
+
+ response = PindoraSeasonalBookingResponse(
+ access_code="12345",
+ access_code_keypad_url="https://keypad.test.ovaa.fi/hel/list/kannelmaen_leikkipuisto",
+ access_code_phone_number="+358407089833",
+ access_code_sms_number="+358407089834",
+ access_code_sms_message="a12345",
+ access_code_generated_at=local_datetime(2022, 1, 1),
+ access_code_is_active=True,
+ reservation_unit_code_validity=[
+ PindoraSeasonalBookingAccessCodeValidity(
+ reservation_unit_id=uuid.uuid4(),
+ access_code_valid_minutes_before=10,
+ access_code_valid_minutes_after=5,
+ begin=local_datetime(2022, 1, 1, 12),
+ end=local_datetime(2022, 1, 1, 13),
+ ),
+ ],
+ )
+
+ with patch_method(PindoraClient.get_seasonal_booking, return_value=response):
+ response = graphql(query)
+
+ assert response.has_errors is False, response
+
+ assert response.first_query_object["pindoraInfo"] is None
diff --git a/backend/tests/test_graphql_api/test_reservation/test_staff_adjust_time.py b/backend/tests/test_graphql_api/test_reservation/test_staff_adjust_time.py
index 7c4da14196..d0bec0e71f 100644
--- a/backend/tests/test_graphql_api/test_reservation/test_staff_adjust_time.py
+++ b/backend/tests/test_graphql_api/test_reservation/test_staff_adjust_time.py
@@ -541,7 +541,7 @@ def test_reservation__staff_adjust_time__same_access_type(graphql):
reservation = ReservationFactory.create_for_time_adjustment(
type=ReservationTypeChoice.STAFF,
access_type=AccessType.ACCESS_CODE,
- reservation_units__access_type=AccessType.ACCESS_CODE,
+ reservation_units__access_types__access_type=AccessType.ACCESS_CODE,
access_code_is_active=True,
)
@@ -564,7 +564,7 @@ def test_reservation__staff_adjust_time__same_access_type__requires_handling(gra
reservation = ReservationFactory.create_for_time_adjustment(
type=ReservationTypeChoice.STAFF,
access_type=AccessType.ACCESS_CODE,
- reservation_units__access_type=AccessType.ACCESS_CODE,
+ reservation_units__access_types__access_type=AccessType.ACCESS_CODE,
reservation_units__require_reservation_handling=True,
access_code_is_active=True,
)
@@ -593,7 +593,7 @@ def test_reservation__staff_adjust_time__change_to_access_code(graphql):
reservation = ReservationFactory.create_for_time_adjustment(
type=ReservationTypeChoice.STAFF,
access_type=AccessType.UNRESTRICTED,
- reservation_units__access_type=AccessType.ACCESS_CODE,
+ reservation_units__access_types__access_type=AccessType.ACCESS_CODE,
access_code_is_active=False,
)
@@ -622,7 +622,7 @@ def test_reservation__staff_adjust_time__change_to_access_code__requires_handlin
reservation = ReservationFactory.create_for_time_adjustment(
type=ReservationTypeChoice.STAFF,
access_type=AccessType.UNRESTRICTED,
- reservation_units__access_type=AccessType.ACCESS_CODE,
+ reservation_units__access_types__access_type=AccessType.ACCESS_CODE,
reservation_units__require_reservation_handling=True,
access_code_is_active=False,
)
@@ -646,7 +646,7 @@ def test_reservation__staff_adjust_time__change_from_access_code(graphql):
reservation = ReservationFactory.create_for_time_adjustment(
type=ReservationTypeChoice.STAFF,
access_type=AccessType.ACCESS_CODE,
- reservation_units__access_type=AccessType.UNRESTRICTED,
+ reservation_units__access_types__access_type=AccessType.UNRESTRICTED,
access_code_generated_at=datetime.datetime(2025, 1, 1, tzinfo=DEFAULT_TIMEZONE),
access_code_is_active=True,
)
diff --git a/backend/tests/test_graphql_api/test_reservation/test_staff_create.py b/backend/tests/test_graphql_api/test_reservation/test_staff_create.py
index fcd3b858e0..d94eea6641 100644
--- a/backend/tests/test_graphql_api/test_reservation/test_staff_create.py
+++ b/backend/tests/test_graphql_api/test_reservation/test_staff_create.py
@@ -18,6 +18,7 @@
ReservableTimeSpanFactory,
ReservationFactory,
ReservationPurposeFactory,
+ ReservationUnitAccessTypeFactory,
ReservationUnitFactory,
SpaceFactory,
UserFactory,
@@ -493,7 +494,7 @@ def test_reservation__staff_create__reservee_used_ad_login(graphql, amr, expecte
},
)
def test_reservation__staff_create__access_type__access_code(graphql):
- reservation_unit = ReservationUnitFactory.create(access_type=AccessType.ACCESS_CODE)
+ reservation_unit = ReservationUnitFactory.create(access_types__access_type=AccessType.ACCESS_CODE)
graphql.login_with_superuser()
data = get_staff_create_data(reservation_unit)
@@ -515,8 +516,8 @@ def test_reservation__staff_create__access_type__changed_to_access_code_in_the_f
today = local_date()
reservation_unit = ReservationUnitFactory.create(
- access_type=AccessType.ACCESS_CODE,
- access_type_start_date=today + datetime.timedelta(days=1),
+ access_types__access_type=AccessType.ACCESS_CODE,
+ access_types__begin_date=today + datetime.timedelta(days=1),
)
graphql.login_with_superuser()
@@ -538,9 +539,16 @@ def test_reservation__staff_create__access_type__changed_to_access_code_in_the_f
def test_reservation__staff_create__access_type__access_code_has_ended(graphql):
today = local_date()
- reservation_unit = ReservationUnitFactory.create(
+ reservation_unit = ReservationUnitFactory.create()
+ ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
access_type=AccessType.ACCESS_CODE,
- access_type_end_date=today - datetime.timedelta(days=1),
+ begin_date=today - datetime.timedelta(days=10),
+ )
+ ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.UNRESTRICTED,
+ begin_date=today - datetime.timedelta(days=1),
)
graphql.login_with_superuser()
@@ -566,7 +574,9 @@ def test_reservation__staff_create__access_type__access_code_has_ended(graphql):
},
)
def test_reservation__staff_create__access_type__access_code__blocked(graphql):
- reservation_unit = ReservationUnitFactory.create(access_type=AccessType.ACCESS_CODE)
+ reservation_unit = ReservationUnitFactory.create(
+ access_types__access_type=AccessType.ACCESS_CODE,
+ )
graphql.login_with_superuser()
data = get_staff_create_data(reservation_unit, type=ReservationTypeChoice.BLOCKED)
@@ -585,7 +595,9 @@ def test_reservation__staff_create__access_type__access_code__blocked(graphql):
@patch_method(PindoraClient.create_reservation, side_effect=PindoraAPIError())
def test_reservation__staff_create__access_type__access_code__create_reservation_on_pindora_failure(graphql):
- reservation_unit = ReservationUnitFactory.create(access_type=AccessType.ACCESS_CODE)
+ reservation_unit = ReservationUnitFactory.create(
+ access_types__access_type=AccessType.ACCESS_CODE,
+ )
graphql.login_with_superuser()
data = get_staff_create_data(reservation_unit)
diff --git a/backend/tests/test_graphql_api/test_reservation_unit/helpers.py b/backend/tests/test_graphql_api/test_reservation_unit/helpers.py
index 20f20afdc2..42c1a0727a 100644
--- a/backend/tests/test_graphql_api/test_reservation_unit/helpers.py
+++ b/backend/tests/test_graphql_api/test_reservation_unit/helpers.py
@@ -7,8 +7,8 @@
from graphene_django_extensions.testing import build_mutation, build_query
-from tilavarauspalvelu.enums import AuthenticationType, PriceUnit, ReservationKind, ReservationStartInterval
-from utils.date_utils import local_datetime
+from tilavarauspalvelu.enums import AccessType, AuthenticationType, PriceUnit, ReservationKind, ReservationStartInterval
+from utils.date_utils import local_date, local_datetime
from tests.factories import (
PaymentProductFactory,
@@ -49,7 +49,7 @@
UPDATE_MUTATION = build_mutation("updateReservationUnit", "ReservationUnitUpdateMutation")
-def get_create_non_draft_input_data() -> dict[str, Any]:
+def get_create_non_draft_input_data(**overrides: Any) -> dict[str, Any]:
unit = UnitFactory.create()
space = SpaceFactory.create(unit=unit)
resource = ResourceFactory.create(space=space)
@@ -58,6 +58,8 @@ def get_create_non_draft_input_data() -> dict[str, Any]:
metadata_set = ReservationMetadataSetFactory.create()
tax_percentage = TaxPercentageFactory.create()
+ today = local_date()
+
return {
"isDraft": False,
"name": "Name",
@@ -94,13 +96,20 @@ def get_create_non_draft_input_data() -> dict[str, Any]:
"reservationKind": ReservationKind.DIRECT.value.upper(),
"pricings": [
{
- "begins": datetime.date.today().strftime("%Y-%m-%d"),
+ "begins": today.isoformat(),
"priceUnit": PriceUnit.PRICE_UNIT_PER_15_MINS.value.upper(),
"lowestPrice": "10.5",
"highestPrice": "18.8",
"taxPercentage": tax_percentage.id,
}
],
+ "accessTypes": [
+ {
+ "beginDate": today.isoformat(),
+ "accessType": AccessType.UNRESTRICTED.value,
+ },
+ ],
+ **overrides,
}
@@ -150,6 +159,7 @@ def get_draft_update_input_data(reservation_unit: ReservationUnit, **overrides)
def get_non_draft_update_input_data(reservation_unit: ReservationUnit, **overrides):
+ today = local_date()
return {
"pk": reservation_unit.pk,
"name": "name",
@@ -160,6 +170,12 @@ def get_non_draft_update_input_data(reservation_unit: ReservationUnit, **overrid
"descriptionEn": "description",
"descriptionSv": "description",
"pricings": [get_pricing_data()],
+ "accessTypes": [
+ {
+ "beginDate": today.isoformat(),
+ "accessType": AccessType.UNRESTRICTED.value,
+ },
+ ],
**overrides,
}
diff --git a/backend/tests/test_graphql_api/test_reservation_unit/test_access_types.py b/backend/tests/test_graphql_api/test_reservation_unit/test_access_types.py
new file mode 100644
index 0000000000..a9dbd27386
--- /dev/null
+++ b/backend/tests/test_graphql_api/test_reservation_unit/test_access_types.py
@@ -0,0 +1,398 @@
+from __future__ import annotations
+
+import datetime
+
+import pytest
+from freezegun import freeze_time
+
+from tilavarauspalvelu.enums import AccessType
+from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
+from tilavarauspalvelu.models import ReservationUnit
+from utils.date_utils import combine, local_date, local_datetime, local_time
+
+from tests.factories import ReservationFactory, ReservationUnitAccessTypeFactory, ReservationUnitFactory
+from tests.helpers import patch_method
+
+from .helpers import (
+ CREATE_MUTATION,
+ UPDATE_MUTATION,
+ get_create_draft_input_data,
+ get_create_non_draft_input_data,
+ get_draft_update_input_data,
+)
+
+# Applied to all tests
+pytestmark = [
+ pytest.mark.django_db,
+]
+
+
+def test_reservation_unit__create__access_types(graphql):
+ today = local_date()
+
+ data = get_create_draft_input_data()
+ data["accessTypes"] = [
+ {
+ "beginDate": today.isoformat(),
+ "accessType": AccessType.OPENED_BY_STAFF.value,
+ },
+ {
+ "beginDate": (today + datetime.timedelta(days=1)).isoformat(),
+ "accessType": AccessType.UNRESTRICTED.value,
+ },
+ ]
+
+ graphql.login_with_superuser()
+ response = graphql(CREATE_MUTATION, input_data=data)
+
+ assert response.has_errors is False, response.errors
+
+ reservation_unit = ReservationUnit.objects.get(pk=response.first_query_object["pk"])
+
+ access_type = list(reservation_unit.access_types.order_by("begin_date").all())
+ assert len(access_type) == 2
+
+ assert access_type[0].access_type == AccessType.OPENED_BY_STAFF
+ assert access_type[1].access_type == AccessType.UNRESTRICTED
+
+
+def test_reservation_unit__create__access_types__not_access_code(graphql):
+ today = local_date()
+
+ data = get_create_draft_input_data()
+ data["accessTypes"] = [
+ {
+ "beginDate": today.isoformat(),
+ "accessType": AccessType.ACCESS_CODE.value,
+ },
+ ]
+
+ graphql.login_with_superuser()
+ response = graphql(CREATE_MUTATION, input_data=data)
+
+ assert response.error_message() == "Mutation was unsuccessful."
+ assert response.field_error_messages() == ["Cannot set access type to access code on reservation unit create."]
+
+
+def test_reservation_unit__create__access_types__not_in_the_past(graphql):
+ today = local_date()
+
+ data = get_create_draft_input_data()
+ data["accessTypes"] = [
+ {
+ "beginDate": (today - datetime.timedelta(days=1)).isoformat(),
+ "accessType": AccessType.OPENED_BY_STAFF.value,
+ },
+ ]
+
+ graphql.login_with_superuser()
+ response = graphql(CREATE_MUTATION, input_data=data)
+
+ assert response.error_message() == "Mutation was unsuccessful."
+ assert response.field_error_messages() == ["Access type cannot be created in the past."]
+
+
+def test_reservation_unit__create__access_types__published(graphql):
+ data = get_create_non_draft_input_data()
+
+ graphql.login_with_superuser()
+ response = graphql(CREATE_MUTATION, input_data=data)
+
+ assert response.has_errors is False, response.errors
+
+ reservation_unit = ReservationUnit.objects.get(pk=response.first_query_object["pk"])
+
+ access_type = list(reservation_unit.access_types.order_by("begin_date").all())
+ assert len(access_type) == 1
+
+ assert access_type[0].access_type == AccessType.UNRESTRICTED
+
+
+def test_reservation_unit__create__access_types__published__no_active_access_type(graphql):
+ data = get_create_non_draft_input_data()
+ data["accessTypes"] = []
+
+ graphql.login_with_superuser()
+ response = graphql(CREATE_MUTATION, input_data=data)
+
+ assert response.error_message() == "Mutation was unsuccessful."
+ assert response.field_error_messages() == ["At least one active access type is required."]
+
+
+def test_reservation_unit__update__access_types(graphql):
+ reservation_unit = ReservationUnitFactory.create(is_draft=True)
+
+ access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ begin_date=local_date() - datetime.timedelta(days=1),
+ access_type=AccessType.PHYSICAL_KEY,
+ )
+
+ today = local_date()
+
+ data = get_draft_update_input_data(reservation_unit=reservation_unit)
+ data["accessTypes"] = [
+ {
+ "pk": access_type.pk,
+ "beginDate": access_type.begin_date.isoformat(),
+ "accessType": AccessType.PHYSICAL_KEY.value,
+ },
+ {
+ "beginDate": today.isoformat(),
+ "accessType": AccessType.OPENED_BY_STAFF.value,
+ },
+ {
+ "beginDate": (today + datetime.timedelta(days=1)).isoformat(),
+ "accessType": AccessType.UNRESTRICTED.value,
+ },
+ ]
+
+ graphql.login_with_superuser()
+ response = graphql(UPDATE_MUTATION, input_data=data)
+
+ assert response.has_errors is False, response.errors
+
+ reservation_unit = ReservationUnit.objects.get(pk=response.first_query_object["pk"])
+
+ access_type = list(reservation_unit.access_types.order_by("begin_date").all())
+ assert len(access_type) == 3
+
+ assert access_type[0].access_type == AccessType.PHYSICAL_KEY
+ assert access_type[1].access_type == AccessType.OPENED_BY_STAFF
+ assert access_type[2].access_type == AccessType.UNRESTRICTED
+
+
+@pytest.mark.parametrize("started_days_ago", [0, 1])
+def test_reservation_unit__update__access_types__cannot_change_access_type_begin_date(graphql, started_days_ago):
+ reservation_unit = ReservationUnitFactory.create(is_draft=True)
+
+ today = local_date() - datetime.timedelta(days=started_days_ago)
+
+ access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ begin_date=today,
+ access_type=AccessType.PHYSICAL_KEY,
+ )
+
+ data = get_draft_update_input_data(reservation_unit=reservation_unit)
+ data["accessTypes"] = [
+ {
+ "pk": access_type.pk,
+ "beginDate": (today + datetime.timedelta(days=1)).isoformat(),
+ "accessType": AccessType.PHYSICAL_KEY.value,
+ },
+ ]
+
+ graphql.login_with_superuser()
+ response = graphql(UPDATE_MUTATION, input_data=data)
+
+ assert response.error_message() == "Mutation was unsuccessful."
+ assert response.field_error_messages() == ["Past of active access type begin date cannot be changed."]
+
+
+def test_reservation_unit__update__access_types__cannot_move_to_the_past(graphql):
+ reservation_unit = ReservationUnitFactory.create(is_draft=True)
+
+ today = local_date()
+
+ ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ begin_date=today,
+ access_type=AccessType.PHYSICAL_KEY,
+ )
+ access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ begin_date=today + datetime.timedelta(days=1),
+ access_type=AccessType.PHYSICAL_KEY,
+ )
+
+ data = get_draft_update_input_data(reservation_unit=reservation_unit)
+ data["accessTypes"] = [
+ {
+ "pk": access_type.pk,
+ "beginDate": (today - datetime.timedelta(days=1)).isoformat(),
+ "accessType": AccessType.PHYSICAL_KEY.value,
+ },
+ ]
+
+ graphql.login_with_superuser()
+ response = graphql(UPDATE_MUTATION, input_data=data)
+
+ assert response.error_message() == "Mutation was unsuccessful."
+ assert response.field_error_messages() == ["Access type cannot be moved to the past."]
+
+
+@freeze_time(local_datetime(2023, 1, 1, hour=0))
+def test_reservation_unit__update__access_types__set_new_access_type_to_reservations(graphql):
+ reservation_unit = ReservationUnitFactory.create(is_draft=True)
+
+ today = local_date(2023, 1, 1)
+
+ access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ begin_date=local_date(),
+ access_type=AccessType.PHYSICAL_KEY,
+ )
+
+ past_reservation = ReservationFactory.create(
+ begin=combine(today - datetime.timedelta(days=1), local_time(12)),
+ end=combine(today - datetime.timedelta(days=1), local_time(13)),
+ access_type=AccessType.PHYSICAL_KEY,
+ reservation_units=[reservation_unit],
+ )
+ todays_reservation = ReservationFactory.create(
+ begin=combine(today, local_time(12)),
+ end=combine(today, local_time(13)),
+ access_type=AccessType.PHYSICAL_KEY,
+ reservation_units=[reservation_unit],
+ )
+ future_reservation = ReservationFactory.create(
+ begin=combine(today + datetime.timedelta(days=1), local_time(12)),
+ end=combine(today + datetime.timedelta(days=1), local_time(13)),
+ access_type=AccessType.PHYSICAL_KEY,
+ reservation_units=[reservation_unit],
+ )
+
+ today = local_date()
+
+ graphql.login_with_superuser()
+
+ data = get_draft_update_input_data(reservation_unit=reservation_unit)
+ data["accessTypes"] = [
+ {
+ "pk": access_type.pk,
+ "beginDate": today.isoformat(),
+ "accessType": AccessType.OPENED_BY_STAFF.value,
+ },
+ ]
+
+ response = graphql(UPDATE_MUTATION, input_data=data)
+
+ assert response.has_errors is False, response.errors
+
+ past_reservation.refresh_from_db()
+ assert past_reservation.access_type == AccessType.PHYSICAL_KEY
+
+ todays_reservation.refresh_from_db()
+ assert todays_reservation.access_type == AccessType.OPENED_BY_STAFF
+
+ future_reservation.refresh_from_db()
+ assert future_reservation.access_type == AccessType.OPENED_BY_STAFF
+
+
+@patch_method(PindoraClient.get_reservation_unit)
+def test_reservation_unit__update__access_types__check_from_pindora__switch_to_access_code(graphql):
+ reservation_unit = ReservationUnitFactory.create(is_draft=True)
+
+ access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ begin_date=local_date() - datetime.timedelta(days=1),
+ access_type=AccessType.PHYSICAL_KEY,
+ )
+
+ data = get_draft_update_input_data(reservation_unit=reservation_unit)
+ data["accessTypes"] = [
+ {
+ "pk": access_type.pk,
+ "beginDate": access_type.begin_date.isoformat(),
+ "accessType": AccessType.ACCESS_CODE.value,
+ },
+ ]
+
+ graphql.login_with_superuser()
+ response = graphql(UPDATE_MUTATION, input_data=data)
+
+ assert response.has_errors is False, response.errors
+
+ assert PindoraClient.get_reservation_unit.call_count == 1
+
+
+@patch_method(PindoraClient.get_reservation_unit)
+def test_reservation_unit__update__access_types__check_from_pindora__new_access_type(graphql):
+ reservation_unit = ReservationUnitFactory.create(is_draft=True)
+ today = local_date()
+
+ access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ begin_date=today - datetime.timedelta(days=1),
+ access_type=AccessType.PHYSICAL_KEY,
+ )
+
+ data = get_draft_update_input_data(reservation_unit=reservation_unit)
+ data["accessTypes"] = [
+ {
+ "pk": access_type.pk,
+ "beginDate": access_type.begin_date.isoformat(),
+ "accessType": AccessType.PHYSICAL_KEY.value,
+ },
+ {
+ "beginDate": (today + datetime.timedelta(days=1)).isoformat(),
+ "accessType": AccessType.ACCESS_CODE.value,
+ },
+ ]
+
+ graphql.login_with_superuser()
+ response = graphql(UPDATE_MUTATION, input_data=data)
+
+ assert response.has_errors is False, response.errors
+
+ assert PindoraClient.get_reservation_unit.call_count == 1
+
+
+@patch_method(PindoraClient.get_reservation_unit)
+def test_reservation_unit__update__access_types__dont_check_from_pindora__still_access_code(graphql):
+ reservation_unit = ReservationUnitFactory.create(is_draft=True)
+
+ access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ begin_date=local_date() - datetime.timedelta(days=1),
+ access_type=AccessType.ACCESS_CODE,
+ )
+
+ data = get_draft_update_input_data(reservation_unit=reservation_unit)
+ data["accessTypes"] = [
+ {
+ "pk": access_type.pk,
+ "beginDate": access_type.begin_date.isoformat(),
+ "accessType": AccessType.ACCESS_CODE.value,
+ },
+ ]
+
+ graphql.login_with_superuser()
+ response = graphql(UPDATE_MUTATION, input_data=data)
+
+ assert response.has_errors is False, response.errors
+
+ assert PindoraClient.get_reservation_unit.call_count == 0
+
+
+def test_reservation_unit__update__access_types__future_one_is_deleted(graphql):
+ reservation_unit = ReservationUnitFactory.create(is_draft=True)
+
+ today = local_date()
+
+ active_access_type = ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ begin_date=today - datetime.timedelta(days=1),
+ access_type=AccessType.PHYSICAL_KEY,
+ )
+
+ ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ begin_date=today + datetime.timedelta(days=1),
+ access_type=AccessType.UNRESTRICTED,
+ )
+
+ data = get_draft_update_input_data(reservation_unit=reservation_unit)
+ data["accessTypes"] = []
+
+ graphql.login_with_superuser()
+ response = graphql(UPDATE_MUTATION, input_data=data)
+
+ assert response.has_errors is False, response.errors
+
+ reservation_unit = ReservationUnit.objects.get(pk=response.first_query_object["pk"])
+
+ access_type = list(reservation_unit.access_types.order_by("begin_date").all())
+ assert len(access_type) == 1
+ assert access_type[0].pk == active_access_type.pk
diff --git a/backend/tests/test_graphql_api/test_reservation_unit/test_filtering.py b/backend/tests/test_graphql_api/test_reservation_unit/test_filtering.py
index f9f0b32564..ca621c0fd4 100644
--- a/backend/tests/test_graphql_api/test_reservation_unit/test_filtering.py
+++ b/backend/tests/test_graphql_api/test_reservation_unit/test_filtering.py
@@ -15,6 +15,7 @@
from tests.factories import (
ApplicationRoundFactory,
EquipmentFactory,
+ ReservationUnitAccessTypeFactory,
ReservationUnitFactory,
ReservationUnitTypeFactory,
UnitFactory,
@@ -801,123 +802,66 @@ def test_reservation_unit__filter__by_access_type(graphql):
today = local_date()
- # Always active access type
- reservation_unit_1 = ReservationUnitFactory.create(
- name="Always physical key",
- access_type=AccessType.PHYSICAL_KEY,
- )
-
- # Access type before filter period.
+ # Access type before filter period
ReservationUnitFactory.create(
- name="physical key ends before filter period",
- access_type=AccessType.PHYSICAL_KEY,
- access_type_end_date=today - datetime.timedelta(days=1),
+ name="before filter period",
+ access_types__access_type=AccessType.UNRESTRICTED,
+ access_types__begin_date=today - datetime.timedelta(days=10),
)
- # Access type starts during the filter period.
- reservation_unit_2 = ReservationUnitFactory.create(
- name="physical key starts during filter period",
- access_type=AccessType.PHYSICAL_KEY,
- access_type_start_date=today + datetime.timedelta(days=1),
+ # Access type during the filter period
+ reservation_unit = ReservationUnitFactory.create(
+ name="on filter period",
+ access_types__access_type=AccessType.PHYSICAL_KEY,
+ access_types__begin_date=today,
)
- # Access type starts after the filter period.
+ # Access type after the filter period
ReservationUnitFactory.create(
- name="physical key starts after filter period",
- access_type=AccessType.PHYSICAL_KEY,
- access_type_start_date=today + datetime.timedelta(days=10),
- )
-
- # Access type ending during the filter period.
- reservation_unit_3 = ReservationUnitFactory.create(
- name="physical key ends during filter period",
- access_type=AccessType.PHYSICAL_KEY,
- access_type_end_date=today,
+ name="after filter period",
+ access_types__access_type=AccessType.PHYSICAL_KEY,
+ access_types__begin_date=today + datetime.timedelta(days=10),
)
# Access type something other
- ReservationUnitFactory.create(name="unrestricted", access_type=AccessType.UNRESTRICTED)
- ReservationUnitFactory.create(name="access code", access_type=AccessType.ACCESS_CODE)
- ReservationUnitFactory.create(name="opened by staff", access_type=AccessType.OPENED_BY_STAFF)
+ ReservationUnitFactory.create(name="unrestricted", access_types__access_type=AccessType.UNRESTRICTED)
+ ReservationUnitFactory.create(name="access code", access_types__access_type=AccessType.ACCESS_CODE)
+ ReservationUnitFactory.create(name="opened by staff", access_types__access_type=AccessType.OPENED_BY_STAFF)
query = reservation_units_query(
fields="name",
access_type=AccessType.PHYSICAL_KEY,
- access_type_start_date=today.isoformat(),
+ access_type_begin_date=today.isoformat(),
access_type_end_date=(today + datetime.timedelta(days=1)).isoformat(),
)
response = graphql(query)
assert response.has_errors is False
- assert len(response.edges) == 3
- assert response.node(0) == {"name": reservation_unit_1.name}
- assert response.node(1) == {"name": reservation_unit_2.name}
- assert response.node(2) == {"name": reservation_unit_3.name}
-
-
-def test_reservation_unit__filter__by_access_type__open_access(graphql):
- graphql.login_with_superuser()
-
- today = local_date()
-
- # Access type open access
- reservation_unit_1 = ReservationUnitFactory.create(
- name="Always open access",
- access_type=AccessType.UNRESTRICTED,
- )
+ assert len(response.edges) == 1
+ assert response.node(0) == {"name": reservation_unit.name}
- # Other access type ends during the filter period
- # => Has "open access" during rest of period.
- reservation_unit_2 = ReservationUnitFactory.create(
- name="ending during filter period",
- access_type=AccessType.PHYSICAL_KEY,
- access_type_end_date=today,
- )
- # Other access type starts during the filter period
- # => Has "open access" during beginning of period.
- reservation_unit_3 = ReservationUnitFactory.create(
- name="starting during filter period",
- access_type=AccessType.PHYSICAL_KEY,
- access_type_start_date=today + datetime.timedelta(days=1),
+def test_reservation_unit__filter__by_access_type__no_period(graphql):
+ reservation_unit = ReservationUnitFactory.create(
+ name="with key",
+ access_types__access_type=AccessType.PHYSICAL_KEY,
+ access_types__begin_date=local_date(),
)
- # Access type something other.
- ReservationUnitFactory.create(name="with key", access_type=AccessType.PHYSICAL_KEY)
- ReservationUnitFactory.create(name="keyless", access_type=AccessType.ACCESS_CODE)
- ReservationUnitFactory.create(name="opened by staff", access_type=AccessType.OPENED_BY_STAFF)
-
- query = reservation_units_query(
- fields="name",
- access_type=AccessType.UNRESTRICTED,
- access_type_start_date=today.isoformat(),
- access_type_end_date=(today + datetime.timedelta(days=1)).isoformat(),
+ # Access type was in the past, default filter only looks to the future.
+ ReservationUnitAccessTypeFactory.create(
+ reservation_unit=reservation_unit,
+ access_type=AccessType.ACCESS_CODE,
+ begin_date=local_date() - datetime.timedelta(days=1),
)
- response = graphql(query)
-
- assert response.has_errors is False
- assert len(response.edges) == 3
- assert response.node(0) == {"name": reservation_unit_1.name}
- assert response.node(1) == {"name": reservation_unit_2.name}
- assert response.node(2) == {"name": reservation_unit_3.name}
-
-
-def test_reservation_unit__filter__by_access_type__no_period(graphql):
- graphql.login_with_superuser()
- reservation_unit = ReservationUnitFactory.create(name="with key", access_type=AccessType.PHYSICAL_KEY)
- ReservationUnitFactory.create(name="open access", access_type=AccessType.UNRESTRICTED)
- ReservationUnitFactory.create(name="keyless", access_type=AccessType.ACCESS_CODE)
- ReservationUnitFactory.create(name="opened by staff", access_type=AccessType.OPENED_BY_STAFF)
-
- # Target access type was in the past, default filter only looks to the future.
- ReservationUnitFactory.create(
- name="with key",
- access_type=AccessType.PHYSICAL_KEY,
- access_type_end_date=local_date() - datetime.timedelta(days=1),
- )
+ # Other access types
+ ReservationUnitFactory.create(name="open access", access_types__access_type=AccessType.UNRESTRICTED)
+ ReservationUnitFactory.create(name="keyless", access_types__access_type=AccessType.ACCESS_CODE)
+ ReservationUnitFactory.create(name="opened by staff", access_types__access_type=AccessType.OPENED_BY_STAFF)
query = reservation_units_query(fields="name", access_type=AccessType.PHYSICAL_KEY)
+ graphql.login_with_superuser()
response = graphql(query)
assert response.has_errors is False
diff --git a/backend/tests/test_graphql_api/test_reservation_unit/test_pricing.py b/backend/tests/test_graphql_api/test_reservation_unit/test_pricing.py
index 971c842d09..0fbe09a4fd 100644
--- a/backend/tests/test_graphql_api/test_reservation_unit/test_pricing.py
+++ b/backend/tests/test_graphql_api/test_reservation_unit/test_pricing.py
@@ -15,6 +15,7 @@
CREATE_MUTATION,
UPDATE_MUTATION,
get_create_draft_input_data,
+ get_create_non_draft_input_data,
get_pricing_data,
reservation_units_query,
)
@@ -193,7 +194,7 @@ def test_reservation_unit__create__pricing__creating_future_pricing_without_acti
def test_reservation_unit__update__pricing__active_pricing_can_be_created_on_update(graphql):
graphql.login_with_superuser()
- data = get_create_draft_input_data()
+ data = get_create_non_draft_input_data(pricings=[])
response = graphql(CREATE_MUTATION, input_data=data)
assert response.has_errors is False, response
@@ -205,6 +206,7 @@ def test_reservation_unit__update__pricing__active_pricing_can_be_created_on_upd
data["pk"] = reservation_unit.pk
data["isDraft"] = False
data["pricings"] = [get_pricing_data()]
+ data["accessTypes"] = []
response = graphql(UPDATE_MUTATION, input_data=data)
assert response.has_errors is False, response
@@ -373,7 +375,7 @@ def test_reservation_unit__update__pricing__remove_active_pricing_while_future_e
def test_reservation_unit__update__pricing__pricings_not_sent_for_non_draft_reservation_unit(graphql):
graphql.login_with_superuser()
- data = get_create_draft_input_data(pricings=[get_pricing_data()])
+ data = get_create_non_draft_input_data(pricings=[get_pricing_data()])
response = graphql(CREATE_MUTATION, input_data=data)
assert response.has_errors is False, response
@@ -384,6 +386,7 @@ def test_reservation_unit__update__pricing__pricings_not_sent_for_non_draft_rese
data["pk"] = reservation_unit.pk
data["isDraft"] = False
del data["pricings"]
+ data["accessTypes"] = []
response = graphql(UPDATE_MUTATION, input_data=data)
assert response.has_errors is False, response
diff --git a/backend/tests/test_graphql_api/test_reservation_unit/test_query.py b/backend/tests/test_graphql_api/test_reservation_unit/test_query.py
index 1a0d963360..588a18af1d 100644
--- a/backend/tests/test_graphql_api/test_reservation_unit/test_query.py
+++ b/backend/tests/test_graphql_api/test_reservation_unit/test_query.py
@@ -12,7 +12,7 @@
TermsOfUseTypeChoices,
WeekdayChoice,
)
-from utils.date_utils import local_datetime, next_hour
+from utils.date_utils import local_date, local_datetime, next_hour
from tests.factories import (
ApplicationRoundFactory,
@@ -134,8 +134,6 @@ def test_reservation_unit__query__all_fields(graphql):
maxReservationDuration
bufferTimeBefore
bufferTimeAfter
- accessTypeStartDate
- accessTypeEndDate
isDraft
isArchived
@@ -149,7 +147,6 @@ def test_reservation_unit__query__all_fields(graphql):
reservationKind
publishingState
reservationState
- accessType
currentAccessType
"""
@@ -214,8 +211,6 @@ def test_reservation_unit__query__all_fields(graphql):
"maxReservationDuration": int(reservation_unit.max_reservation_duration.total_seconds()),
"bufferTimeBefore": int(reservation_unit.buffer_time_before.total_seconds()),
"bufferTimeAfter": int(reservation_unit.buffer_time_after.total_seconds()),
- "accessTypeStartDate": None,
- "accessTypeEndDate": None,
#
"isDraft": reservation_unit.is_draft,
"isArchived": reservation_unit.is_archived,
@@ -229,7 +224,6 @@ def test_reservation_unit__query__all_fields(graphql):
"reservationKind": reservation_unit.reservation_kind.upper(),
"publishingState": reservation_unit.publishing_state,
"reservationState": reservation_unit.reservation_state,
- "accessType": reservation_unit.access_type,
"currentAccessType": reservation_unit.current_access_type,
}
@@ -336,12 +330,17 @@ def test_reservation_unit__query__all_one_to_many_relations(graphql):
applicationRoundTimeSlots {
closed
}
+ accessTypes {
+ accessType
+ beginDate
+ }
"""
reservation_unit = ReservationUnitFactory.create(
pricings__highest_price=20,
images__large_url="https://example.com",
application_round_time_slots__closed=False,
+ access_types__begin_date=local_date(),
)
graphql.login_with_superuser()
query = reservation_units_query(fields=fields)
@@ -369,6 +368,12 @@ def test_reservation_unit__query__all_one_to_many_relations(graphql):
"closed": reservation_unit.application_round_time_slots.first().closed,
},
],
+ "accessTypes": [
+ {
+ "accessType": reservation_unit.access_types.first().access_type,
+ "beginDate": reservation_unit.access_types.first().begin_date.isoformat(),
+ },
+ ],
}
diff --git a/backend/tests/test_graphql_api/test_reservation_unit/test_update_draft.py b/backend/tests/test_graphql_api/test_reservation_unit/test_update_draft.py
index e37ecbedec..a8c9013b89 100644
--- a/backend/tests/test_graphql_api/test_reservation_unit/test_update_draft.py
+++ b/backend/tests/test_graphql_api/test_reservation_unit/test_update_draft.py
@@ -6,7 +6,7 @@
from config.utils.auditlog_util import AuditLogger
from tilavarauspalvelu.api.graphql.extensions import error_codes
-from tilavarauspalvelu.enums import AuthenticationType, TermsOfUseTypeChoices
+from tilavarauspalvelu.enums import AccessType, AuthenticationType, TermsOfUseTypeChoices
from tilavarauspalvelu.models import ReservationUnit
from tests.factories import ReservationMetadataSetFactory, ReservationUnitFactory, TermsOfUseFactory
@@ -170,8 +170,6 @@ def test_reservation_unit__update__archiving_removes_contact_information_and_aud
"_reservation_state",
"_active_pricing_price",
"_current_access_type",
- "_perceived_access_type_end_date",
- "_perceived_access_type_start_date",
],
)
@@ -224,6 +222,7 @@ def test_reservation_unit__update__publish(graphql):
description_sv="foo",
description_en="foo",
pricings__highest_price=20,
+ access_types__access_type=AccessType.UNRESTRICTED,
)
data = get_draft_update_input_data(reservation_unit, isDraft=False)
diff --git a/backend/tests/test_graphql_api/test_reservation_unit/test_update_not_draft.py b/backend/tests/test_graphql_api/test_reservation_unit/test_update_not_draft.py
index f67d3cea56..3340fd453d 100644
--- a/backend/tests/test_graphql_api/test_reservation_unit/test_update_not_draft.py
+++ b/backend/tests/test_graphql_api/test_reservation_unit/test_update_not_draft.py
@@ -13,7 +13,7 @@
ReservationStateChoice,
TermsOfUseTypeChoices,
)
-from utils.date_utils import local_datetime, next_hour
+from utils.date_utils import local_date, local_datetime, next_hour
from tests.factories import (
ReservationFactory,
@@ -364,7 +364,10 @@ def test_reservation_unit__update__archiving_not_blocked_if_reservation_unit_has
def test_reservation_unit__update__access_type__change_future_reservations(graphql):
graphql.login_with_superuser()
- reservation_unit = ReservationUnitFactory.create(is_draft=False, access_type=AccessType.UNRESTRICTED)
+ reservation_unit = ReservationUnitFactory.create(
+ is_draft=False,
+ access_types__access_type=AccessType.UNRESTRICTED,
+ )
past_reservation = ReservationFactory.create(
reservation_units=[reservation_unit],
@@ -379,38 +382,42 @@ def test_reservation_unit__update__access_type__change_future_reservations(graph
end=local_datetime(2024, 1, 1, 13),
)
- data = get_non_draft_update_input_data(reservation_unit, accessType=AccessType.ACCESS_CODE)
+ access_type_data = {
+ "accessType": AccessType.PHYSICAL_KEY,
+ "beginDate": local_date(2024, 1, 1).isoformat(),
+ }
+ data = get_non_draft_update_input_data(reservation_unit, accessTypes=access_type_data)
response = graphql(UPDATE_MUTATION, input_data=data)
assert response.has_errors is False, response
- reservation_unit.refresh_from_db()
- assert reservation_unit.access_type == AccessType.ACCESS_CODE
-
past_reservation.refresh_from_db()
assert past_reservation.access_type == AccessType.UNRESTRICTED
future_reservation.refresh_from_db()
- assert future_reservation.access_type == AccessType.ACCESS_CODE
+ assert future_reservation.access_type == AccessType.PHYSICAL_KEY
@freeze_time(local_datetime(2024, 1, 1))
-def test_reservation_unit__update__access_type__valid_for_period(graphql):
+def test_reservation_unit__update__access_type__change_period(graphql):
graphql.login_with_superuser()
- reservation_unit = ReservationUnitFactory.create(is_draft=False, access_type=AccessType.UNRESTRICTED)
+ reservation_unit = ReservationUnitFactory.create(
+ is_draft=False,
+ access_types__access_type=AccessType.UNRESTRICTED,
+ )
before_period_reservation = ReservationFactory.create(
reservation_units=[reservation_unit],
- access_type=AccessType.OPENED_BY_STAFF,
- begin=local_datetime(2024, 1, 1, 12),
- end=local_datetime(2024, 1, 1, 13),
+ access_type=AccessType.PHYSICAL_KEY,
+ begin=local_datetime(2023, 12, 31, 12),
+ end=local_datetime(2023, 12, 31, 13),
)
on_period_reservation = ReservationFactory.create(
reservation_units=[reservation_unit],
access_type=AccessType.UNRESTRICTED,
- begin=local_datetime(2024, 1, 2, 12),
- end=local_datetime(2024, 1, 2, 13),
+ begin=local_datetime(2024, 1, 1, 12),
+ end=local_datetime(2024, 1, 1, 13),
)
after_period_reservation = ReservationFactory.create(
reservation_units=[reservation_unit],
@@ -421,22 +428,28 @@ def test_reservation_unit__update__access_type__valid_for_period(graphql):
data = get_non_draft_update_input_data(
reservation_unit,
- accessType=AccessType.ACCESS_CODE,
- accessTypeStartDate="2024-01-02",
- accessTypeEndDate="2024-01-02",
+ accessTypes=[
+ {
+ "accessType": AccessType.OPENED_BY_STAFF,
+ "beginDate": "2024-01-01",
+ },
+ {
+ "accessType": AccessType.UNRESTRICTED,
+ "beginDate": "2024-01-03",
+ },
+ ],
)
response = graphql(UPDATE_MUTATION, input_data=data)
assert response.has_errors is False, response
- reservation_unit.refresh_from_db()
- assert reservation_unit.access_type == AccessType.ACCESS_CODE
+ assert len(reservation_unit.access_types.all()) == 3
before_period_reservation.refresh_from_db()
- assert before_period_reservation.access_type == AccessType.UNRESTRICTED
+ assert before_period_reservation.access_type == AccessType.PHYSICAL_KEY
on_period_reservation.refresh_from_db()
- assert on_period_reservation.access_type == AccessType.ACCESS_CODE
+ assert on_period_reservation.access_type == AccessType.OPENED_BY_STAFF
after_period_reservation.refresh_from_db()
assert after_period_reservation.access_type == AccessType.UNRESTRICTED
diff --git a/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/conftest.py b/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/conftest.py
new file mode 100644
index 0000000000..3261af98b2
--- /dev/null
+++ b/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/conftest.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+import pytest
+from django.core.cache import cache
+
+
+@pytest.fixture(autouse=True)
+def clear_pindora_cache():
+ cache.clear()
diff --git a/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/helpers.py b/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/helpers.py
index bde683f81d..d15f13b307 100644
--- a/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/helpers.py
+++ b/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/helpers.py
@@ -2,7 +2,7 @@
from typing import TYPE_CHECKING, Any, NamedTuple
-from utils.date_utils import DEFAULT_TIMEZONE
+from utils.date_utils import DEFAULT_TIMEZONE, local_datetime
if TYPE_CHECKING:
from tilavarauspalvelu.models import Reservation, ReservationUnit
@@ -83,6 +83,15 @@ def default_reservation_series_response(reservation: Reservation, **overrides: A
}
+def default_access_code_modify_response(**overrides: Any) -> dict[str, Any]:
+ # This is the json response form Pindora API, which is processed to `PindoraAccessCodeModifyResponse`.
+ return {
+ "access_code_generated_at": local_datetime().isoformat(),
+ "access_code_is_active": True,
+ **overrides,
+ }
+
+
class ErrorParams(NamedTuple):
status_code: int
exception: type[Exception]
diff --git a/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_caching.py b/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_caching.py
index f291b0b613..2dad0489cc 100644
--- a/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_caching.py
+++ b/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_caching.py
@@ -22,15 +22,15 @@ def test_pindora_client__caching__reservation_unit_response():
keypad_url="https://example.com",
)
- PindoraClient.cache_reservation_unit_response(data=data, ext_uuid=reservation_unit_id)
+ PindoraClient._cache_reservation_unit_response(data=data, ext_uuid=reservation_unit_id)
- response = PindoraClient.get_cached_reservation_unit_response(ext_uuid=reservation_unit_id)
+ response = PindoraClient._get_cached_reservation_unit_response(ext_uuid=reservation_unit_id)
assert response == data
- succeeded = PindoraClient.clear_cached_reservation_unit_response(ext_uuid=reservation_unit_id)
+ succeeded = PindoraClient._clear_cached_reservation_unit_response(ext_uuid=reservation_unit_id)
assert succeeded is True
- response = PindoraClient.get_cached_reservation_unit_response(ext_uuid=reservation_unit_id)
+ response = PindoraClient._get_cached_reservation_unit_response(ext_uuid=reservation_unit_id)
assert response is None
@@ -52,15 +52,15 @@ def test_pindora_client__caching__reservation_response():
end=local_datetime(2022, 1, 1, 13),
)
- PindoraClient.cache_reservation_response(data=data, ext_uuid=reservation_id)
+ PindoraClient._cache_reservation_response(data=data, ext_uuid=reservation_id)
- response = PindoraClient.get_cached_reservation_response(ext_uuid=reservation_id)
+ response = PindoraClient._get_cached_reservation_response(ext_uuid=reservation_id)
assert response == data
- succeeded = PindoraClient.clear_cached_reservation_response(ext_uuid=reservation_id)
+ succeeded = PindoraClient._clear_cached_reservation_response(ext_uuid=reservation_id)
assert succeeded is True
- response = PindoraClient.get_cached_reservation_response(ext_uuid=reservation_id)
+ response = PindoraClient._get_cached_reservation_response(ext_uuid=reservation_id)
assert response is None
@@ -86,15 +86,15 @@ def test_pindora_client__caching__seasonal_booking_response():
],
)
- PindoraClient.cache_seasonal_booking_response(data=data, ext_uuid=section_id)
+ PindoraClient._cache_seasonal_booking_response(data=data, ext_uuid=section_id)
- response = PindoraClient.get_cached_seasonal_booking_response(ext_uuid=section_id)
+ response = PindoraClient._get_cached_seasonal_booking_response(ext_uuid=section_id)
assert response == data
- succeeded = PindoraClient.clear_cached_seasonal_booking_response(ext_uuid=section_id)
+ succeeded = PindoraClient._clear_cached_seasonal_booking_response(ext_uuid=section_id)
assert succeeded is True
- response = PindoraClient.get_cached_seasonal_booking_response(ext_uuid=section_id)
+ response = PindoraClient._get_cached_seasonal_booking_response(ext_uuid=section_id)
assert response is None
@@ -120,13 +120,13 @@ def test_pindora_client__caching__reservation_series_response():
],
)
- PindoraClient.cache_reservation_series_response(data=data, ext_uuid=series_id)
+ PindoraClient._cache_reservation_series_response(data=data, ext_uuid=series_id)
- response = PindoraClient.get_cached_reservation_series_response(ext_uuid=series_id)
+ response = PindoraClient._get_cached_reservation_series_response(ext_uuid=series_id)
assert response == data
- succeeded = PindoraClient.clear_cached_reservation_series_response(ext_uuid=series_id)
+ succeeded = PindoraClient._clear_cached_reservation_series_response(ext_uuid=series_id)
assert succeeded is True
- response = PindoraClient.get_cached_reservation_series_response(ext_uuid=series_id)
+ response = PindoraClient._get_cached_reservation_series_response(ext_uuid=series_id)
assert response is None
diff --git a/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_reservation.py b/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_reservation.py
index ed288241b5..2d6f3ef29a 100644
--- a/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_reservation.py
+++ b/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_reservation.py
@@ -28,7 +28,7 @@
from tests.factories import ReservationFactory
from tests.helpers import ResponseMock, exact, patch_method, use_retries
-from .helpers import ErrorParams, default_reservation_response
+from .helpers import ErrorParams, default_access_code_modify_response, default_reservation_response
def test_pindora_client__get_reservation():
@@ -215,7 +215,9 @@ def test_pindora_client__create_reservation__errors(status_code, exception, erro
def test_pindora_client__reschedule_reservation():
reservation = ReservationFactory.build()
- with patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_204_NO_CONTENT)) as patch:
+ data = default_access_code_modify_response()
+
+ with patch_method(PindoraClient.request, return_value=ResponseMock(json_data=data)) as patch:
PindoraClient.reschedule_reservation(reservation)
assert patch.call_count == 1
@@ -291,7 +293,9 @@ def test_pindora_client__delete_reservation__errors(status_code, exception, erro
def test_pindora_client__change_reservation_access_code():
reservation = ReservationFactory.build()
- with patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_204_NO_CONTENT)) as patch:
+ data = default_access_code_modify_response()
+
+ with patch_method(PindoraClient.request, return_value=ResponseMock(json_data=data)) as patch:
PindoraClient.change_reservation_access_code(reservation)
assert patch.call_count == 1
diff --git a/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_reservation_series.py b/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_reservation_series.py
index 0949c68469..35eadea10e 100644
--- a/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_reservation_series.py
+++ b/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_reservation_series.py
@@ -14,7 +14,7 @@
HTTP_500_INTERNAL_SERVER_ERROR,
)
-from tilavarauspalvelu.enums import ReservationStateChoice
+from tilavarauspalvelu.enums import AccessType, ReservationStateChoice, ReservationTypeChoice
from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
from tilavarauspalvelu.integrations.keyless_entry.exceptions import (
PindoraAPIError,
@@ -31,7 +31,7 @@
from tests.factories import RecurringReservationFactory, ReservationFactory
from tests.helpers import ResponseMock, exact, patch_method, use_retries
-from .helpers import ErrorParams, default_reservation_series_response
+from .helpers import ErrorParams, default_access_code_modify_response, default_reservation_series_response
def test_pindora_client__get_reservation_series():
@@ -168,17 +168,19 @@ def test_pindora_client__get_reservation_series__succeeds_after_retry():
@pytest.mark.django_db
@pytest.mark.parametrize("is_active", [True, False])
def test_pindora_client__create_reservation_series(is_active: bool):
- recurring_reservation = RecurringReservationFactory.create()
+ series = RecurringReservationFactory.create()
reservation = ReservationFactory.create(
- recurring_reservation=recurring_reservation,
+ recurring_reservation=series,
created_at=local_datetime(),
state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ access_type=AccessType.ACCESS_CODE,
)
data = default_reservation_series_response(reservation, access_code_is_active=is_active)
with patch_method(PindoraClient.request, return_value=ResponseMock(json_data=data)):
- response = PindoraClient.create_reservation_series(recurring_reservation, is_active=is_active)
+ response = PindoraClient.create_reservation_series(series, is_active=is_active)
assert response["reservation_unit_id"] == reservation.ext_uuid
assert response["access_code"] == "13245#"
@@ -225,45 +227,62 @@ def test_pindora_client__create_reservation_series(is_active: bool):
)
@pytest.mark.django_db
def test_pindora_client__create_reservation_series__errors(status_code, exception, error_msg):
- recurring_reservation = RecurringReservationFactory.create()
- ReservationFactory.create(recurring_reservation=recurring_reservation, state=ReservationStateChoice.CONFIRMED)
+ series = RecurringReservationFactory.create()
+ ReservationFactory.create(
+ recurring_reservation=series,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ access_type=AccessType.ACCESS_CODE,
+ )
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=status_code))
with patch, pytest.raises(exception, match=exact(error_msg) if error_msg else None):
- PindoraClient.create_reservation_series(recurring_reservation)
+ PindoraClient.create_reservation_series(series)
@pytest.mark.django_db
def test_pindora_client__create_reservation_series__no_reservations():
- recurring_reservation = RecurringReservationFactory.create()
+ series = RecurringReservationFactory.create()
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_200_OK))
- msg = f"No confirmed reservations in reservation series '{recurring_reservation.ext_uuid}'."
+ msg = f"No reservations require an access code in reservation series '{series.ext_uuid}'."
with patch, pytest.raises(PindoraClientError, match=exact(msg)):
- PindoraClient.create_reservation_series(recurring_reservation)
+ PindoraClient.create_reservation_series(series)
@pytest.mark.django_db
def test_pindora_client__create_reservation_series__no_confirmed_reservations():
- recurring_reservation = RecurringReservationFactory.create()
- ReservationFactory.create(recurring_reservation=recurring_reservation, state=ReservationStateChoice.DENIED)
+ series = RecurringReservationFactory.create()
+ ReservationFactory.create(
+ recurring_reservation=series,
+ state=ReservationStateChoice.DENIED,
+ type=ReservationTypeChoice.NORMAL,
+ access_type=AccessType.ACCESS_CODE,
+ )
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_200_OK))
- msg = f"No confirmed reservations in reservation series '{recurring_reservation.ext_uuid}'."
+ msg = f"No reservations require an access code in reservation series '{series.ext_uuid}'."
with patch, pytest.raises(PindoraClientError, match=exact(msg)):
- PindoraClient.create_reservation_series(recurring_reservation)
+ PindoraClient.create_reservation_series(series)
@pytest.mark.django_db
def test_pindora_client__reschedule_reservation_series():
- recurring_reservation = RecurringReservationFactory.create()
- ReservationFactory.create(recurring_reservation=recurring_reservation, state=ReservationStateChoice.CONFIRMED)
+ series = RecurringReservationFactory.create()
+ ReservationFactory.create(
+ recurring_reservation=series,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ access_type=AccessType.ACCESS_CODE,
+ )
- with patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_204_NO_CONTENT)) as patch:
- PindoraClient.reschedule_reservation_series(recurring_reservation)
+ data = default_access_code_modify_response()
+
+ with patch_method(PindoraClient.request, return_value=ResponseMock(json_data=data)) as patch:
+ PindoraClient.reschedule_reservation_series(series)
assert patch.call_count == 1
@@ -290,43 +309,53 @@ def test_pindora_client__reschedule_reservation_series():
)
@pytest.mark.django_db
def test_pindora_client__reschedule_reservation_series__errors(status_code, exception, error_msg):
- recurring_reservation = RecurringReservationFactory.create()
- ReservationFactory.create(recurring_reservation=recurring_reservation, state=ReservationStateChoice.CONFIRMED)
+ series = RecurringReservationFactory.create()
+ ReservationFactory.create(
+ recurring_reservation=series,
+ state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ access_type=AccessType.ACCESS_CODE,
+ )
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=status_code))
with patch, pytest.raises(exception, match=exact(error_msg) if error_msg else None):
- PindoraClient.reschedule_reservation_series(recurring_reservation)
+ PindoraClient.reschedule_reservation_series(series)
@pytest.mark.django_db
def test_pindora_client__reschedule_reservation_series__no_reservations():
- recurring_reservation = RecurringReservationFactory.create()
+ series = RecurringReservationFactory.create()
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_200_OK))
- msg = f"No confirmed reservations in reservation series '{recurring_reservation.ext_uuid}'."
+ msg = f"No confirmed reservations in reservation series '{series.ext_uuid}'."
with patch, pytest.raises(PindoraClientError, match=exact(msg)):
- PindoraClient.reschedule_reservation_series(recurring_reservation)
+ PindoraClient.reschedule_reservation_series(series)
@pytest.mark.django_db
def test_pindora_client__reschedule_reservation_series__no_confirmed_reservations():
- recurring_reservation = RecurringReservationFactory.create()
- ReservationFactory.create(recurring_reservation=recurring_reservation, state=ReservationStateChoice.DENIED)
+ series = RecurringReservationFactory.create()
+ ReservationFactory.create(
+ recurring_reservation=series,
+ state=ReservationStateChoice.DENIED,
+ type=ReservationTypeChoice.NORMAL,
+ access_type=AccessType.ACCESS_CODE,
+ )
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_200_OK))
- msg = f"No confirmed reservations in reservation series '{recurring_reservation.ext_uuid}'."
+ msg = f"No confirmed reservations in reservation series '{series.ext_uuid}'."
with patch, pytest.raises(PindoraClientError, match=exact(msg)):
- PindoraClient.reschedule_reservation_series(recurring_reservation)
+ PindoraClient.reschedule_reservation_series(series)
def test_pindora_client__delete_reservation_series():
- recurring_reservation = RecurringReservationFactory.build()
+ series = RecurringReservationFactory.build()
with patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_204_NO_CONTENT)) as patch:
- PindoraClient.delete_reservation_series(recurring_reservation)
+ PindoraClient.delete_reservation_series(series)
assert patch.call_count == 1
@@ -352,19 +381,21 @@ def test_pindora_client__delete_reservation_series():
})
)
def test_pindora_client__delete_reservation_series__errors(status_code, exception, error_msg):
- recurring_reservation = RecurringReservationFactory.build()
+ series = RecurringReservationFactory.build()
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=status_code))
with patch, pytest.raises(exception, match=exact(error_msg) if error_msg else None):
- PindoraClient.delete_reservation_series(recurring_reservation)
+ PindoraClient.delete_reservation_series(series)
def test_pindora_client__change_reservation_series_access_code():
- recurring_reservation = RecurringReservationFactory.build()
+ series = RecurringReservationFactory.build()
- with patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_204_NO_CONTENT)) as patch:
- PindoraClient.change_reservation_series_access_code(recurring_reservation)
+ data = default_access_code_modify_response()
+
+ with patch_method(PindoraClient.request, return_value=ResponseMock(json_data=data)) as patch:
+ PindoraClient.change_reservation_series_access_code(series)
assert patch.call_count == 1
@@ -390,19 +421,19 @@ def test_pindora_client__change_reservation_series_access_code():
})
)
def test_pindora_client__change_reservation_series_access_code__errors(status_code, exception, error_msg):
- recurring_reservation = RecurringReservationFactory.build()
+ seres = RecurringReservationFactory.build()
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=status_code))
with patch, pytest.raises(exception, match=exact(error_msg) if error_msg else None):
- PindoraClient.change_reservation_series_access_code(recurring_reservation)
+ PindoraClient.change_reservation_series_access_code(seres)
def test_pindora_client__activate_reservation_series_access_code():
- recurring_reservation = RecurringReservationFactory.build()
+ series = RecurringReservationFactory.build()
with patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_204_NO_CONTENT)) as patch:
- PindoraClient.activate_reservation_series_access_code(recurring_reservation)
+ PindoraClient.activate_reservation_series_access_code(series)
assert patch.call_count == 1
@@ -428,19 +459,19 @@ def test_pindora_client__activate_reservation_series_access_code():
})
)
def test_pindora_client__activate_reservation_series_access_code__errors(status_code, exception, error_msg):
- recurring_reservation = RecurringReservationFactory.build()
+ series = RecurringReservationFactory.build()
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=status_code))
with patch, pytest.raises(exception, match=exact(error_msg) if error_msg else None):
- PindoraClient.activate_reservation_series_access_code(recurring_reservation)
+ PindoraClient.activate_reservation_series_access_code(series)
def test_pindora_client__deactivate_reservation_series_access_code():
- recurring_reservation = RecurringReservationFactory.build()
+ series = RecurringReservationFactory.build()
with patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_204_NO_CONTENT)) as patch:
- PindoraClient.deactivate_reservation_series_access_code(recurring_reservation)
+ PindoraClient.deactivate_reservation_series_access_code(series)
assert patch.call_count == 1
@@ -466,9 +497,9 @@ def test_pindora_client__deactivate_reservation_series_access_code():
})
)
def test_pindora_client__deactivate_reservation_series_access_code__errors(status_code, exception, error_msg):
- recurring_reservation = RecurringReservationFactory.build()
+ series = RecurringReservationFactory.build()
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=status_code))
with patch, pytest.raises(exception, match=exact(error_msg) if error_msg else None):
- PindoraClient.deactivate_reservation_series_access_code(recurring_reservation)
+ PindoraClient.deactivate_reservation_series_access_code(series)
diff --git a/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_seasonal_booking.py b/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_seasonal_booking.py
index 258e0b902d..5c983def45 100644
--- a/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_seasonal_booking.py
+++ b/backend/tests/test_integrations/test_keyless_entry/test_pindora_client/test_seasonal_booking.py
@@ -14,7 +14,7 @@
HTTP_500_INTERNAL_SERVER_ERROR,
)
-from tilavarauspalvelu.enums import ReservationStateChoice
+from tilavarauspalvelu.enums import AccessType, ReservationStateChoice, ReservationTypeChoice
from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
from tilavarauspalvelu.integrations.keyless_entry.exceptions import (
PindoraBadRequestError,
@@ -36,7 +36,7 @@
)
from tests.helpers import ResponseMock, exact, patch_method, use_retries
-from .helpers import ErrorParams, default_seasonal_booking_response
+from .helpers import ErrorParams, default_access_code_modify_response, default_seasonal_booking_response
def test_pindora_client__get_seasonal_booking():
@@ -155,6 +155,8 @@ def test_pindora_client__create_seasonal_booking(is_active: bool):
created_at=local_datetime(),
user=application_section.application.user,
state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ access_type=AccessType.ACCESS_CODE,
)
data = default_seasonal_booking_response(reservation, access_code_is_active=is_active)
@@ -217,6 +219,8 @@ def test_pindora_client__create_seasonal_booking__errors(status_code, exception,
created_at=local_datetime(),
user=application_section.application.user,
state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ access_type=AccessType.ACCESS_CODE,
)
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=status_code))
@@ -231,7 +235,7 @@ def test_pindora_client__create_seasonal_booking__no_reservations():
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_200_OK))
- msg = f"No confirmed reservations in seasonal booking '{application_section.ext_uuid}'."
+ msg = f"No reservations require an access code in seasonal booking '{application_section.ext_uuid}'."
with patch, pytest.raises(PindoraClientError, match=exact(msg)):
PindoraClient.create_seasonal_booking(application_section)
@@ -252,7 +256,7 @@ def test_pindora_client__create_seasonal_booking__no_confirmed_reservations():
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_200_OK))
- msg = f"No confirmed reservations in seasonal booking '{application_section.ext_uuid}'."
+ msg = f"No reservations require an access code in seasonal booking '{application_section.ext_uuid}'."
with patch, pytest.raises(PindoraClientError, match=exact(msg)):
PindoraClient.create_seasonal_booking(application_section)
@@ -269,9 +273,13 @@ def test_pindora_client__reschedule_seasonal_booking():
created_at=local_datetime(),
user=application_section.application.user,
state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ access_type=AccessType.ACCESS_CODE,
)
- with patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_204_NO_CONTENT)) as patch:
+ data = default_access_code_modify_response()
+
+ with patch_method(PindoraClient.request, return_value=ResponseMock(json_data=data)) as patch:
PindoraClient.reschedule_seasonal_booking(application_section)
assert patch.call_count == 1
@@ -309,6 +317,8 @@ def test_pindora_client__reschedule_seasonal_booking__errors(status_code, except
created_at=local_datetime(),
user=application_section.application.user,
state=ReservationStateChoice.CONFIRMED,
+ type=ReservationTypeChoice.NORMAL,
+ access_type=AccessType.ACCESS_CODE,
)
patch = patch_method(PindoraClient.request, return_value=ResponseMock(status_code=status_code))
@@ -390,7 +400,9 @@ def test_pindora_client__delete_seasonal_booking__errors(status_code, exception,
def test_pindora_client__change_seasonal_booking_access_code():
application_section = ApplicationSectionFactory.build()
- with patch_method(PindoraClient.request, return_value=ResponseMock(status_code=HTTP_204_NO_CONTENT)) as patch:
+ data = default_access_code_modify_response()
+
+ with patch_method(PindoraClient.request, return_value=ResponseMock(json_data=data)) as patch:
PindoraClient.change_seasonal_booking_access_code(application_section)
assert patch.call_count == 1
diff --git a/backend/tests/test_models/test_recurring_reservation.py b/backend/tests/test_models/test_recurring_reservation.py
new file mode 100644
index 0000000000..e12d8e444e
--- /dev/null
+++ b/backend/tests/test_models/test_recurring_reservation.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+import pytest
+from lookup_property import L
+
+from tilavarauspalvelu.enums import AccessType
+from tilavarauspalvelu.models import RecurringReservation
+
+from tests.factories import RecurringReservationFactory, ReservationFactory
+
+pytestmark = [
+ pytest.mark.django_db,
+]
+
+
+def test__recurring_reservation__access_type__single():
+ series = RecurringReservationFactory.create()
+ ReservationFactory.create(recurring_reservation=series, access_type=AccessType.ACCESS_CODE)
+
+ # Test non-ORM code
+ assert series.access_type == AccessType.ACCESS_CODE
+ assert series.used_access_types == [AccessType.ACCESS_CODE]
+
+ series = RecurringReservation.objects.annotate(
+ used_access_types=L("used_access_types"),
+ access_type=L("access_type"),
+ ).first()
+
+ # Test ORM code
+ assert series.access_type == AccessType.ACCESS_CODE
+ assert series.used_access_types == [AccessType.ACCESS_CODE]
+
+
+def test__recurring_reservation__access_type__zero():
+ series = RecurringReservationFactory.create()
+
+ # Test non-ORM code
+ assert series.access_type == AccessType.UNRESTRICTED
+ assert series.used_access_types == []
+
+ series = RecurringReservation.objects.annotate(
+ used_access_types=L("used_access_types"),
+ access_type=L("access_type"),
+ ).first()
+
+ # Test ORM code
+ assert series.access_type == AccessType.UNRESTRICTED
+ assert series.used_access_types == []
+
+
+def test__recurring_reservation__access_type__multiple_same():
+ series = RecurringReservationFactory.create()
+ ReservationFactory.create(recurring_reservation=series, access_type=AccessType.PHYSICAL_KEY)
+ ReservationFactory.create(recurring_reservation=series, access_type=AccessType.PHYSICAL_KEY)
+
+ # Test non-ORM code
+ assert series.access_type == AccessType.PHYSICAL_KEY
+ assert series.used_access_types == [AccessType.PHYSICAL_KEY]
+
+ series = RecurringReservation.objects.annotate(
+ used_access_types=L("used_access_types"),
+ access_type=L("access_type"),
+ ).first()
+
+ # Test ORM code
+ assert series.access_type == AccessType.PHYSICAL_KEY
+ assert series.used_access_types == [AccessType.PHYSICAL_KEY]
+
+
+def test__recurring_reservation__access_type__multiple_different():
+ series = RecurringReservationFactory.create()
+ ReservationFactory.create(recurring_reservation=series, access_type=AccessType.PHYSICAL_KEY)
+ ReservationFactory.create(recurring_reservation=series, access_type=AccessType.ACCESS_CODE)
+
+ # Test non-ORM code
+ assert series.access_type == AccessType.MULTIVALUED
+ assert series.used_access_types == [AccessType.ACCESS_CODE, AccessType.PHYSICAL_KEY]
+
+ series = RecurringReservation.objects.annotate(
+ used_access_types=L("used_access_types"),
+ access_type=L("access_type"),
+ ).first()
+
+ # Test ORM code
+ assert series.access_type == AccessType.MULTIVALUED
+ assert series.used_access_types == [AccessType.ACCESS_CODE, AccessType.PHYSICAL_KEY]
diff --git a/backend/tests/test_models/test_reservation_reservee_name.py b/backend/tests/test_models/test_reservation.py
similarity index 100%
rename from backend/tests/test_models/test_reservation_reservee_name.py
rename to backend/tests/test_models/test_reservation.py
diff --git a/backend/tilavarauspalvelu/admin/application_section/admin.py b/backend/tilavarauspalvelu/admin/application_section/admin.py
index eac5d8b045..e006b0e166 100644
--- a/backend/tilavarauspalvelu/admin/application_section/admin.py
+++ b/backend/tilavarauspalvelu/admin/application_section/admin.py
@@ -114,10 +114,20 @@ class ApplicationSectionAdmin(admin.ModelAdmin):
],
},
],
+ [
+ _("Pindora information"),
+ {
+ "fields": [
+ "should_have_active_access_code",
+ "pindora_response",
+ ],
+ },
+ ],
]
readonly_fields = [
"id",
"ext_uuid",
+ "should_have_active_access_code",
]
inlines = [
SuitableTimeRangeInline,
@@ -131,6 +141,7 @@ def get_queryset(self, request: WSGIRequest) -> QuerySet:
.annotate(
status=L("status"),
application_status=L("application__status"),
+ should_have_active_access_code=L("should_have_active_access_code"),
)
.select_related(
"application",
diff --git a/backend/tilavarauspalvelu/admin/application_section/form.py b/backend/tilavarauspalvelu/admin/application_section/form.py
index 4741abe3f6..fd14670739 100644
--- a/backend/tilavarauspalvelu/admin/application_section/form.py
+++ b/backend/tilavarauspalvelu/admin/application_section/form.py
@@ -1,11 +1,13 @@
from __future__ import annotations
+import json
from typing import Any
from django import forms
from django.utils.translation import gettext_lazy as _
from tilavarauspalvelu.enums import ApplicationSectionStatusChoice
+from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
from tilavarauspalvelu.models import (
Application,
ApplicationSection,
@@ -93,6 +95,8 @@ class Meta:
class ApplicationSectionAdminForm(forms.ModelForm):
+ instance: ApplicationSection | None
+
status = forms.CharField(
widget=disabled_widget,
required=False,
@@ -113,14 +117,31 @@ class ApplicationSectionAdminForm(forms.ModelForm):
},
)
+ pindora_response = forms.CharField(
+ widget=forms.Textarea(attrs={"disabled": True, "cols": "40", "rows": "1"}),
+ required=False,
+ label=_("Pindora API response"),
+ help_text=_("Response from Pindora API"),
+ )
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
instance: ApplicationSection | None = kwargs.get("instance")
if instance:
kwargs.setdefault("initial", {})
kwargs["initial"]["status"] = ApplicationSectionStatusChoice(instance.status).label
+
self.base_fields["application"].queryset = Application.objects.select_related("user")
+
super().__init__(*args, **kwargs)
+ if getattr(self.instance, "pk", None) and self.instance.should_have_active_access_code:
+ pindora_field = self.fields["pindora_response"]
+ pindora_field.widget.attrs.update({"cols": "100", "rows": "20"})
+
+ response = PindoraClient.get_seasonal_booking(section=self.instance)
+
+ pindora_field.initial = json.dumps(response, default=str, indent=2)
+
class Meta:
model = ApplicationSection
fields = [] # Use fields from ModelAdmin
@@ -137,6 +158,7 @@ class Meta:
"application": _("Application"),
"age_group": _("Age group"),
"purpose": _("Purpose"),
+ "should_have_active_access_code": _("Should have active access code"),
}
help_texts = {
"ext_uuid": _("ID for external systems to use"),
@@ -150,4 +172,5 @@ class Meta:
"application": _("Application this section is in."),
"age_group": _("Age group for this section."),
"purpose": _("Purpose for this section."),
+ "should_have_active_access_code": _("Should this application section have an active access code?"),
}
diff --git a/backend/tilavarauspalvelu/admin/recurring_reservation/admin.py b/backend/tilavarauspalvelu/admin/recurring_reservation/admin.py
index 6baaec450a..bce3ef43dd 100644
--- a/backend/tilavarauspalvelu/admin/recurring_reservation/admin.py
+++ b/backend/tilavarauspalvelu/admin/recurring_reservation/admin.py
@@ -1,28 +1,108 @@
from __future__ import annotations
+from typing import TYPE_CHECKING
+
from django.contrib import admin
from django.contrib.admin import EmptyFieldListFilter
+from django.utils.translation import gettext_lazy as _
+from lookup_property import L
from tilavarauspalvelu.admin.reservation.admin import ReservationInline
from tilavarauspalvelu.models import RecurringReservation
+from .filters import ShouldHaveActiveAccessCodeFilter
+from .form import ReservationSeriesAdminForm
+
+if TYPE_CHECKING:
+ from django.db import models
+
+ from tilavarauspalvelu.typing import WSGIRequest
+
+
+__all__ = [
+ "RecurringReservationAdmin",
+]
+
@admin.register(RecurringReservation)
class RecurringReservationAdmin(admin.ModelAdmin):
# List
list_display = [
- "ext_uuid",
"name",
"reservation_unit",
- "allocated_time_slot",
"begin_date",
"end_date",
- "recurrence_in_days",
]
- list_filter = [("allocated_time_slot", EmptyFieldListFilter)]
+ list_filter = [
+ ("allocated_time_slot", EmptyFieldListFilter),
+ ShouldHaveActiveAccessCodeFilter,
+ ]
# Form
+ form = ReservationSeriesAdminForm
+ fieldsets = [
+ [
+ _("Basic information"),
+ {
+ "fields": [
+ "ext_uuid",
+ "name",
+ "description",
+ "created",
+ "reservation_unit",
+ "user",
+ "age_group",
+ "allocated_time_slot",
+ ],
+ },
+ ],
+ [
+ _("Time"),
+ {
+ "fields": [
+ "begin_date",
+ "begin_time",
+ "end_date",
+ "end_time",
+ "weekdays",
+ "recurrence_in_days",
+ ],
+ },
+ ],
+ [
+ _("Pindora information"),
+ {
+ "fields": [
+ "should_have_active_access_code",
+ "access_type",
+ "used_access_types",
+ "pindora_response",
+ ],
+ },
+ ],
+ ]
readonly_fields = [
"ext_uuid",
+ "created",
+ "should_have_active_access_code",
+ "access_type",
+ "used_access_types",
]
inlines = [ReservationInline]
+
+ def get_queryset(self, request: WSGIRequest) -> models.QuerySet:
+ return (
+ super()
+ .get_queryset(request)
+ .select_related(
+ "reservation_unit",
+ "allocated_time_slot",
+ "user",
+ "age_group",
+ )
+ .annotate(
+ should_have_active_access_code=L("should_have_active_access_code"),
+ access_type=L("access_type"),
+ used_access_types=L("used_access_types"),
+ )
+ )
diff --git a/backend/tilavarauspalvelu/admin/recurring_reservation/filters.py b/backend/tilavarauspalvelu/admin/recurring_reservation/filters.py
new file mode 100644
index 0000000000..e7b1375e92
--- /dev/null
+++ b/backend/tilavarauspalvelu/admin/recurring_reservation/filters.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from django.contrib import admin
+from django.utils.translation import gettext_lazy as _
+from lookup_property import L
+
+if TYPE_CHECKING:
+ from django.db.models import QuerySet
+
+ from tilavarauspalvelu.typing import WSGIRequest
+
+
+class ShouldHaveActiveAccessCodeFilter(admin.SimpleListFilter):
+ title = _("Should have active access code")
+ parameter_name = "should_have_active_access_code"
+
+ def lookups(self, *args: Any) -> list[tuple[str, str]]:
+ return [
+ ("1", _("Yes")),
+ ("0", _("No")),
+ ]
+
+ def queryset(self, request: WSGIRequest, queryset: QuerySet) -> QuerySet:
+ if self.value() == "1":
+ return queryset.filter(L(should_have_active_access_code=True))
+ if self.value() == "0":
+ return queryset.filter(L(should_have_active_access_code=False))
+ return queryset
diff --git a/backend/tilavarauspalvelu/admin/recurring_reservation/form.py b/backend/tilavarauspalvelu/admin/recurring_reservation/form.py
new file mode 100644
index 0000000000..589a3bbe46
--- /dev/null
+++ b/backend/tilavarauspalvelu/admin/recurring_reservation/form.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
+from tilavarauspalvelu.models import RecurringReservation
+
+
+class ReservationSeriesAdminForm(forms.ModelForm):
+ instance: RecurringReservation
+
+ weekdays = forms.CharField(
+ label=_("Weekdays"),
+ help_text=_(
+ "Comma separated list of weekday integers (0-6) on which reservations exists on this recurring reservation"
+ ),
+ )
+
+ pindora_response = forms.CharField(
+ widget=forms.Textarea(attrs={"disabled": True, "cols": "40", "rows": "1"}),
+ required=False,
+ label=_("Pindora API response"),
+ help_text=_("Response from Pindora API"),
+ )
+
+ class Meta:
+ model = RecurringReservation
+ fields = [] # Use fields from ModelAdmin
+ labels = {
+ "ext_uuid": _("External UUID"),
+ "name": _("Name"),
+ "description": _("Description"),
+ "begin_date": _("Begin date"),
+ "begin_time": _("Begin time"),
+ "end_date": _("End date"),
+ "end_time": _("End time"),
+ "recurrence_in_days": _("Recurrence in days"),
+ "created": _("Created"),
+ "user": _("User"),
+ "reservation_unit": _("Reservation unit"),
+ "allocated_time_slot": _("Allocated time slot"),
+ "age_group": _("Age group"),
+ "should_have_active_access_code": _("Should have active access code"),
+ "access_type": _("Access type"),
+ "used_access_types": _("Used access types"),
+ }
+ help_texts = {
+ "ext_uuid": _("ID for external systems to use"),
+ "name": _("Name of the recurring reservation"),
+ "description": _("Description of the recurring reservation"),
+ "begin_date": _("Begin date of the recurring reservation"),
+ "begin_time": _("Begin time of reservations in this recurring reservation"),
+ "end_date": _("End date of the recurring reservation"),
+ "end_time": _("End time of reservations in this recurring reservation"),
+ "recurrence_in_days": _("Interval between reservations in this recurring reservation"),
+ "created": _("When this recurring reservation was created"),
+ "user": _("User that created this recurring reservation"),
+ "reservation_unit": _("Reservation unit for this recurring reservation"),
+ "allocated_time_slot": _("Allocated time slot this recurring reservation is for"),
+ "age_group": _("Age group for this recurring reservation"),
+ "should_have_active_access_code": _("Should this recurring reservation have an active access code?"),
+ "access_type": _(
+ "Access type for the reservations in this recurring reservation (if unambiguous), "
+ "otherwise access type will be 'multi-valued'"
+ ),
+ "used_access_types": _("All unique access types used in the reservations of this recurring reservation"),
+ }
+ widgets = {
+ "description": forms.Textarea(attrs={"rows": 4}),
+ }
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ super().__init__(*args, **kwargs)
+
+ if getattr(self.instance, "pk", None) and self.instance.should_have_active_access_code:
+ pindora_field = self.fields["pindora_response"]
+ pindora_field.widget.attrs.update({"cols": "100", "rows": "20"})
+
+ if self.instance.allocated_time_slot is None:
+ response = PindoraClient.get_reservation_series(series=self.instance)
+
+ pindora_field.initial = json.dumps(response, default=str, indent=2)
+
+ else:
+ section = self.instance.allocated_time_slot.reservation_unit_option.application_section
+ response = PindoraClient.get_seasonal_booking(section=section)
+
+ # Only show validity for this series's reservation unit (might not match one-to-one with the series)
+ response["reservation_unit_code_validity"] = [
+ item
+ for item in response["reservation_unit_code_validity"]
+ if item["reservation_unit_id"] == self.instance.reservation_unit.uuid
+ ]
+
+ pindora_field.initial = json.dumps(response, default=str, indent=2)
diff --git a/backend/tilavarauspalvelu/admin/reservation/admin.py b/backend/tilavarauspalvelu/admin/reservation/admin.py
index e188620ec2..4ebd8e6d58 100644
--- a/backend/tilavarauspalvelu/admin/reservation/admin.py
+++ b/backend/tilavarauspalvelu/admin/reservation/admin.py
@@ -6,6 +6,7 @@
from django.contrib.admin import helpers
from django.template.response import TemplateResponse
from django.utils.translation import gettext_lazy as _
+from lookup_property import L
from more_admin_filters import MultiSelectFilter
from more_admin_filters.filters import MultiSelectRelatedOnlyDropdownFilter
from rangefilter.filters import DateRangeFilterBuilder
@@ -92,6 +93,7 @@ class ReservationAdmin(admin.ModelAdmin):
PaidReservationListFilter,
("reservation_units__unit", MultiSelectRelatedOnlyDropdownFilter),
("reservation_units", MultiSelectRelatedOnlyDropdownFilter),
+ "access_type",
]
# Form
@@ -181,6 +183,10 @@ class ReservationAdmin(admin.ModelAdmin):
_("Pindora information"),
{
"fields": [
+ "access_type",
+ "access_code_is_active",
+ "access_code_should_be_active",
+ "access_code_generated_at",
"pindora_response",
],
},
@@ -211,11 +217,20 @@ class ReservationAdmin(admin.ModelAdmin):
"created_at",
"price_net",
"non_subsidised_price_net",
+ "access_code_is_active",
+ "access_code_should_be_active",
+ "access_code_generated_at",
+ "access_type",
]
inlines = [PaymentOrderInline]
def get_queryset(self, request: WSGIRequest) -> QuerySet[Reservation]:
- return super().get_queryset(request).prefetch_related("reservation_units")
+ return (
+ super()
+ .get_queryset(request)
+ .annotate(access_code_should_be_active=L("access_code_should_be_active"))
+ .prefetch_related("reservation_units")
+ )
def get_search_results(
self,
diff --git a/backend/tilavarauspalvelu/admin/reservation/form.py b/backend/tilavarauspalvelu/admin/reservation/form.py
index c709f73467..a27fe511e3 100644
--- a/backend/tilavarauspalvelu/admin/reservation/form.py
+++ b/backend/tilavarauspalvelu/admin/reservation/form.py
@@ -8,15 +8,21 @@
from tilavarauspalvelu.enums import AccessType
from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
+from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraClientError
from tilavarauspalvelu.models import Reservation
+from utils.date_utils import DEFAULT_TIMEZONE
class ReservationAdminForm(forms.ModelForm):
instance: Reservation
+ access_type = forms.ChoiceField(choices=AccessType.model_choices, required=False)
+
pindora_response = forms.CharField(
widget=forms.Textarea(attrs={"disabled": True, "cols": "40", "rows": "1"}),
required=False,
+ label=_("Pindora API response"),
+ help_text=_("Response from Pindora API"),
)
class Meta:
@@ -43,6 +49,11 @@ class Meta:
"confirmed_at": _("Confirmed at"),
"created_at": _("Created at"),
#
+ "access_type": _("Access type"),
+ "access_code_is_active": _("Access code is active"),
+ "access_code_should_be_active": _("Access code should be active"),
+ "access_code_generated_at": _("Access code generated at"),
+ #
"price": _("Price"),
"price_net": _("Price net"),
"non_subsidised_price": _("Non-subsidised price"),
@@ -73,8 +84,6 @@ class Meta:
"billing_address_city": _("Billing address city"),
"billing_address_zip": _("Billing address zip code"),
#
- "pindora_response": _("Pindora API response"),
- #
"user": _("User"),
"recurring_reservation": _("Recurring reservation"),
"deny_reason": _("Reason for deny"),
@@ -104,6 +113,11 @@ class Meta:
"confirmed_at": _("When this reservation was confirmed"),
"created_at": _("When this reservation was created"),
#
+ "access_type": _("Access type"),
+ "access_code_is_active": _("Access code is active"),
+ "access_code_should_be_active": _("Access code should be active"),
+ "access_code_generated_at": _("Access code generated at"),
+ #
"price": _("The price of this particular reservation including VAT"),
"price_net": _("The price of this particular reservation excluding VAT"),
"non_subsidised_price": _("The non subsidised price of this reservation including VAT"),
@@ -134,8 +148,6 @@ class Meta:
"billing_address_city": _("Billing address city"),
"billing_address_zip": _("Billing address zip code"),
#
- "pindora_response": _("Response from Pindora API"),
- #
"user": _("User who made the reservation"),
"recurring_reservation": _("Recurring reservation"),
"deny_reason": _("Reason for denying the reservation"),
@@ -148,18 +160,50 @@ class Meta:
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
- if (
- self.instance
- and self.instance.recurring_reservation is None
- and self.instance.access_type == AccessType.ACCESS_CODE
- ):
+ if getattr(self.instance, "pk", None) and self.instance.access_type == AccessType.ACCESS_CODE:
pindora_field = self.fields["pindora_response"]
- pindora_field.initial = self.get_pindora_response(self.instance)
pindora_field.widget.attrs.update({"cols": "100", "rows": "20"})
+ pindora_field.initial = self.get_pindora_response()
+
+ def get_pindora_response(self) -> str | None:
+ if self.instance.recurring_reservation is None:
+ try:
+ response = PindoraClient.get_reservation(reservation=self.instance)
+ except PindoraClientError as error:
+ return str(error.msg)
+ return json.dumps(response, default=str, indent=2)
+
+ if self.instance.recurring_reservation.allocated_time_slot is None:
+ try:
+ response = PindoraClient.get_reservation_series(series=self.instance.recurring_reservation)
+ except PindoraClientError as error:
+ return str(error.msg)
+
+ # Only show the validity for this reservation
+ response["reservation_unit_code_validity"] = [
+ item
+ for item in response["reservation_unit_code_validity"]
+ if item["begin"] == self.instance.begin.astimezone(DEFAULT_TIMEZONE)
+ and item["end"] == self.instance.end.astimezone(DEFAULT_TIMEZONE)
+ ]
+
+ return json.dumps(response, default=str, indent=2)
+
+ allocation = self.instance.recurring_reservation.allocated_time_slot
+ section = allocation.reservation_unit_option.application_section
+
+ try:
+ response = PindoraClient.get_seasonal_booking(section=section)
+ except PindoraClientError as error:
+ return str(error.msg)
- def get_pindora_response(self, obj: Reservation) -> str | None:
- if obj.access_type != AccessType.ACCESS_CODE:
- return None
+ # Only show the validity for this reservation
+ response["reservation_unit_code_validity"] = [
+ item
+ for item in response["reservation_unit_code_validity"]
+ if item["begin"] == self.instance.begin.astimezone(DEFAULT_TIMEZONE)
+ and item["end"] == self.instance.end.astimezone(DEFAULT_TIMEZONE)
+ and item["reservation_unit_id"] == self.instance.recurring_reservation.reservation_unit.uuid
+ ]
- response = PindoraClient.get_reservation(reservation=obj)
return json.dumps(response, default=str, indent=2)
diff --git a/backend/tilavarauspalvelu/admin/reservation_unit/admin.py b/backend/tilavarauspalvelu/admin/reservation_unit/admin.py
index c534c6fa89..4fb7fd88e9 100644
--- a/backend/tilavarauspalvelu/admin/reservation_unit/admin.py
+++ b/backend/tilavarauspalvelu/admin/reservation_unit/admin.py
@@ -16,10 +16,10 @@
from tilavarauspalvelu.enums import ReservationKind
from tilavarauspalvelu.integrations.opening_hours.hauki_resource_hash_updater import HaukiResourceHashUpdater
from tilavarauspalvelu.integrations.sentry import SentryLogger
-from tilavarauspalvelu.models import ReservationUnit
+from tilavarauspalvelu.models import ReservationUnit, ReservationUnitAccessType
from tilavarauspalvelu.services.export import ReservationUnitExporter
-from .form import ReservationUnitAdminForm
+from .form import ReservationUnitAccessTypeForm, ReservationUnitAccessTypeFormSet, ReservationUnitAdminForm
if TYPE_CHECKING:
from django import forms
@@ -46,6 +46,13 @@ def reservation_unit_link(self, obj) -> str:
return format_html(f"{obj.name_fi}")
+class ReservationUnitAccessTypeInline(admin.TabularInline):
+ model = ReservationUnitAccessType
+ form = ReservationUnitAccessTypeForm
+ formset = ReservationUnitAccessTypeFormSet
+ extra = 0
+
+
@admin.register(ReservationUnit)
class ReservationUnitAdmin(SortableAdminMixin, TabbedTranslationAdmin):
# Functions
@@ -172,12 +179,9 @@ class ReservationUnitAdmin(SortableAdminMixin, TabbedTranslationAdmin):
},
],
[
- _("Access type"),
+ _("Pindora information"),
{
"fields": [
- "access_type",
- "access_type_start_date",
- "access_type_end_date",
"pindora_response",
],
},
@@ -210,6 +214,7 @@ class ReservationUnitAdmin(SortableAdminMixin, TabbedTranslationAdmin):
ReservationUnitImageInline,
ReservationUnitPricingInline,
ApplicationRoundTimeSlotInline,
+ ReservationUnitAccessTypeInline,
]
@admin.display(description=_("Publishing state"), ordering=L("publishing_state"))
diff --git a/backend/tilavarauspalvelu/admin/reservation_unit/form.py b/backend/tilavarauspalvelu/admin/reservation_unit/form.py
index 63bbd74a69..ac7e3b097c 100644
--- a/backend/tilavarauspalvelu/admin/reservation_unit/form.py
+++ b/backend/tilavarauspalvelu/admin/reservation_unit/form.py
@@ -4,23 +4,112 @@
from typing import TYPE_CHECKING, Any
from django import forms
+from django.core.exceptions import ValidationError
from django.db import transaction
+from django.forms.formsets import DELETION_FIELD_NAME
+from django.forms.models import BaseInlineFormSet
from django.utils.translation import gettext_lazy as _
from subforms.fields import DynamicArrayField
from tinymce.widgets import TinyMCE
+from tilavarauspalvelu.api.graphql.extensions import error_codes
from tilavarauspalvelu.enums import AccessType, TermsOfUseTypeChoices
from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
-from tilavarauspalvelu.models import ReservationUnit, TermsOfUse
+from tilavarauspalvelu.models import ReservationUnit, ReservationUnitAccessType, TermsOfUse
+from utils.date_utils import local_date
from utils.external_service.errors import ExternalServiceError
+from utils.utils import only_django_validation_errors
if TYPE_CHECKING:
import datetime
+class ReservationUnitAccessTypeForm(forms.ModelForm):
+ instance: ReservationUnitAccessType
+
+ class Meta:
+ model = ReservationUnitAccessType
+ fields = ["access_type", "begin_date"]
+ labels = {
+ "access_type": _("Access type"),
+ "begin_date": _("Access type begin date"),
+ }
+ help_texts = {
+ "access_type": _("How is the reservee able to enter the space in their reservation unit?"),
+ "begin_date": _("Begin date of this access type"),
+ }
+
+ @only_django_validation_errors()
+ def clean(self) -> None:
+ cleaned_data = super().clean()
+ if not cleaned_data:
+ return
+
+ editing: bool = getattr(self.instance, "pk", None) is not None
+
+ if editing and cleaned_data.get(DELETION_FIELD_NAME, False):
+ self.validate_deletion()
+ return
+
+ access_type: str = cleaned_data["access_type"]
+ begin_date: datetime.date = cleaned_data["begin_date"]
+ reservation_unit: ReservationUnit = cleaned_data["reservation_unit"]
+
+ if editing:
+ self.instance.validator.validate_not_past(begin_date)
+ self.instance.validator.validate_not_moved_to_past(begin_date)
+ else:
+ ReservationUnitAccessType.validator.validate_new_not_in_past(begin_date)
+
+ if reservation_unit.pk is None:
+ ReservationUnitAccessType.validator.validate_not_access_code(access_type)
+
+ need_to_check_pindora = access_type == AccessType.ACCESS_CODE and (
+ not editing or self.instance.access_type != AccessType.ACCESS_CODE
+ )
+
+ if need_to_check_pindora:
+ try:
+ PindoraClient.get_reservation_unit(reservation_unit)
+ except ExternalServiceError as error:
+ self.add_error("access_type", str(error))
+ return
+
+ def validate_deletion(self) -> None:
+ self.instance.validator.validate_deleted_not_active_or_past()
+
+
+class ReservationUnitAccessTypeFormSet(BaseInlineFormSet):
+ form = ReservationUnitAccessTypeForm
+
+ def clean(self) -> None:
+ today = local_date()
+ only_begun = (True for form in self.forms if form.cleaned_data["begin_date"] <= today)
+ has_active = next(only_begun, None) is not None
+ if not has_active:
+ msg = "At least one active access type is required."
+ raise ValidationError(msg, code=error_codes.RESERVATION_UNIT_MISSING_ACTIVE_ACCESS_TYPE)
+
+ @transaction.atomic
+ def save(self, commit: bool = True) -> list[ReservationUnitAccessType]: # noqa: FBT001, FBT002
+ access_types = super().save(commit=commit)
+ if not access_types:
+ return access_types
+
+ reservation_unit: ReservationUnit = access_types[0].reservation_unit
+ reservation_unit.actions.update_access_types_for_reservations()
+ return access_types
+
+ def _should_delete_form(self, form: ReservationUnitAccessTypeForm) -> bool:
+ # Required so errors from `validate_deletion` are not ignored.
+ return (not form.errors) and super()._should_delete_form(form)
+
+
class ReservationUnitAdminForm(forms.ModelForm):
instance: ReservationUnit
+ access_type = forms.ChoiceField(choices=AccessType.model_choices, required=False)
+
pindora_response = forms.CharField(
widget=forms.Textarea(attrs={"disabled": True, "cols": "40", "rows": "1"}),
required=False,
@@ -119,9 +208,6 @@ class Meta:
"payment_accounting": _("Payment accounting"),
"payment_product": _("Payment product"),
"search_terms": _("Search terms"),
- "access_type": _("Access type"),
- "access_type_start_date": _("Access type start date"),
- "access_type_end_date": _("Access type end date"),
"pindora_response": _("Pindora API response"),
}
help_texts = {
@@ -213,15 +299,6 @@ class Meta:
"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."
),
- "access_type": _("How is the reservee able to enter the space in their reservation unit?"),
- "access_type_start_date": _(
- "If set, this is the date from which the access type is used. If current date is "
- "before this date, the access type is 'unrestricted'."
- ),
- "access_type_end_date": _(
- "If set, this is the date before which the access type is used. If current date is "
- "after this date, the access type is 'unrestricted'."
- ),
"pindora_response": _("Response from Pindora API"),
}
@@ -234,13 +311,15 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
- if self.instance and self.instance.access_type == AccessType.ACCESS_CODE:
+ editing: bool = getattr(self.instance, "pk", None) is not None
+
+ if editing and self.instance.current_access_type == AccessType.ACCESS_CODE:
pindora_field = self.fields["pindora_response"]
pindora_field.initial = self.get_pindora_response(self.instance)
pindora_field.widget.attrs.update({"cols": "100", "rows": "10"})
def get_pindora_response(self, obj: ReservationUnit) -> str | None:
- if obj.access_type != AccessType.ACCESS_CODE:
+ if obj.current_access_type != AccessType.ACCESS_CODE:
return None
response = PindoraClient.get_reservation_unit(reservation_unit=obj)
@@ -249,33 +328,3 @@ def get_pindora_response(self, obj: ReservationUnit) -> str | None:
return None
return json.dumps(response, default=str, indent=2)
-
- def clean(self) -> None:
- cleaned_data = super().clean()
- if not cleaned_data:
- return
-
- access_type: str | None = cleaned_data.get("access_type")
-
- # Check if reservation unit has been configured in Pindora.
- # No need to check if access type is already 'ACCESS_CODE'.
- if access_type == AccessType.ACCESS_CODE and self.instance.access_type != AccessType.ACCESS_CODE:
- try:
- PindoraClient.get_reservation_unit(self.instance)
- except ExternalServiceError as error:
- self.add_error("access_type", str(error))
- return
-
- @transaction.atomic
- def save(self, commit: bool = True) -> ReservationUnit: # noqa: FBT001, FBT002
- access_type: str = self.cleaned_data.get("access_type")
- access_type_start_date: datetime.date | None = self.cleaned_data.get("access_type_start_date")
- access_type_end_date: datetime.date | None = self.cleaned_data.get("access_type_end_date")
-
- reservation_unit: ReservationUnit = super().save(commit=commit)
- reservation_unit.actions.update_access_type_for_reservations(
- access_type=AccessType(access_type),
- access_type_start_date=access_type_start_date,
- access_type_end_date=access_type_end_date,
- )
- return reservation_unit
diff --git a/backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py b/backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py
index c4656077f9..4846ab2f44 100644
--- a/backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py
+++ b/backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py
@@ -7,6 +7,11 @@
#
from __future__ import annotations
+ACCESS_TYPE_ACCESS_CODE_ON_CREATE = "ACCESS_TYPE_ACCESS_CODE_ON_CREATE"
+ACCESS_TYPE_BEGIN_DATE_IN_PAST = "RESERVATION_UNIT_ACCESS_TYPE_BEGIN_DATE_IN_PAST"
+ACCESS_TYPE_CANNOT_BE_MOVED = "RESERVATION_UNIT_ACCESS_TYPE_CANNOT_BE_MOVED"
+ACCESS_TYPE_CANNOT_DELETE_LAST_WITH_FUTURE_RESERVATIONS = "ACCESS_TYPE_CANNOT_DELETE_LAST_WITH_FUTURE_RESERVATIONS"
+ACCESS_TYPE_CANNOT_DELETE_PAST_OR_ACTIVE = "RESERVATION_UNIT_ACCESS_TYPE_CANNOT_DELETE_PAST_OR_ACTIVE"
ALLOCATION_APPLICATION_STATUS_NOT_ALLOWED = "ALLOCATION_APPLICATION_STATUS_NOT_ALLOWED"
ALLOCATION_APPLIED_RESERVATIONS_PER_WEEK_EXCEEDED = "ALLOCATION_APPLIED_RESERVATIONS_PER_WEEK_EXCEEDED"
ALLOCATION_DAY_OF_THE_WEEK_NOT_SUITABLE = "ALLOCATION_DAY_OF_THE_WEEK_NOT_SUITABLE"
@@ -109,6 +114,7 @@
RESERVATION_UNIT_ADULT_RESERVEE_REQUIRED = "RESERVATION_UNIT_ADULT_RESERVEE_REQUIRED"
RESERVATION_UNIT_FIRST_RESERVABLE_DATETIME_NOT_CALCULATED = "RESERVATION_UNIT_FIRST_RESERVABLE_DATETIME_NOT_CALCULATED"
RESERVATION_UNIT_HAS_FUTURE_RESERVATIONS = "RESERVATION_UNIT_HAS_FUTURE_RESERVATIONS"
+RESERVATION_UNIT_HAS_NO_ACCESS_TYPE = "RESERVATION_UNIT_HAS_NO_ACCESS_TYPE"
RESERVATION_UNIT_IN_OPEN_ROUND = "RESERVATION_UNIT_IN_OPEN_ROUND"
RESERVATION_UNIT_MAX_NUMBER_OF_RESERVATIONS_EXCEEDED = "RESERVATION_UNIT_MAX_NUMBER_OF_RESERVATIONS_EXCEEDED"
RESERVATION_UNIT_MAX_RESERVATION_DURATION_INVALID = "RESERVATION_UNIT_MAX_RESERVATION_DURATION_INVALID"
@@ -116,6 +122,7 @@
RESERVATION_UNIT_MIN_MAX_RESERVATION_DURATIONS_INVALID = "RESERVATION_UNIT_MIN_MAX_RESERVATION_DURATIONS_INVALID"
RESERVATION_UNIT_MIN_PERSONS_GREATER_THAN_MAX_PERSONS = "RESERVATION_UNIT_MIN_PERSONS_GREATER_THAN_MAX_PERSONS"
RESERVATION_UNIT_MIN_RESERVATION_DURATION_INVALID = "RESERVATION_UNIT_MIN_RESERVATION_DURATION_INVALID"
+RESERVATION_UNIT_MISSING_ACTIVE_ACCESS_TYPE = "RESERVATION_UNIT_MISSING_ACTIVE_ACCESS_TYPE"
RESERVATION_UNIT_MISSING_RESERVATION_UNIT_TYPE = "RESERVATION_UNIT_MISSING_RESERVATION_UNIT_TYPE"
RESERVATION_UNIT_MISSING_SPACES_OR_RESOURCES = "RESERVATION_UNIT_MISSING_SPACES_OR_RESOURCES"
RESERVATION_UNIT_MISSING_TRANSLATIONS = "RESERVATION_UNIT_MISSING_TRANSLATIONS"
diff --git a/backend/tilavarauspalvelu/api/graphql/queries.py b/backend/tilavarauspalvelu/api/graphql/queries.py
index 8fe63fbc49..ab6509efe1 100644
--- a/backend/tilavarauspalvelu/api/graphql/queries.py
+++ b/backend/tilavarauspalvelu/api/graphql/queries.py
@@ -35,6 +35,7 @@
from .types.reservation_metadata.types import ReservationMetadataFieldNode, ReservationMetadataSetNode
from .types.reservation_purpose.types import ReservationPurposeNode
from .types.reservation_unit.types import ReservationUnitAllNode, ReservationUnitNode
+from .types.reservation_unit_access_type.types import ReservationUnitAccessTypeNode
from .types.reservation_unit_cancellation_rule.types import ReservationUnitCancellationRuleNode
from .types.reservation_unit_image.types import ReservationUnitImageNode
from .types.reservation_unit_option.types import ReservationUnitOptionNode
@@ -84,6 +85,7 @@
"ReservationMetadataSetNode",
"ReservationNode",
"ReservationPurposeNode",
+ "ReservationUnitAccessTypeNode",
"ReservationUnitAllNode",
"ReservationUnitCancellationRuleNode",
"ReservationUnitImageNode",
diff --git a/backend/tilavarauspalvelu/api/graphql/types/application_section/types.py b/backend/tilavarauspalvelu/api/graphql/types/application_section/types.py
index b3eb6e54db..b4747eb64a 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/application_section/types.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/application_section/types.py
@@ -1,16 +1,20 @@
from __future__ import annotations
+import datetime
from typing import TYPE_CHECKING, Any
import graphene
from django.db import models
from graphene_django_extensions import DjangoNode
from lookup_property import L
-from query_optimizer import AnnotatedField, ManuallyOptimizedField
+from query_optimizer import AnnotatedField, ManuallyOptimizedField, MultiField
from query_optimizer.optimizer import QueryOptimizer
from tilavarauspalvelu.enums import ApplicationSectionStatusChoice, UserRoleChoice
-from tilavarauspalvelu.models import Application, ApplicationSection, Reservation, User
+from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
+from tilavarauspalvelu.models import Application, ApplicationSection, RecurringReservation, Reservation, User
+from tilavarauspalvelu.typing import PindoraSectionInfoData
+from utils.date_utils import local_date
from .filtersets import ApplicationSectionFilterSet
from .permissions import ApplicationSectionPermission
@@ -18,7 +22,31 @@
if TYPE_CHECKING:
from tilavarauspalvelu.models.application.queryset import ApplicationQuerySet
from tilavarauspalvelu.models.application_section.queryset import ApplicationSectionQuerySet
- from tilavarauspalvelu.typing import GQLInfo
+ from tilavarauspalvelu.typing import GQLInfo, PindoraValidityInfoData
+
+__all__ = [
+ "ApplicationSectionNode",
+]
+
+
+class PindoraSectionValidityInfoType(graphene.ObjectType):
+ reservation_id = graphene.Int(required=True)
+ reservation_series_id = graphene.Int(required=True)
+ access_code_begins_at = graphene.DateTime(required=True)
+ access_code_ends_at = graphene.DateTime(required=True)
+
+
+class PindoraSectionInfoType(graphene.ObjectType):
+ access_code = graphene.String(required=True)
+ access_code_generated_at = graphene.DateTime(required=True)
+ access_code_is_active = graphene.Boolean(required=True)
+
+ access_code_keypad_url = graphene.String(required=True)
+ access_code_phone_number = graphene.String(required=True)
+ access_code_sms_number = graphene.String(required=True)
+ access_code_sms_message = graphene.String(required=True)
+
+ access_code_validity = graphene.List(PindoraSectionValidityInfoType, required=True)
class ApplicationSectionNode(DjangoNode):
@@ -27,6 +55,17 @@ class ApplicationSectionNode(DjangoNode):
has_reservations = ManuallyOptimizedField(graphene.Boolean, required=True)
+ should_have_active_access_code = AnnotatedField(graphene.Boolean, expression=L("should_have_active_access_code"))
+
+ pindora_info = MultiField(
+ PindoraSectionInfoType,
+ fields=["ext_uuid", "reservations_end_date"],
+ description=(
+ "Info fetched from Pindora API. Cached per reservation for 30s. "
+ "Please don't use this when filtering multiple sections, queries to Pindora are not optimized."
+ ),
+ )
+
class Meta:
model = ApplicationSection
fields = [
@@ -46,6 +85,7 @@ class Meta:
"reservation_unit_options",
"suitable_time_ranges",
"status",
+ "should_have_active_access_code",
]
filterset_class = ApplicationSectionFilterSet
permission_classes = [ApplicationSectionPermission]
@@ -115,3 +155,48 @@ def optimize_has_reservations(queryset: models.QuerySet, optimizer: QueryOptimiz
)
return queryset
+
+ def resolve_pindora_info(root: ApplicationSection, info: GQLInfo) -> PindoraSectionInfoData | None:
+ # Not using access codes
+ if not root.should_have_active_access_code:
+ return None
+
+ # No need to show Pindora info after 24 hours have passed since the section has ended
+ today = local_date()
+ cutoff = root.reservations_end_date + datetime.timedelta(hours=24)
+ if today > cutoff:
+ return None
+
+ has_perms = info.context.user.permissions.can_manage_application(root.application, reserver_needs_role=True)
+
+ # Don't show Pindora info without permissions if the application round results haven't been sent yet
+ if not has_perms and root.application.application_round.sent_date is None:
+ return None
+
+ try:
+ response = PindoraClient.get_seasonal_booking(section=root.ext_uuid)
+ except Exception: # noqa: BLE001
+ return None
+
+ # Don't show Pindora info without permissions if the access code is not active
+ access_code_is_active = response["access_code_is_active"]
+ if not has_perms and not access_code_is_active:
+ return None
+
+ qs = RecurringReservation.objects.filter(allocated_time_slot__reservation_unit_option__application_section=root)
+
+ access_code_validity: list[PindoraValidityInfoData] = []
+ for series in qs:
+ validity = series.actions.get_access_code_validity_info(response["reservation_unit_code_validity"])
+ access_code_validity.extend(validity)
+
+ return PindoraSectionInfoData(
+ access_code=response["access_code"],
+ access_code_generated_at=response["access_code_generated_at"],
+ access_code_is_active=response["access_code_is_active"],
+ access_code_keypad_url=response["access_code_keypad_url"],
+ access_code_phone_number=response["access_code_phone_number"],
+ access_code_sms_number=response["access_code_sms_number"],
+ access_code_sms_message=response["access_code_sms_message"],
+ access_code_validity=access_code_validity,
+ )
diff --git a/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers.py b/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers.py
index 54aba46f11..3cfdf99137 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers.py
@@ -21,6 +21,7 @@
WeekdayChoice,
)
from tilavarauspalvelu.integrations.email.main import EmailService
+from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
from tilavarauspalvelu.integrations.opening_hours.reservable_time_span_client import ReservableTimeSpanClient
from tilavarauspalvelu.models import RecurringReservation, Reservation, ReservationDenyReason, ReservationStatistic
from tilavarauspalvelu.models.recurring_reservation.actions import ReservationDetails
@@ -195,7 +196,7 @@ def save(self, **kwargs: Any) -> RecurringReservation:
# Create both the recurring reservation and the reservations in a transaction.
# This way if we get, e.g., overlapping reservations, the whole operation is rolled back.
with transaction.atomic():
- instance = super().save()
+ instance: RecurringReservation = super().save()
reservations = self.create_reservations(
instance=instance,
reservation_details=reservation_details,
@@ -212,6 +213,15 @@ def save(self, **kwargs: Any) -> RecurringReservation:
reservation_pks=[reservation.pk for reservation in reservations],
)
+ # Lastly, create any access codes without suppressing errors if they cannot be created,
+ # but don't fail creating the reservation series itself.
+ if instance.reservations.requires_active_access_code().exists():
+ response = PindoraClient.create_reservation_series(instance, is_active=True)
+ instance.reservations.requires_active_access_code().update(
+ access_code_generated_at=response["access_code_generated_at"],
+ access_code_is_active=response["access_code_is_active"],
+ )
+
return instance
def create_reservations(
diff --git a/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/types.py b/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/types.py
index 65f6ba2e04..57b5f60108 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/types.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/types.py
@@ -1,11 +1,18 @@
from __future__ import annotations
+import datetime
from typing import TYPE_CHECKING
import graphene
from graphene_django_extensions import DjangoNode
+from lookup_property import L
+from query_optimizer import AnnotatedField, MultiField
+from tilavarauspalvelu.enums import AccessType
+from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
from tilavarauspalvelu.models import RecurringReservation
+from tilavarauspalvelu.typing import PindoraSeriesInfoData
+from utils.date_utils import local_date
from .filtersets import RecurringReservationFilterSet
from .permissions import RecurringReservationPermission
@@ -20,9 +27,41 @@
]
+class PindoraSeriesValidityInfoType(graphene.ObjectType):
+ reservation_id = graphene.Int(required=True)
+ reservation_series_id = graphene.Int(required=True)
+ access_code_begins_at = graphene.DateTime(required=True)
+ access_code_ends_at = graphene.DateTime(required=True)
+
+
+class PindoraSeriesInfoType(graphene.ObjectType):
+ access_code = graphene.String(required=True)
+ access_code_generated_at = graphene.DateTime(required=True)
+ access_code_is_active = graphene.Boolean(required=True)
+
+ access_code_keypad_url = graphene.String(required=True)
+ access_code_phone_number = graphene.String(required=True)
+ access_code_sms_number = graphene.String(required=True)
+ access_code_sms_message = graphene.String(required=True)
+
+ access_code_validity = graphene.List(PindoraSeriesValidityInfoType, required=True)
+
+
class RecurringReservationNode(DjangoNode):
weekdays = graphene.List(graphene.Int)
+ should_have_active_access_code = AnnotatedField(graphene.Boolean, expression=L("should_have_active_access_code"))
+ access_type = AnnotatedField(graphene.Enum.from_enum(AccessType), expression=L("access_type"))
+
+ pindora_info = MultiField(
+ PindoraSeriesInfoType,
+ fields=["ext_uuid", "end_date"],
+ description=(
+ "Info fetched from Pindora API. Cached per reservation for 30s. "
+ "Please don't use this when filtering multiple series, queries to Pindora are not optimized."
+ ),
+ )
+
class Meta:
model = RecurringReservation
fields = [
@@ -44,12 +83,17 @@ class Meta:
"reservations",
"rejected_occurrences",
"allocated_time_slot",
+ "should_have_active_access_code",
+ "access_type",
+ "pindora_info",
]
restricted_fields = {
"name": lambda user, res: user.permissions.can_view_recurring_reservation(res),
"description": lambda user, res: user.permissions.can_view_recurring_reservation(res),
"user": lambda user, res: user.permissions.can_view_recurring_reservation(res),
"allocated_time_slot": lambda user, res: user.permissions.can_view_recurring_reservation(res),
+ "should_have_active_access_code": lambda user, res: user.permissions.can_view_recurring_reservation(res),
+ "pindora_info": lambda user, res: user.permissions.can_view_recurring_reservation(res),
}
filterset_class = RecurringReservationFilterSet
permission_classes = [RecurringReservationPermission]
@@ -68,3 +112,73 @@ def resolve_weekdays(root: RecurringReservation, info: GQLInfo) -> list[int]:
if root.weekdays:
return [int(i) for i in root.weekdays.split(",")]
return []
+
+ def resolve_pindora_info(root: RecurringReservation, info: GQLInfo) -> PindoraSeriesInfoData | None:
+ # Not using access codes
+ if not root.should_have_active_access_code:
+ return None
+
+ # No need to show Pindora info after 24 hours have passed since the series has ended
+ today = local_date()
+ cutoff = root.end_date + datetime.timedelta(hours=24)
+ if today > cutoff:
+ return None
+
+ has_perms = info.context.user.permissions.can_view_recurring_reservation(root, reserver_needs_role=True)
+
+ if root.allocated_time_slot is not None:
+ return RecurringReservationNode.section_pindora_info(root, has_perms=has_perms)
+
+ try:
+ response = PindoraClient.get_reservation_series(series=root.ext_uuid)
+ except Exception: # noqa: BLE001
+ return None
+
+ # Don't allow reserver to view Pindora info without view permissions if the access code is not active
+ access_code_is_active = response["access_code_is_active"]
+ if not has_perms and not access_code_is_active:
+ return None
+
+ access_code_validity = root.actions.get_access_code_validity_info(response["reservation_unit_code_validity"])
+
+ return PindoraSeriesInfoData(
+ access_code=response["access_code"],
+ access_code_generated_at=response["access_code_generated_at"],
+ access_code_is_active=response["access_code_is_active"],
+ access_code_keypad_url=response["access_code_keypad_url"],
+ access_code_phone_number=response["access_code_phone_number"],
+ access_code_sms_number=response["access_code_sms_number"],
+ access_code_sms_message=response["access_code_sms_message"],
+ access_code_validity=access_code_validity,
+ )
+
+ @staticmethod
+ def section_pindora_info(series: RecurringReservation, *, has_perms: bool) -> PindoraSeriesInfoData | None:
+ section = series.allocated_time_slot.reservation_unit_option.application_section
+
+ # Don't show Pindora info without permissions if the application round results haven't been sent yet
+ if not has_perms and section.application.application_round.sent_date is None:
+ return None
+
+ try:
+ response = PindoraClient.get_seasonal_booking(section=section.ext_uuid)
+ except Exception: # noqa: BLE001
+ return None
+
+ # Don't allow reserver to view Pindora info without permissions if the access code is not active
+ access_code_is_active = response["access_code_is_active"]
+ if not has_perms and not access_code_is_active:
+ return None
+
+ access_code_validity = series.actions.get_access_code_validity_info(response["reservation_unit_code_validity"])
+
+ return PindoraSeriesInfoData(
+ access_code=response["access_code"],
+ access_code_generated_at=response["access_code_generated_at"],
+ access_code_is_active=access_code_is_active,
+ access_code_keypad_url=response["access_code_keypad_url"],
+ access_code_phone_number=response["access_code_phone_number"],
+ access_code_sms_number=response["access_code_sms_number"],
+ access_code_sms_message=response["access_code_sms_message"],
+ access_code_validity=access_code_validity,
+ )
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/adjust_time_serializers.py b/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/adjust_time_serializers.py
index 33fe5e476b..443b30bd28 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/adjust_time_serializers.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/adjust_time_serializers.py
@@ -7,7 +7,7 @@
from graphene_django_extensions.fields import EnumFriendlyChoiceField
from rest_framework.fields import IntegerField
-from tilavarauspalvelu.enums import ReservationStateChoice
+from tilavarauspalvelu.enums import AccessType, ReservationStateChoice
from tilavarauspalvelu.integrations.email.main import EmailService
from tilavarauspalvelu.models import Reservation
from utils.date_utils import DEFAULT_TIMEZONE
@@ -78,7 +78,7 @@ def validate(self, data: ReservationAdjustTimeData) -> ReservationAdjustTimeData
data["buffer_time_before"] = reservation_unit.actions.get_actual_before_buffer(begin)
data["buffer_time_after"] = reservation_unit.actions.get_actual_after_buffer(end)
- data["access_type"] = reservation_unit.actions.get_access_type_at(begin)
+ data["access_type"] = reservation_unit.actions.get_access_type_at(begin) or AccessType.UNRESTRICTED
return data
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/create_serializer.py b/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/create_serializer.py
index 255de9720d..a2b48f32c9 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/create_serializer.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/create_serializer.py
@@ -89,7 +89,7 @@ def validate(self, data: ReservationCreateData) -> ReservationCreateData:
data["unit_price"] = pricing.highest_price
data["tax_percentage_value"] = pricing.tax_percentage.value
data["non_subsidised_price"] = data["price"]
- data["access_type"] = reservation_unit.actions.get_access_type_at(begin)
+ data["access_type"] = reservation_unit.actions.get_access_type_at(begin) or AccessType.UNRESTRICTED
if settings.PREFILL_RESERVATION_WITH_PROFILE_DATA:
self.prefill_reservation_from_profile(data)
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/staff_adjust_time_serializers.py b/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/staff_adjust_time_serializers.py
index 89f0fe2250..7d1d18b6a9 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/staff_adjust_time_serializers.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/staff_adjust_time_serializers.py
@@ -7,7 +7,7 @@
from graphene_django_extensions.fields import EnumFriendlyChoiceField
from rest_framework.fields import IntegerField
-from tilavarauspalvelu.enums import ReservationStateChoice
+from tilavarauspalvelu.enums import AccessType, ReservationStateChoice
from tilavarauspalvelu.integrations.email.main import EmailService
from tilavarauspalvelu.models import Reservation
from utils.date_utils import DEFAULT_TIMEZONE
@@ -62,7 +62,7 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]:
ignore_ids=[self.instance.pk],
)
- data["access_type"] = reservation_unit.actions.get_access_type_at(begin)
+ data["access_type"] = reservation_unit.actions.get_access_type_at(begin) or AccessType.UNRESTRICTED
return data
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/staff_create_serializers.py b/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/staff_create_serializers.py
index 409475ddc7..be1a73b957 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/staff_create_serializers.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation/serializers/staff_create_serializers.py
@@ -147,7 +147,7 @@ def validate(self, data: StaffCreateReservationData) -> StaffCreateReservationDa
data["state"] = ReservationStateChoice.CONFIRMED
data["user"] = user
data["reservee_used_ad_login"] = False if id_token is None else id_token.is_ad_login
- data["access_type"] = reservation_unit.actions.get_access_type_at(begin)
+ data["access_type"] = reservation_unit.actions.get_access_type_at(begin) or AccessType.UNRESTRICTED
return data
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation/types.py b/backend/tilavarauspalvelu/api/graphql/types/reservation/types.py
index efb4c27302..4700298ec1 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/reservation/types.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation/types.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import datetime
-from typing import TYPE_CHECKING, NamedTuple
+from typing import TYPE_CHECKING
import graphene
from django.db import models
@@ -16,6 +16,7 @@
from tilavarauspalvelu.enums import AccessType, CustomerTypeChoice, ReservationStateChoice, ReservationTypeChoice
from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
from tilavarauspalvelu.models import Reservation, ReservationUnit, User
+from tilavarauspalvelu.typing import PindoraReservationInfoData
from utils.date_utils import DEFAULT_TIMEZONE, local_datetime
from utils.db import SubqueryArray
from utils.utils import ical_hmac_signature
@@ -24,7 +25,11 @@
from .permissions import ReservationPermission
if TYPE_CHECKING:
- from tilavarauspalvelu.models import PaymentOrder
+ from tilavarauspalvelu.integrations.keyless_entry.typing import (
+ PindoraReservationSeriesAccessCodeValidity,
+ PindoraSeasonalBookingAccessCodeValidity,
+ )
+ from tilavarauspalvelu.models import ApplicationSection, PaymentOrder, RecurringReservation
from tilavarauspalvelu.models.reservation.queryset import ReservationQuerySet
from tilavarauspalvelu.typing import AnyUser, GQLInfo
@@ -33,21 +38,7 @@
]
-class PindoraInfoData(NamedTuple):
- access_code: str
- access_code_generated_at: datetime.datetime
- access_code_is_active: bool
-
- access_code_keypad_url: str
- access_code_phone_number: str
- access_code_sms_number: str
- access_code_sms_message: str
-
- access_code_begins_at: datetime.datetime
- access_code_ends_at: datetime.datetime
-
-
-class PindoraInfoType(graphene.ObjectType):
+class PindoraReservationInfoType(graphene.ObjectType):
access_code = graphene.String(required=True)
access_code_generated_at = graphene.DateTime(required=True)
access_code_is_active = graphene.Boolean(required=True)
@@ -102,7 +93,7 @@ class ReservationNode(DjangoNode):
)
pindora_info = MultiField(
- PindoraInfoType,
+ PindoraReservationInfoType,
fields=["access_type", "ext_uuid", "state", "end"],
description=(
"Info fetched from Pindora API. Cached per reservation for 30s. "
@@ -291,7 +282,7 @@ def resolve_calendar_url(root: Reservation, info: GQLInfo) -> str:
signature = ical_hmac_signature(f"reservation-{root.pk}")
return f"{scheme}://{host}{calendar_url}?hash={signature}"
- def resolve_pindora_info(root: Reservation, info: GQLInfo) -> PindoraInfoData | None:
+ def resolve_pindora_info(root: Reservation, info: GQLInfo) -> PindoraReservationInfoData | None:
# No Pindora info if access type is not 'ACCESS_CODE'
if root.access_type != AccessType.ACCESS_CODE:
return None
@@ -302,20 +293,28 @@ def resolve_pindora_info(root: Reservation, info: GQLInfo) -> PindoraInfoData |
if now > cutoff:
return None
- has_view_permissions = info.context.user.permissions.can_view_reservation(root, reserver_needs_role=True)
+ has_perms = info.context.user.permissions.can_view_reservation(root, reserver_needs_role=True)
# Don't allow reserver to view Pindora info without view permissions if the reservation is not confirmed
- if not has_view_permissions and root.state != ReservationStateChoice.CONFIRMED:
+ if not has_perms and root.state != ReservationStateChoice.CONFIRMED:
return None
+ if root.recurring_reservation is not None:
+ return ReservationNode.series_pindora_info(
+ root.recurring_reservation,
+ begin=root.begin.astimezone(DEFAULT_TIMEZONE),
+ end=root.end.astimezone(DEFAULT_TIMEZONE),
+ has_perms=has_perms,
+ )
+
try:
response = PindoraClient.get_reservation(reservation=root.ext_uuid)
except Exception: # noqa: BLE001
return None
- # Don't allow reserver to view Pindora info without view permissions if the access code is not active
+ # Don't allow reserver to view Pindora info without permissions if the access code is not active
access_code_is_active = response["access_code_is_active"]
- if not has_view_permissions and not access_code_is_active:
+ if not has_perms and not access_code_is_active:
return None
begin = response["begin"]
@@ -325,7 +324,115 @@ def resolve_pindora_info(root: Reservation, info: GQLInfo) -> PindoraInfoData |
access_code_begins_at = begin - datetime.timedelta(minutes=access_code_valid_minutes_before)
access_code_ends_at = end + datetime.timedelta(minutes=access_code_valid_minutes_after)
- return PindoraInfoData(
+ return PindoraReservationInfoData(
+ access_code=response["access_code"],
+ access_code_generated_at=response["access_code_generated_at"],
+ access_code_is_active=access_code_is_active,
+ access_code_keypad_url=response["access_code_keypad_url"],
+ access_code_phone_number=response["access_code_phone_number"],
+ access_code_sms_number=response["access_code_sms_number"],
+ access_code_sms_message=response["access_code_sms_message"],
+ access_code_begins_at=access_code_begins_at,
+ access_code_ends_at=access_code_ends_at,
+ )
+
+ @staticmethod
+ def series_pindora_info(
+ series: RecurringReservation,
+ *,
+ begin: datetime.datetime,
+ end: datetime.datetime,
+ has_perms: bool,
+ ) -> PindoraReservationInfoData | None:
+ if series.allocated_time_slot is not None:
+ return ReservationNode.section_pindora_info(
+ series.allocated_time_slot.reservation_unit_option.application_section,
+ begin=begin,
+ end=end,
+ has_perms=has_perms,
+ )
+
+ try:
+ response = PindoraClient.get_reservation_series(series=series.ext_uuid)
+ except Exception: # noqa: BLE001
+ return None
+
+ # Don't allow reserver to view Pindora info without permissions if the access code is not active
+ access_code_is_active = response["access_code_is_active"]
+ if not has_perms and not access_code_is_active:
+ return None
+
+ validity: PindoraReservationSeriesAccessCodeValidity | None = next(
+ (
+ validity
+ for validity in response["reservation_unit_code_validity"]
+ if validity["begin"] == begin and validity["end"] == end
+ ),
+ None,
+ )
+ if validity is None:
+ return None
+
+ begin = validity["begin"]
+ end = validity["end"]
+ access_code_valid_minutes_before = validity["access_code_valid_minutes_before"]
+ access_code_valid_minutes_after = validity["access_code_valid_minutes_after"]
+ access_code_begins_at = begin - datetime.timedelta(minutes=access_code_valid_minutes_before)
+ access_code_ends_at = end + datetime.timedelta(minutes=access_code_valid_minutes_after)
+
+ return PindoraReservationInfoData(
+ access_code=response["access_code"],
+ access_code_generated_at=response["access_code_generated_at"],
+ access_code_is_active=access_code_is_active,
+ access_code_keypad_url=response["access_code_keypad_url"],
+ access_code_phone_number=response["access_code_phone_number"],
+ access_code_sms_number=response["access_code_sms_number"],
+ access_code_sms_message=response["access_code_sms_message"],
+ access_code_begins_at=access_code_begins_at,
+ access_code_ends_at=access_code_ends_at,
+ )
+
+ @staticmethod
+ def section_pindora_info(
+ section: ApplicationSection,
+ *,
+ begin: datetime.datetime,
+ end: datetime.datetime,
+ has_perms: bool,
+ ) -> PindoraReservationInfoData | None:
+ # Don't show Pindora info without permissions if the application round results haven't been sent yet
+ if not has_perms and section.application.application_round.sent_date is None:
+ return None
+
+ try:
+ response = PindoraClient.get_seasonal_booking(section=section.ext_uuid)
+ except Exception: # noqa: BLE001
+ return None
+
+ # Don't allow reserver to view Pindora info without permissions if the access code is not active
+ access_code_is_active = response["access_code_is_active"]
+ if not has_perms and not access_code_is_active:
+ return None
+
+ validity: PindoraSeasonalBookingAccessCodeValidity | None = next(
+ (
+ validity
+ for validity in response["reservation_unit_code_validity"]
+ if validity["begin"] == begin and validity["end"] == end
+ ),
+ None,
+ )
+ if validity is None:
+ return None
+
+ begin = validity["begin"]
+ end = validity["end"]
+ access_code_valid_minutes_before = validity["access_code_valid_minutes_before"]
+ access_code_valid_minutes_after = validity["access_code_valid_minutes_after"]
+ access_code_begins_at = begin - datetime.timedelta(minutes=access_code_valid_minutes_before)
+ access_code_ends_at = end + datetime.timedelta(minutes=access_code_valid_minutes_after)
+
+ return PindoraReservationInfoData(
access_code=response["access_code"],
access_code_generated_at=response["access_code_generated_at"],
access_code_is_active=access_code_is_active,
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation_unit/filtersets.py b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit/filtersets.py
index 4c67586f3f..b70fd94a3e 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/reservation_unit/filtersets.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit/filtersets.py
@@ -107,7 +107,7 @@ class ReservationUnitFilterSet(ModelFilterSet, ReservationUnitFilterSetMixin):
)
access_type = EnumMultipleChoiceFilter(method="filter_by_access_type", enum=AccessType)
- access_type_start_date = django_filters.DateFilter(method="filter_by_access_type")
+ access_type_begin_date = django_filters.DateFilter(method="filter_by_access_type")
access_type_end_date = django_filters.DateFilter(method="filter_by_access_type")
only_with_permission = django_filters.BooleanFilter(method="get_only_with_permission")
@@ -215,8 +215,8 @@ def filter_by_access_type(qs: ReservationUnitQuerySet, name: str, value: dict[st
return qs
return qs.with_access_type_at(
- allowed_access_types=[AccessType(access_type) for access_type in allowed_access_types],
- begin_date=value.get("access_type_start_date"),
+ allowed_access_types=allowed_access_types,
+ begin_date=value.get("access_type_begin_date"),
end_date=value.get("access_type_end_date"),
)
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation_unit/serializers.py b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit/serializers.py
index c011218a3b..dd850e97a8 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/reservation_unit/serializers.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit/serializers.py
@@ -1,30 +1,34 @@
from __future__ import annotations
-import datetime
-from typing import Any
+from typing import TYPE_CHECKING, Any
from auditlog.models import LogEntry
from django.conf import settings
from django.db import transaction
-from django.db.transaction import atomic
from graphene.utils.str_converters import to_camel_case
from graphene_django_extensions import NestingModelSerializer
from graphene_django_extensions.errors import GQLCodeError
-from graphene_django_extensions.serializers import NotProvided
from rest_framework.exceptions import ValidationError
from tilavarauspalvelu.api.graphql.extensions import error_codes
from tilavarauspalvelu.api.graphql.types.application_round_time_slot.serializers import (
ApplicationRoundTimeSlotSerializer,
)
+from tilavarauspalvelu.api.graphql.types.reservation_unit_access_type.serializers import (
+ ReservationUnitAccessTypeSerializer,
+)
from tilavarauspalvelu.api.graphql.types.reservation_unit_image.serializers import ReservationUnitImageFieldSerializer
from tilavarauspalvelu.api.graphql.types.reservation_unit_pricing.serializers import ReservationUnitPricingSerializer
from tilavarauspalvelu.enums import AccessType, ReservationStartInterval, ReservationUnitPublishingState, WeekdayChoice
+from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
from tilavarauspalvelu.integrations.opening_hours.hauki_resource_hash_updater import HaukiResourceHashUpdater
-from tilavarauspalvelu.models import ReservationUnit, ReservationUnitPricing
+from tilavarauspalvelu.models import ReservationUnit, ReservationUnitAccessType, ReservationUnitPricing
from utils.date_utils import local_date, local_datetime
from utils.external_service.errors import ExternalServiceError
+if TYPE_CHECKING:
+ import datetime
+
__all__ = [
"ReservationUnitSerializer",
]
@@ -36,6 +40,7 @@ class ReservationUnitSerializer(NestingModelSerializer):
images = ReservationUnitImageFieldSerializer(many=True, required=False)
pricings = ReservationUnitPricingSerializer(many=True, required=False)
application_round_time_slots = ApplicationRoundTimeSlotSerializer(many=True, required=False)
+ access_types = ReservationUnitAccessTypeSerializer(many=True, required=False)
class Meta:
model = ReservationUnit
@@ -71,8 +76,6 @@ class Meta:
"max_reservation_duration",
"buffer_time_before",
"buffer_time_after",
- "access_type_start_date",
- "access_type_end_date",
#
# Booleans
"is_draft",
@@ -87,7 +90,6 @@ class Meta:
"authentication",
"reservation_start_interval",
"reservation_kind",
- "access_type",
"publishing_state",
#
# List fields
@@ -114,16 +116,16 @@ class Meta:
# Reverse one-to-many related
"images",
"pricings",
+ "access_types",
"application_round_time_slots",
]
def validate(self, data: dict[str, Any]) -> dict[str, Any]:
- is_draft = data.get("is_draft", getattr(self.instance, "is_draft", False))
+ is_draft = self.get_or_default("is_draft", data)
self._validate_reservation_duration_fields(data)
self._validate_pricings(data)
-
- self._validate_access_type(data)
+ self._validate_access_types(access_types=data.get("access_types", []), is_draft=is_draft)
if not is_draft:
self._validate_for_publish(data)
@@ -250,13 +252,73 @@ def _validate_pricings(self, data: dict[str, Any]) -> None:
msg = "Highest price cannot be less than lowest price."
raise ValidationError(msg, code=error_codes.RESERVATION_UNIT_PRICINGS_INVALID_PRICES)
- def _validate_access_type(self, data: dict[str, Any]) -> None:
- access_type_start_date = self.get_or_default("access_type_start_date", data) or datetime.date.min
- access_type_end_date = self.get_or_default("access_type_end_date", data) or datetime.date.max
+ def _validate_access_types(self, *, access_types: list[dict[str, Any]], is_draft: bool) -> None: # noqa: PLR0912
+ editing = getattr(self.instance, "pk", None) is not None
+
+ today = local_date()
+ has_active: bool = False
+ if editing:
+ has_active = self.instance.access_types.filter(begin_date__lte=today).exists()
+
+ if not access_types:
+ if is_draft:
+ return
+
+ if not has_active:
+ msg = "At least one active access type is required."
+ raise ValidationError(msg, code=error_codes.RESERVATION_UNIT_MISSING_ACTIVE_ACCESS_TYPE)
+
+ return
+
+ need_to_check_pindora: bool = False
+
+ existing: dict[int, ReservationUnitAccessType] = {}
+ if editing:
+ pks: set[int] = {int(at["pk"]) for at in access_types if "pk" in at}
+ existing = {at.pk: at for at in self.instance.access_types.filter(pk__in=pks).order_by("begin_date")}
+
+ for access_type in access_types:
+ existing_access_type: ReservationUnitAccessType | None = None
+
+ pk: int | None = access_type.get("pk")
+ if pk is not None:
+ existing_access_type = existing.get(pk)
+ if existing_access_type is None:
+ msg = "Access type with this primary key doesn't belong to this reservation unit"
+ raise ValidationError(msg)
- if access_type_end_date < access_type_start_date:
- msg = "Access type start date must be before access type end date."
- raise ValidationError(msg, code=error_codes.RESERVATION_UNIT_ACCESS_TYPE_START_DATE_INVALID)
+ new_access_type: str = access_type["access_type"]
+ begin_date: datetime.date = access_type["begin_date"]
+
+ if not editing:
+ ReservationUnitAccessType.validator.validate_not_access_code(new_access_type)
+
+ if not need_to_check_pindora:
+ # Check from Pindora even if changed access type begin date is in the past since
+ # it could be the currently active one. For more precision, we would need to calculate
+ # the end dates for the new access types, but that would add a lot of complexity.
+ need_to_check_pindora = new_access_type == AccessType.ACCESS_CODE and (
+ existing_access_type is None
+ or (existing_access_type is not None and existing_access_type.access_type != AccessType.ACCESS_CODE)
+ )
+
+ if existing_access_type is not None:
+ existing_access_type.validator.validate_not_past(begin_date)
+ existing_access_type.validator.validate_not_moved_to_past(begin_date)
+ else:
+ ReservationUnitAccessType.validator.validate_new_not_in_past(begin_date)
+
+ has_active = has_active or begin_date <= today
+
+ if not has_active:
+ msg = "At least one active access type is required."
+ raise ValidationError(msg, code=error_codes.RESERVATION_UNIT_MISSING_ACTIVE_ACCESS_TYPE)
+
+ if need_to_check_pindora:
+ try:
+ PindoraClient.get_reservation_unit(self.instance)
+ except ExternalServiceError as error:
+ raise ValidationError(str(error)) from error
@staticmethod
def validate_application_round_time_slots(timeslots: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -290,6 +352,31 @@ def save(self, **kwargs: Any) -> ReservationUnit:
self.update_hauki(instance)
return instance
+ @transaction.atomic
+ def create(self, validated_data: dict[str, Any]) -> ReservationUnit:
+ pricings = validated_data.pop("pricings", [])
+ access_types = validated_data.pop("access_types", [])
+ reservation_unit = super().create(validated_data)
+ self.handle_pricings(pricings, reservation_unit)
+ self.handle_access_types(access_types, reservation_unit)
+ return reservation_unit
+
+ @transaction.atomic
+ def update(self, instance: ReservationUnit, validated_data: dict[str, Any]) -> ReservationUnit:
+ # The ReservationUnit can't be archived if it has active reservations in the future
+ if instance.publishing_state != ReservationUnitPublishingState.ARCHIVED and validated_data.get("is_archived"):
+ future_reservations = instance.reservations.going_to_occur().filter(end__gt=local_datetime())
+ if future_reservations.exists():
+ msg = "Reservation unit can't be archived if it has any reservations in the future"
+ raise ValidationError(msg, code=error_codes.RESERVATION_UNIT_HAS_FUTURE_RESERVATIONS)
+
+ pricings = validated_data.pop("pricings", [])
+ access_types = validated_data.pop("access_types", [])
+ reservation_unit = super().update(instance, validated_data)
+ self.handle_pricings(pricings, reservation_unit)
+ self.handle_access_types(access_types, reservation_unit)
+ return reservation_unit
+
def remove_personal_data_and_logs_on_archive(self, instance: ReservationUnit) -> None:
"""
When reservation unit is archived, we want to delete all personally identifiable information (GDPR stuff).
@@ -324,7 +411,7 @@ def update_hauki(instance: ReservationUnit) -> None:
@staticmethod
def handle_pricings(pricings: list[dict[Any, Any]], reservation_unit: ReservationUnit) -> None:
- with atomic():
+ with transaction.atomic():
# Delete future pricings that are not in the payload.
# Past or Active pricings can not be deleted.
pricing_pks = [pricing.get("pk") for pricing in pricings if "pk" in pricing]
@@ -341,32 +428,28 @@ def handle_pricings(pricings: list[dict[Any, Any]], reservation_unit: Reservatio
else: # Create new pricings
ReservationUnitPricing.objects.create(**pricing, reservation_unit=reservation_unit)
- @transaction.atomic
- def update(self, instance: ReservationUnit, validated_data: dict[str, Any]) -> ReservationUnit:
- # The ReservationUnit can't be archived if it has active reservations in the future
- if instance.publishing_state != ReservationUnitPublishingState.ARCHIVED and validated_data.get("is_archived"):
- future_reservations = instance.reservations.going_to_occur().filter(end__gt=local_datetime())
- if future_reservations.exists():
- msg = "Reservation unit can't be archived if it has any reservations in the future"
- raise ValidationError(msg, code=error_codes.RESERVATION_UNIT_HAS_FUTURE_RESERVATIONS)
-
- access_type = validated_data.get("access_type", NotProvided)
- access_type_start_date = validated_data.get("access_type_start_date", NotProvided)
- access_type_end_date = validated_data.get("access_type_end_date", NotProvided)
-
- # If at least one value was provided, we might need to update access type for reservations
- if {access_type, access_type_start_date, access_type_end_date} != {NotProvided}:
- access_type = AccessType(validated_data.get("access_type", self.instance.access_type))
- access_type_start_date = validated_data.get("access_type_start_date", self.instance.access_type_start_date)
- access_type_end_date = validated_data.get("access_type_end_date", self.instance.access_type_end_date)
+ @staticmethod
+ def handle_access_types(access_types: list[dict[Any, Any]], reservation_unit: ReservationUnit) -> None:
+ """Update access types for a reservation unit."""
+ access_type_pks = [access_type.get("pk") for access_type in access_types if "pk" in access_type]
+ today = local_date()
- self.instance.actions.update_access_type_for_reservations(
- access_type=access_type,
- access_type_start_date=access_type_start_date,
- access_type_end_date=access_type_end_date,
+ with transaction.atomic():
+ # Delete future access types that are not in the payload.
+ # Past or active access types should not be deleted.
+ ReservationUnitAccessType.objects.filter(
+ reservation_unit=reservation_unit,
+ begin_date__gt=today,
+ ).exclude(pk__in=access_type_pks).delete()
+
+ ReservationUnitAccessType.objects.bulk_create(
+ objs=[
+ ReservationUnitAccessType(**access_type, reservation_unit=reservation_unit)
+ for access_type in access_types
+ ],
+ update_conflicts=True,
+ update_fields=["access_type", "begin_date"],
+ unique_fields=["pk"],
)
- pricings = validated_data.pop("pricings", [])
- reservation_unit = super().update(instance, validated_data)
- self.handle_pricings(pricings, instance)
- return reservation_unit
+ reservation_unit.actions.update_access_types_for_reservations()
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation_unit/types.py b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit/types.py
index 77b8fb4fab..f82af24e9f 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/reservation_unit/types.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit/types.py
@@ -119,8 +119,6 @@ class Meta:
"max_reservation_duration",
"buffer_time_before",
"buffer_time_after",
- "access_type_start_date",
- "access_type_end_date",
#
# Booleans
"is_draft",
@@ -137,7 +135,6 @@ class Meta:
"reservation_kind",
"publishing_state",
"reservation_state",
- "access_type",
"current_access_type",
#
# List fields
@@ -171,6 +168,7 @@ class Meta:
# Reverse one-to-many related
"images",
"pricings",
+ "access_types",
"application_round_time_slots",
#
# "Special" fields
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/__init__.py b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/filtersets.py b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/filtersets.py
new file mode 100644
index 0000000000..5539d749df
--- /dev/null
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/filtersets.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import django_filters
+from graphene_django_extensions import ModelFilterSet
+from graphene_django_extensions.filters import IntMultipleChoiceFilter
+from lookup_property import L
+
+from tilavarauspalvelu.models import ReservationUnitAccessType
+from utils.date_utils import local_date
+
+if TYPE_CHECKING:
+ from django.db.models import QuerySet
+
+ from tilavarauspalvelu.models.reservation_unit_access_type.queryset import ReservationUnitAccessTypeQuerySet
+
+
+__all__ = [
+ "ReservationUnitAccessTypeFilterSet",
+]
+
+
+class ReservationUnitAccessTypeFilterSet(ModelFilterSet):
+ pk = IntMultipleChoiceFilter()
+ reservation_unit = IntMultipleChoiceFilter()
+
+ is_active_or_future = django_filters.BooleanFilter(method="filter_is_active_or_future")
+
+ class Meta:
+ model = ReservationUnitAccessType
+ order_by = ["pk", "begin_date"]
+
+ @staticmethod
+ def filter_is_active_or_future(qs: ReservationUnitAccessTypeQuerySet, name: str, value: bool) -> QuerySet:
+ today = local_date()
+ ftr = L(end_date__gt=today)
+ return qs.filter(ftr if value else ~ftr)
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/permissions.py b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/permissions.py
new file mode 100644
index 0000000000..076340d6d4
--- /dev/null
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/permissions.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from graphene_django_extensions.permissions import BasePermission
+
+if TYPE_CHECKING:
+ from tilavarauspalvelu.typing import AnyUser
+
+
+__all__ = [
+ "ReservationUnitAccessTypePermission",
+]
+
+
+class ReservationUnitAccessTypePermission(BasePermission):
+ @classmethod
+ def has_permission(cls, user: AnyUser) -> bool:
+ return True
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/serializers.py b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/serializers.py
new file mode 100644
index 0000000000..c2f821c8a7
--- /dev/null
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/serializers.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from graphene_django_extensions import NestingModelSerializer
+
+from tilavarauspalvelu.models import ReservationUnitAccessType
+
+__all__ = [
+ "ReservationUnitAccessTypeSerializer",
+]
+
+
+class ReservationUnitAccessTypeSerializer(NestingModelSerializer):
+ class Meta:
+ model = ReservationUnitAccessType
+ fields = [
+ "pk",
+ "access_type",
+ "begin_date",
+ ]
diff --git a/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/types.py b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/types.py
new file mode 100644
index 0000000000..280ae392ab
--- /dev/null
+++ b/backend/tilavarauspalvelu/api/graphql/types/reservation_unit_access_type/types.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from graphene_django_extensions import DjangoNode
+
+from tilavarauspalvelu.models import ReservationUnitAccessType
+
+from .filtersets import ReservationUnitAccessTypeFilterSet
+from .permissions import ReservationUnitAccessTypePermission
+
+__all__ = [
+ "ReservationUnitAccessTypeNode",
+]
+
+
+class ReservationUnitAccessTypeNode(DjangoNode):
+ class Meta:
+ model = ReservationUnitAccessType
+ fields = [
+ "pk",
+ "begin_date",
+ "access_type",
+ "reservation_unit",
+ ]
+ filterset_class = ReservationUnitAccessTypeFilterSet
+ permission_classes = [ReservationUnitAccessTypePermission]
diff --git a/backend/tilavarauspalvelu/enums.py b/backend/tilavarauspalvelu/enums.py
index 44ac782950..5905ba4da0 100644
--- a/backend/tilavarauspalvelu/enums.py
+++ b/backend/tilavarauspalvelu/enums.py
@@ -1179,3 +1179,11 @@ class AccessType(models.TextChoices):
OPENED_BY_STAFF = "OPENED_BY_STAFF", pgettext_lazy("AccessType", "opened by staff")
PHYSICAL_KEY = "PHYSICAL_KEY", pgettext_lazy("AccessType", "physical key")
UNRESTRICTED = "UNRESTRICTED", pgettext_lazy("AccessType", "unrestricted")
+
+ # Should not be settable to models, only available in API responses.
+ MULTIVALUED = "MULTIVALUED", pgettext_lazy("AccessType", "multi-valued")
+
+ @classproperty
+ def model_choices(cls) -> list[tuple[str, str]]:
+ """Don't allow 'MULTIVALUED' to be set to models."""
+ return [(value, label) for value, label in cls.choices if value != cls.MULTIVALUED.value]
diff --git a/backend/tilavarauspalvelu/integrations/keyless_entry/client.py b/backend/tilavarauspalvelu/integrations/keyless_entry/client.py
index 761d5eccaa..3dde1afc1f 100644
--- a/backend/tilavarauspalvelu/integrations/keyless_entry/client.py
+++ b/backend/tilavarauspalvelu/integrations/keyless_entry/client.py
@@ -16,7 +16,6 @@
HTTP_409_CONFLICT,
)
-from tilavarauspalvelu.enums import ReservationStateChoice
from utils.date_utils import local_iso_format
from utils.external_service.base_external_service_client import BaseExternalServiceClient
@@ -32,6 +31,7 @@
PindoraUnexpectedResponseError,
)
from .typing import (
+ PindoraAccessCodeModifyResponse,
PindoraReservationCreateData,
PindoraReservationRescheduleData,
PindoraReservationResponse,
@@ -58,22 +58,85 @@
]
-class PindoraClient(BaseExternalServiceClient):
- """Client for the Pindora-Tilavaraus API."""
-
+class BasePindoraClient(BaseExternalServiceClient):
SERVICE_NAME = "Pindora"
REQUEST_TIMEOUT_SECONDS = 10
- ####################
- # Reservation unit #
- ####################
+ @classmethod
+ def _build_url(cls, endpoint: str) -> str:
+ if not settings.PINDORA_API_URL:
+ raise PindoraClientConfigurationError(config="PINDORA_API_URL")
+
+ base_url = settings.PINDORA_API_URL.removesuffix("/")
+ return f"{base_url}/{endpoint}"
+
+ @classmethod
+ def _get_headers(cls, headers: dict[str, Any] | None) -> dict[str, str]:
+ if not settings.PINDORA_API_KEY:
+ raise PindoraClientConfigurationError(config="PINDORA_API_KEY")
+
+ return {
+ "Pindora-Api-Key": str(settings.PINDORA_API_KEY),
+ "Accept": "application/vdn.varaamo-pindora.v1+json",
+ **(headers or {}),
+ }
+
+ @classmethod
+ def _cache_response(cls, data: dict[str, Any], *, ext_uuid: uuid.UUID, prefix: str) -> str:
+ cache_key = cls._cache_key(ext_uuid=ext_uuid, prefix=prefix)
+ cache_data = json.dumps(data, default=str)
+ cache.set(cache_key, cache_data, timeout=30)
+ return cache_data
+
+ @classmethod
+ def _get_cached_response(cls, *, ext_uuid: uuid.UUID, prefix: str) -> dict[str, Any] | None:
+ cache_key = cls._cache_key(ext_uuid=ext_uuid, prefix=prefix)
+ cached_data = cache.get(cache_key)
+ if cached_data is None:
+ return None
+ return json.loads(cached_data)
+
+ @classmethod
+ def _clear_cached_response(cls, *, ext_uuid: uuid.UUID, prefix: str) -> bool:
+ cache_key = cls._cache_key(ext_uuid=ext_uuid, prefix=prefix)
+ return cache.delete(cache_key)
+
+ @classmethod
+ def _cache_key(cls, *, ext_uuid: uuid.UUID, prefix: str) -> str:
+ return f"pindora:{prefix}:{ext_uuid}"
+
+ @classmethod
+ def _parse_access_code_modify_response(cls, data: dict[str, Any]) -> PindoraAccessCodeModifyResponse:
+ try:
+ return PindoraAccessCodeModifyResponse(
+ access_code_generated_at=datetime.datetime.fromisoformat(data["access_code_generated_at"]),
+ access_code_is_active=bool(data["access_code_is_active"]),
+ )
+ except KeyError as error:
+ raise PindoraMissingKeyError(entity="reservation", key=error) from error
+
+ except (ValueError, TypeError) as error:
+ raise PindoraInvalidValueError(entity="reservation", error=error) from error
+
+ @classmethod
+ def _validate_response(cls, response: Response) -> None:
+ """Handle common errors in Pindora API responses."""
+ if response.status_code == HTTP_403_FORBIDDEN:
+ raise PindoraPermissionError
+
+ if response.status_code == HTTP_400_BAD_REQUEST:
+ raise PindoraBadRequestError(text=response.text)
+
+
+class PindoraReservationUnitClient(BasePindoraClient):
+ """Pindora client for working with reservation units."""
@classmethod
def get_reservation_unit(cls, reservation_unit: ReservationUnit | uuid.UUID) -> PindoraReservationUnitResponse:
"""Get a reservation unit from Pindora."""
reservation_unit_uuid = reservation_unit if isinstance(reservation_unit, uuid.UUID) else reservation_unit.uuid
- response = cls.get_cached_reservation_unit_response(ext_uuid=reservation_unit_uuid)
+ response = cls._get_cached_reservation_unit_response(ext_uuid=reservation_unit_uuid)
if response is not None:
return response
@@ -88,19 +151,74 @@ def get_reservation_unit(cls, reservation_unit: ReservationUnit | uuid.UUID) ->
data = cls.response_json(response)
parsed_data = cls._parse_reservation_unit_response(data)
- cls.cache_reservation_unit_response(data=data, ext_uuid=reservation_unit_uuid)
+ cls._cache_reservation_unit_response(data=data, ext_uuid=reservation_unit_uuid)
return parsed_data
- ######################
- # Single reservation #
- ######################
+ # ----------------------------------------------------------------------------------------------------------------
+
+ @classmethod
+ def _cache_reservation_unit_response(cls, data: PindoraReservationUnitResponse, *, ext_uuid: uuid.UUID) -> str:
+ return cls._cache_response(data, ext_uuid=ext_uuid, prefix="reservation-unit")
+
+ @classmethod
+ def _get_cached_reservation_unit_response(cls, *, ext_uuid: uuid.UUID) -> PindoraReservationUnitResponse | None:
+ data = cls._get_cached_response(ext_uuid=ext_uuid, prefix="reservation-unit")
+ if data is None:
+ return None
+ return cls._parse_reservation_unit_response(data)
+
+ @classmethod
+ def _clear_cached_reservation_unit_response(cls, *, ext_uuid: uuid.UUID) -> bool:
+ return cls._clear_cached_response(ext_uuid=ext_uuid, prefix="reservation-unit")
+
+ @classmethod
+ def _parse_reservation_unit_response(cls, data: dict[str, Any]) -> PindoraReservationUnitResponse:
+ try:
+ return PindoraReservationUnitResponse(
+ reservation_unit_id=uuid.UUID(data["reservation_unit_id"]),
+ name=data["name"],
+ keypad_url=data["keypad_url"],
+ )
+
+ except KeyError as error:
+ raise PindoraMissingKeyError(entity="reservation unit", key=error) from error
+
+ except (ValueError, TypeError) as error:
+ raise PindoraInvalidValueError(entity="reservation unit", error=error) from error
+
+ @classmethod
+ def _validate_reservation_unit_response(
+ cls,
+ response: Response,
+ reservation_unit_uuid: uuid.UUID,
+ *,
+ action: str,
+ expected_status_code: int = HTTP_200_OK,
+ ) -> None:
+ """Handle errors in reservation unit responses."""
+ cls._validate_response(response)
+
+ if response.status_code == HTTP_404_NOT_FOUND:
+ raise PindoraNotFoundError(entity="Reservation unit", uuid=reservation_unit_uuid)
+
+ if response.status_code != expected_status_code:
+ raise PindoraUnexpectedResponseError(
+ action=action,
+ uuid=reservation_unit_uuid,
+ status_code=response.status_code,
+ text=response.text,
+ )
+
+
+class PindoraReservationClient(BasePindoraClient):
+ """Pindora client for working with reservations"""
@classmethod
def get_reservation(cls, reservation: Reservation | uuid.UUID) -> PindoraReservationResponse:
"""Fetch a reservation from Pindora."""
reservation_uuid = reservation if isinstance(reservation, uuid.UUID) else reservation.ext_uuid
- response = cls.get_cached_reservation_response(ext_uuid=reservation_uuid)
+ response = cls._get_cached_reservation_response(ext_uuid=reservation_uuid)
if response is not None:
return response
@@ -115,7 +233,7 @@ def get_reservation(cls, reservation: Reservation | uuid.UUID) -> PindoraReserva
data = cls.response_json(response)
parsed_data = cls._parse_reservation_response(data)
- cls.cache_reservation_response(parsed_data, ext_uuid=reservation.ext_uuid)
+ cls._cache_reservation_response(parsed_data, ext_uuid=reservation_uuid)
return parsed_data
@classmethod
@@ -142,11 +260,11 @@ def create_reservation(cls, reservation: Reservation, *, is_active: bool = False
data = cls.response_json(response)
parsed_data = cls._parse_reservation_response(data)
- cls.cache_reservation_response(parsed_data, ext_uuid=reservation.ext_uuid)
+ cls._cache_reservation_response(parsed_data, ext_uuid=reservation.ext_uuid)
return parsed_data
@classmethod
- def reschedule_reservation(cls, reservation: Reservation) -> None:
+ def reschedule_reservation(cls, reservation: Reservation) -> PindoraAccessCodeModifyResponse:
"""Reschedule a reservation in Pindora."""
url = cls._build_url(f"reservation/reschedule/{reservation.ext_uuid}")
@@ -160,12 +278,16 @@ def reschedule_reservation(cls, reservation: Reservation) -> None:
response,
reservation_uuid=reservation.ext_uuid,
action="rescheduling reservation",
- expected_status_code=HTTP_204_NO_CONTENT,
+ expected_status_code=HTTP_200_OK,
)
- cls.clear_cached_reservation_response(ext_uuid=reservation.ext_uuid)
+
+ data = cls.response_json(response)
+ parsed_data = cls._parse_access_code_modify_response(data)
+ cls._update_cached_reservation_response(parsed_data, ext_uuid=reservation.ext_uuid)
+ return parsed_data
@classmethod
- def change_reservation_access_code(cls, reservation: Reservation | uuid.UUID) -> None:
+ def change_reservation_access_code(cls, reservation: Reservation | uuid.UUID) -> PindoraAccessCodeModifyResponse:
"""Change a reservation's access code in Pindora."""
reservation_uuid = reservation if isinstance(reservation, uuid.UUID) else reservation.ext_uuid
@@ -176,9 +298,13 @@ def change_reservation_access_code(cls, reservation: Reservation | uuid.UUID) ->
response,
reservation_uuid=reservation_uuid,
action="changing access code for reservation",
- expected_status_code=HTTP_204_NO_CONTENT,
+ expected_status_code=HTTP_200_OK,
)
- cls.clear_cached_reservation_response(ext_uuid=reservation.ext_uuid)
+
+ data = cls.response_json(response)
+ parsed_data = cls._parse_access_code_modify_response(data)
+ cls._update_cached_reservation_response(parsed_data, ext_uuid=reservation_uuid)
+ return parsed_data
@classmethod
def activate_reservation_access_code(cls, reservation: Reservation | uuid.UUID) -> None:
@@ -194,7 +320,7 @@ def activate_reservation_access_code(cls, reservation: Reservation | uuid.UUID)
action="activating access code for reservation",
expected_status_code=HTTP_204_NO_CONTENT,
)
- cls.clear_cached_reservation_response(ext_uuid=reservation.ext_uuid)
+ cls._clear_cached_reservation_response(ext_uuid=reservation_uuid)
@classmethod
def deactivate_reservation_access_code(cls, reservation: Reservation | uuid.UUID) -> None:
@@ -210,7 +336,7 @@ def deactivate_reservation_access_code(cls, reservation: Reservation | uuid.UUID
action="deactivating access code for reservation",
expected_status_code=HTTP_204_NO_CONTENT,
)
- cls.clear_cached_reservation_response(ext_uuid=reservation.ext_uuid)
+ cls._clear_cached_reservation_response(ext_uuid=reservation_uuid)
@classmethod
def delete_reservation(cls, reservation: Reservation | uuid.UUID) -> None:
@@ -226,18 +352,99 @@ def delete_reservation(cls, reservation: Reservation | uuid.UUID) -> None:
action="deleting reservation",
expected_status_code=HTTP_204_NO_CONTENT,
)
- cls.clear_cached_reservation_response(ext_uuid=reservation.ext_uuid)
+ cls._clear_cached_reservation_response(ext_uuid=reservation_uuid)
+
+ # ----------------------------------------------------------------------------------------------------------------
+
+ @classmethod
+ def _cache_reservation_response(cls, data: PindoraReservationResponse, *, ext_uuid: uuid.UUID) -> str:
+ return cls._cache_response(data, ext_uuid=ext_uuid, prefix="reservation")
+
+ @classmethod
+ def _update_cached_reservation_response(
+ cls,
+ data: PindoraAccessCodeModifyResponse,
+ *,
+ ext_uuid: uuid.UUID,
+ ) -> None:
+ cached_data = cls._get_cached_reservation_response(ext_uuid=ext_uuid)
+ if cached_data is None:
+ return
+
+ cached_data.update(data)
+ cls._cache_reservation_response(cached_data, ext_uuid=ext_uuid)
+
+ @classmethod
+ def _get_cached_reservation_response(cls, *, ext_uuid: uuid.UUID) -> PindoraReservationResponse | None:
+ data = cls._get_cached_response(ext_uuid=ext_uuid, prefix="reservation")
+ if data is None:
+ return None
+ return cls._parse_reservation_response(data)
+
+ @classmethod
+ def _clear_cached_reservation_response(cls, *, ext_uuid: uuid.UUID) -> bool:
+ return cls._clear_cached_response(ext_uuid=ext_uuid, prefix="reservation")
+
+ @classmethod
+ def _parse_reservation_response(cls, data: dict[str, Any]) -> PindoraReservationResponse:
+ try:
+ return PindoraReservationResponse(
+ reservation_unit_id=uuid.UUID(data["reservation_unit_id"]),
+ access_code=data["access_code"],
+ access_code_keypad_url=data["access_code_keypad_url"],
+ access_code_phone_number=data["access_code_phone_number"],
+ access_code_sms_number=data["access_code_sms_number"],
+ access_code_sms_message=data["access_code_sms_message"],
+ access_code_valid_minutes_before=int(data["access_code_valid_minutes_before"]),
+ access_code_valid_minutes_after=int(data["access_code_valid_minutes_after"]),
+ access_code_generated_at=datetime.datetime.fromisoformat(data["access_code_generated_at"]),
+ access_code_is_active=bool(data["access_code_is_active"]),
+ begin=datetime.datetime.fromisoformat(data["begin"]),
+ end=datetime.datetime.fromisoformat(data["end"]),
+ )
+
+ except KeyError as error:
+ raise PindoraMissingKeyError(entity="reservation", key=error) from error
+
+ except (ValueError, TypeError) as error:
+ raise PindoraInvalidValueError(entity="reservation", error=error) from error
+
+ @classmethod
+ def _validate_reservation_response(
+ cls,
+ response: Response,
+ reservation_uuid: uuid.UUID,
+ *,
+ action: str,
+ expected_status_code: int = HTTP_200_OK,
+ ) -> None:
+ """Handle errors in reservation responses."""
+ cls._validate_response(response)
+
+ if response.status_code == HTTP_404_NOT_FOUND:
+ raise PindoraNotFoundError(entity="Reservation", uuid=reservation_uuid)
+
+ if response.status_code == HTTP_409_CONFLICT:
+ raise PindoraConflictError(entity="Reservation", uuid=reservation_uuid)
+
+ if response.status_code != expected_status_code:
+ raise PindoraUnexpectedResponseError(
+ action=action,
+ uuid=reservation_uuid,
+ status_code=response.status_code,
+ text=response.text,
+ )
- ####################
- # Seasonal booking #
- ####################
+
+class PindoraSeasonalBookingClient(BasePindoraClient):
+ """Pindora client for working with seasonal bookings (application sections)"""
@classmethod
def get_seasonal_booking(cls, section: ApplicationSection | uuid.UUID) -> PindoraSeasonalBookingResponse:
"""Fetch a seasonal booking from Pindora."""
section_uuid = section if isinstance(section, uuid.UUID) else section.ext_uuid
- response = cls.get_cached_seasonal_booking_response(ext_uuid=section_uuid)
+ response = cls._get_cached_seasonal_booking_response(ext_uuid=section_uuid)
if response is not None:
return response
@@ -252,7 +459,7 @@ def get_seasonal_booking(cls, section: ApplicationSection | uuid.UUID) -> Pindor
data = cls.response_json(response)
parsed_data = cls._parse_seasonal_booking_response(data)
- cls.cache_seasonal_booking_response(parsed_data, ext_uuid=section_uuid)
+ cls._cache_seasonal_booking_response(parsed_data, ext_uuid=section_uuid)
return parsed_data
@classmethod
@@ -260,19 +467,19 @@ def create_seasonal_booking(
cls,
section: ApplicationSection,
*,
- is_active: bool = False,
+ is_active: bool = True,
) -> PindoraSeasonalBookingResponse:
"""Create a new seasonal booking in Pindora."""
url = cls._build_url("seasonal-booking")
reservations: list[Reservation] = list(
section.actions.get_reservations()
- .filter(state=ReservationStateChoice.CONFIRMED)
+ .requires_active_access_code()
.select_related("recurring_reservation__reservation_unit")
)
if not reservations:
- msg = f"No confirmed reservations in seasonal booking '{section.ext_uuid}'."
+ msg = f"No reservations require an access code in seasonal booking '{section.ext_uuid}'."
raise PindoraClientError(msg)
data = PindoraSeasonalBookingCreateData(
@@ -297,17 +504,17 @@ def create_seasonal_booking(
data = cls.response_json(response)
parsed_data = cls._parse_seasonal_booking_response(data)
- cls.cache_seasonal_booking_response(parsed_data, ext_uuid=section.ext_uuid)
+ cls._cache_seasonal_booking_response(parsed_data, ext_uuid=section.ext_uuid)
return parsed_data
@classmethod
- def reschedule_seasonal_booking(cls, section: ApplicationSection) -> None:
+ def reschedule_seasonal_booking(cls, section: ApplicationSection) -> PindoraAccessCodeModifyResponse:
"""Reschedule a seasonal booking in Pindora."""
url = cls._build_url(f"seasonal-booking/reschedule/{section.ext_uuid}")
reservations: list[Reservation] = list(
section.actions.get_reservations()
- .filter(state=ReservationStateChoice.CONFIRMED)
+ .requires_active_access_code()
.select_related("recurring_reservation__reservation_unit")
)
@@ -331,12 +538,19 @@ def reschedule_seasonal_booking(cls, section: ApplicationSection) -> None:
response,
application_section_uuid=section.ext_uuid,
action="rescheduling seasonal booking",
- expected_status_code=HTTP_204_NO_CONTENT,
+ expected_status_code=HTTP_200_OK,
)
- cls.clear_cached_seasonal_booking_response(ext_uuid=section.ext_uuid)
+
+ data = cls.response_json(response)
+ parsed_data = cls._parse_access_code_modify_response(data)
+ cls._update_cached_seasonal_booking_response(parsed_data, ext_uuid=section.ext_uuid)
+ return parsed_data
@classmethod
- def change_seasonal_booking_access_code(cls, section: ApplicationSection | uuid.UUID) -> None:
+ def change_seasonal_booking_access_code(
+ cls,
+ section: ApplicationSection | uuid.UUID,
+ ) -> PindoraAccessCodeModifyResponse:
"""Change a seasonal booking's access code in Pindora."""
section_uuid = section if isinstance(section, uuid.UUID) else section.ext_uuid
@@ -347,9 +561,13 @@ def change_seasonal_booking_access_code(cls, section: ApplicationSection | uuid.
response,
application_section_uuid=section_uuid,
action="changing access code for seasonal booking",
- expected_status_code=HTTP_204_NO_CONTENT,
+ expected_status_code=HTTP_200_OK,
)
- cls.clear_cached_seasonal_booking_response(ext_uuid=section.ext_uuid)
+
+ data = cls.response_json(response)
+ parsed_data = cls._parse_access_code_modify_response(data)
+ cls._update_cached_seasonal_booking_response(parsed_data, ext_uuid=section_uuid)
+ return parsed_data
@classmethod
def activate_seasonal_booking_access_code(cls, section: ApplicationSection | uuid.UUID) -> None:
@@ -365,7 +583,7 @@ def activate_seasonal_booking_access_code(cls, section: ApplicationSection | uui
action="activating access code for seasonal booking",
expected_status_code=HTTP_204_NO_CONTENT,
)
- cls.clear_cached_seasonal_booking_response(ext_uuid=section.ext_uuid)
+ cls._clear_cached_seasonal_booking_response(ext_uuid=section_uuid)
@classmethod
def deactivate_seasonal_booking_access_code(cls, section: ApplicationSection | uuid.UUID) -> None:
@@ -381,7 +599,7 @@ def deactivate_seasonal_booking_access_code(cls, section: ApplicationSection | u
action="deactivating access code for seasonal booking",
expected_status_code=HTTP_204_NO_CONTENT,
)
- cls.clear_cached_seasonal_booking_response(ext_uuid=section.ext_uuid)
+ cls._clear_cached_seasonal_booking_response(ext_uuid=section_uuid)
@classmethod
def delete_seasonal_booking(cls, section: ApplicationSection | uuid.UUID) -> None:
@@ -397,63 +615,149 @@ def delete_seasonal_booking(cls, section: ApplicationSection | uuid.UUID) -> Non
action="deleting seasonal booking",
expected_status_code=HTTP_204_NO_CONTENT,
)
- cls.clear_cached_seasonal_booking_response(ext_uuid=section.ext_uuid)
+ cls._clear_cached_seasonal_booking_response(ext_uuid=section_uuid)
- ######################
- # Reservation series #
- ######################
+ # ----------------------------------------------------------------------------------------------------------------
@classmethod
- def get_reservation_series(cls, series: RecurringReservation | uuid.UUID) -> PindoraReservationSeriesResponse:
- """Fetch a reservation series from Pindora."""
- series_uuid = series if isinstance(series, uuid.UUID) else series.ext_uuid
-
- response = cls.get_cached_reservation_series_response(ext_uuid=series_uuid)
- if response is not None:
- return response
-
- url = cls._build_url(f"reservation-series/{series_uuid}")
-
- response = cls.get(url=url)
- cls._validate_reservation_series_response(
- response,
- series_uuid=series_uuid,
- action="fetching reservation series",
- )
-
- data = cls.response_json(response)
- parsed_data = cls._parse_reservation_series_response(data)
- cls.cache_reservation_series_response(parsed_data, ext_uuid=series.ext_uuid)
- return parsed_data
+ def _cache_seasonal_booking_response(cls, data: PindoraSeasonalBookingResponse, *, ext_uuid: uuid.UUID) -> str:
+ return cls._cache_response(data, ext_uuid=ext_uuid, prefix="seasonal-booking")
@classmethod
- def create_reservation_series(
+ def _update_cached_seasonal_booking_response(
cls,
- series: RecurringReservation,
+ data: PindoraAccessCodeModifyResponse,
*,
- is_active: bool = False,
- ) -> PindoraReservationSeriesResponse:
- """Create a new reservation series in Pindora."""
- url = cls._build_url("reservation-series")
+ ext_uuid: uuid.UUID,
+ ) -> None:
+ cached_data = cls._get_cached_seasonal_booking_response(ext_uuid=ext_uuid)
+ if cached_data is None:
+ return
- reservations: list[Reservation] = list(series.reservations.filter(state=ReservationStateChoice.CONFIRMED))
+ cached_data.update(data)
+ cls._cache_seasonal_booking_response(cached_data, ext_uuid=ext_uuid)
- if not reservations:
- msg = f"No confirmed reservations in reservation series '{series.ext_uuid}'."
- raise PindoraClientError(msg)
+ @classmethod
+ def _get_cached_seasonal_booking_response(cls, *, ext_uuid: uuid.UUID) -> PindoraSeasonalBookingResponse | None:
+ data = cls._get_cached_response(ext_uuid=ext_uuid, prefix="seasonal-booking")
+ if data is None:
+ return None
+ return cls._parse_seasonal_booking_response(data)
- data = PindoraReservationSeriesCreateData(
- reservation_series_id=str(series.ext_uuid),
- reservation_unit_id=str(series.reservation_unit.uuid),
- series=[
- PindoraReservationSeriesReservationData(
- begin=local_iso_format(reservation.begin),
- end=local_iso_format(reservation.end),
- )
- for reservation in reservations
- ],
- is_active=is_active,
- )
+ @classmethod
+ def _clear_cached_seasonal_booking_response(cls, *, ext_uuid: uuid.UUID) -> bool:
+ return cls._clear_cached_response(ext_uuid=ext_uuid, prefix="seasonal-booking")
+
+ @classmethod
+ def _parse_seasonal_booking_response(cls, data: dict[str, Any]) -> PindoraSeasonalBookingResponse:
+ try:
+ return PindoraSeasonalBookingResponse(
+ access_code=data["access_code"],
+ access_code_keypad_url=data["access_code_keypad_url"],
+ access_code_phone_number=data["access_code_phone_number"],
+ access_code_sms_number=data["access_code_sms_number"],
+ access_code_sms_message=data["access_code_sms_message"],
+ access_code_generated_at=datetime.datetime.fromisoformat(data["access_code_generated_at"]),
+ access_code_is_active=bool(data["access_code_is_active"]),
+ reservation_unit_code_validity=[
+ PindoraSeasonalBookingAccessCodeValidity(
+ reservation_unit_id=uuid.UUID(validity["reservation_unit_id"]),
+ access_code_valid_minutes_before=int(validity["access_code_valid_minutes_before"]),
+ access_code_valid_minutes_after=int(validity["access_code_valid_minutes_after"]),
+ begin=datetime.datetime.fromisoformat(validity["begin"]),
+ end=datetime.datetime.fromisoformat(validity["end"]),
+ )
+ for validity in data["reservation_unit_code_validity"]
+ ],
+ )
+
+ except KeyError as error:
+ raise PindoraMissingKeyError(entity="seasonal booking", key=error) from error
+
+ except (ValueError, TypeError) as error:
+ raise PindoraInvalidValueError(entity="seasonal booking", error=error) from error
+
+ @classmethod
+ def _validate_seasonal_booking_response(
+ cls,
+ response: Response,
+ application_section_uuid: uuid.UUID,
+ *,
+ action: str,
+ expected_status_code: int = HTTP_200_OK,
+ ) -> None:
+ """Handle errors in seasonal booking responses."""
+ cls._validate_response(response)
+
+ if response.status_code == HTTP_404_NOT_FOUND:
+ raise PindoraNotFoundError(entity="Seasonal booking", uuid=application_section_uuid)
+
+ if response.status_code == HTTP_409_CONFLICT:
+ raise PindoraConflictError(entity="Seasonal booking", uuid=application_section_uuid)
+
+ if response.status_code != expected_status_code:
+ raise PindoraUnexpectedResponseError(
+ action=action,
+ uuid=application_section_uuid,
+ status_code=response.status_code,
+ text=response.text,
+ )
+
+
+class PindoraReservationSeriesClient(BasePindoraClient):
+ """Pindora client for working with reservation series (recurring reservations)"""
+
+ @classmethod
+ def get_reservation_series(cls, series: RecurringReservation | uuid.UUID) -> PindoraReservationSeriesResponse:
+ """Fetch a reservation series from Pindora."""
+ series_uuid = series if isinstance(series, uuid.UUID) else series.ext_uuid
+
+ response = cls._get_cached_reservation_series_response(ext_uuid=series_uuid)
+ if response is not None:
+ return response
+
+ url = cls._build_url(f"reservation-series/{series_uuid}")
+
+ response = cls.get(url=url)
+ cls._validate_reservation_series_response(
+ response,
+ series_uuid=series_uuid,
+ action="fetching reservation series",
+ )
+
+ data = cls.response_json(response)
+ parsed_data = cls._parse_reservation_series_response(data)
+ cls._cache_reservation_series_response(parsed_data, ext_uuid=series_uuid)
+ return parsed_data
+
+ @classmethod
+ def create_reservation_series(
+ cls,
+ series: RecurringReservation,
+ *,
+ is_active: bool = True,
+ ) -> PindoraReservationSeriesResponse:
+ """Create a new reservation series in Pindora."""
+ url = cls._build_url("reservation-series")
+
+ reservations: list[Reservation] = list(series.reservations.requires_active_access_code())
+
+ if not reservations:
+ msg = f"No reservations require an access code in reservation series '{series.ext_uuid}'."
+ raise PindoraClientError(msg)
+
+ data = PindoraReservationSeriesCreateData(
+ reservation_series_id=str(series.ext_uuid),
+ reservation_unit_id=str(series.reservation_unit.uuid),
+ series=[
+ PindoraReservationSeriesReservationData(
+ begin=local_iso_format(reservation.begin),
+ end=local_iso_format(reservation.end),
+ )
+ for reservation in reservations
+ ],
+ is_active=is_active,
+ )
response = cls.post(url=url, json=data)
cls._validate_reservation_series_response(
@@ -464,15 +768,15 @@ def create_reservation_series(
data = cls.response_json(response)
parsed_data = cls._parse_reservation_series_response(data)
- cls.cache_reservation_series_response(parsed_data, ext_uuid=series.ext_uuid)
+ cls._cache_reservation_series_response(parsed_data, ext_uuid=series.ext_uuid)
return parsed_data
@classmethod
- def reschedule_reservation_series(cls, series: RecurringReservation) -> None:
+ def reschedule_reservation_series(cls, series: RecurringReservation) -> PindoraAccessCodeModifyResponse:
"""Reschedule a reservation series in Pindora."""
url = cls._build_url(f"reservation-series/reschedule/{series.ext_uuid}")
- reservations: list[Reservation] = list(series.reservations.filter(state=ReservationStateChoice.CONFIRMED))
+ reservations: list[Reservation] = list(series.reservations.requires_active_access_code())
if not reservations:
msg = f"No confirmed reservations in reservation series '{series.ext_uuid}'."
@@ -493,12 +797,19 @@ def reschedule_reservation_series(cls, series: RecurringReservation) -> None:
response,
series_uuid=series.ext_uuid,
action="rescheduling reservation series",
- expected_status_code=HTTP_204_NO_CONTENT,
+ expected_status_code=HTTP_200_OK,
)
- cls.clear_cached_reservation_series_response(ext_uuid=series.ext_uuid)
+
+ data = cls.response_json(response)
+ parsed_data = cls._parse_access_code_modify_response(data)
+ cls._update_cached_reservation_series_response(parsed_data, ext_uuid=series.ext_uuid)
+ return parsed_data
@classmethod
- def change_reservation_series_access_code(cls, series: RecurringReservation | uuid.UUID) -> None:
+ def change_reservation_series_access_code(
+ cls,
+ series: RecurringReservation | uuid.UUID,
+ ) -> PindoraAccessCodeModifyResponse:
"""Change a reservation series' access code in Pindora."""
series_uuid = series if isinstance(series, uuid.UUID) else series.ext_uuid
@@ -509,9 +820,13 @@ def change_reservation_series_access_code(cls, series: RecurringReservation | uu
response,
series_uuid=series_uuid,
action="changing access code for reservation series",
- expected_status_code=HTTP_204_NO_CONTENT,
+ expected_status_code=HTTP_200_OK,
)
- cls.clear_cached_reservation_series_response(ext_uuid=series.ext_uuid)
+
+ data = cls.response_json(response)
+ parsed_data = cls._parse_access_code_modify_response(data)
+ cls._update_cached_reservation_series_response(parsed_data, ext_uuid=series_uuid)
+ return parsed_data
@classmethod
def activate_reservation_series_access_code(cls, series: RecurringReservation | uuid.UUID) -> None:
@@ -527,7 +842,7 @@ def activate_reservation_series_access_code(cls, series: RecurringReservation |
action="activating access code for reservation series",
expected_status_code=HTTP_204_NO_CONTENT,
)
- cls.clear_cached_reservation_series_response(ext_uuid=series.ext_uuid)
+ cls._clear_cached_reservation_series_response(ext_uuid=series_uuid)
@classmethod
def deactivate_reservation_series_access_code(cls, series: RecurringReservation | uuid.UUID) -> None:
@@ -543,7 +858,7 @@ def deactivate_reservation_series_access_code(cls, series: RecurringReservation
action="deactivating access code for reservation series",
expected_status_code=HTTP_204_NO_CONTENT,
)
- cls.clear_cached_reservation_series_response(ext_uuid=series.ext_uuid)
+ cls._clear_cached_reservation_series_response(ext_uuid=series_uuid)
@classmethod
def delete_reservation_series(cls, series: RecurringReservation | uuid.UUID) -> None:
@@ -559,168 +874,39 @@ def delete_reservation_series(cls, series: RecurringReservation | uuid.UUID) ->
action="deleting reservation series",
expected_status_code=HTTP_204_NO_CONTENT,
)
- cls.clear_cached_reservation_series_response(ext_uuid=series.ext_uuid)
-
- ###########
- # Caching #
- ###########
-
- @classmethod
- def cache_reservation_unit_response(cls, data: PindoraReservationUnitResponse, *, ext_uuid: uuid.UUID) -> str:
- return cls._cache_response(data, ext_uuid=ext_uuid, prefix="reservation-unit")
-
- @classmethod
- def get_cached_reservation_unit_response(cls, *, ext_uuid: uuid.UUID) -> PindoraReservationUnitResponse | None:
- data = cls._get_cached_response(ext_uuid=ext_uuid, prefix="reservation-unit")
- if data is None:
- return None
- return cls._parse_reservation_unit_response(data)
+ cls._clear_cached_reservation_series_response(ext_uuid=series_uuid)
- @classmethod
- def clear_cached_reservation_unit_response(cls, *, ext_uuid: uuid.UUID) -> bool:
- return cls._clear_cached_response(ext_uuid=ext_uuid, prefix="reservation-unit")
+ # ----------------------------------------------------------------------------------------------------------------
@classmethod
- def cache_reservation_response(cls, data: PindoraReservationResponse, *, ext_uuid: uuid.UUID) -> str:
- return cls._cache_response(data, ext_uuid=ext_uuid, prefix="reservation")
-
- @classmethod
- def get_cached_reservation_response(cls, *, ext_uuid: uuid.UUID) -> PindoraReservationResponse | None:
- data = cls._get_cached_response(ext_uuid=ext_uuid, prefix="reservation")
- if data is None:
- return None
- return cls._parse_reservation_response(data)
-
- @classmethod
- def clear_cached_reservation_response(cls, *, ext_uuid: uuid.UUID) -> bool:
- return cls._clear_cached_response(ext_uuid=ext_uuid, prefix="reservation")
-
- @classmethod
- def cache_seasonal_booking_response(cls, data: PindoraSeasonalBookingResponse, *, ext_uuid: uuid.UUID) -> str:
- return cls._cache_response(data, ext_uuid=ext_uuid, prefix="seasonal-booking")
-
- @classmethod
- def get_cached_seasonal_booking_response(cls, *, ext_uuid: uuid.UUID) -> PindoraSeasonalBookingResponse | None:
- data = cls._get_cached_response(ext_uuid=ext_uuid, prefix="seasonal-booking")
- if data is None:
- return None
- return cls._parse_seasonal_booking_response(data)
+ def _cache_reservation_series_response(cls, data: PindoraReservationSeriesResponse, *, ext_uuid: uuid.UUID) -> str:
+ return cls._cache_response(data, ext_uuid=ext_uuid, prefix="reservation-series")
@classmethod
- def clear_cached_seasonal_booking_response(cls, *, ext_uuid: uuid.UUID) -> bool:
- return cls._clear_cached_response(ext_uuid=ext_uuid, prefix="seasonal-booking")
+ def _update_cached_reservation_series_response(
+ cls,
+ data: PindoraAccessCodeModifyResponse,
+ *,
+ ext_uuid: uuid.UUID,
+ ) -> None:
+ cached_data = cls._get_cached_reservation_series_response(ext_uuid=ext_uuid)
+ if cached_data is None:
+ return
- @classmethod
- def cache_reservation_series_response(cls, data: PindoraReservationSeriesResponse, *, ext_uuid: uuid.UUID) -> str:
- return cls._cache_response(data, ext_uuid=ext_uuid, prefix="reservation-series")
+ cached_data.update(data)
+ cls._cache_reservation_series_response(cached_data, ext_uuid=ext_uuid)
@classmethod
- def get_cached_reservation_series_response(cls, *, ext_uuid: uuid.UUID) -> PindoraReservationSeriesResponse | None:
+ def _get_cached_reservation_series_response(cls, *, ext_uuid: uuid.UUID) -> PindoraReservationSeriesResponse | None:
data = cls._get_cached_response(ext_uuid=ext_uuid, prefix="reservation-series")
if data is None:
return None
return cls._parse_reservation_series_response(data)
@classmethod
- def clear_cached_reservation_series_response(cls, *, ext_uuid: uuid.UUID) -> bool:
+ def _clear_cached_reservation_series_response(cls, *, ext_uuid: uuid.UUID) -> bool:
return cls._clear_cached_response(ext_uuid=ext_uuid, prefix="reservation-series")
- @classmethod
- def _cache_response(cls, data: dict[str, Any], *, ext_uuid: uuid.UUID, prefix: str) -> str:
- cache_key = cls._cache_key(ext_uuid=ext_uuid, prefix=prefix)
- cache_data = json.dumps(data, default=str)
- cache.set(cache_key, cache_data, timeout=30)
- return cache_data
-
- @classmethod
- def _get_cached_response(cls, *, ext_uuid: uuid.UUID, prefix: str) -> dict[str, Any] | None:
- cache_key = cls._cache_key(ext_uuid=ext_uuid, prefix=prefix)
- cached_data = cache.get(cache_key)
- if cached_data is None:
- return None
- return json.loads(cached_data)
-
- @classmethod
- def _clear_cached_response(cls, *, ext_uuid: uuid.UUID, prefix: str) -> bool:
- cache_key = cls._cache_key(ext_uuid=ext_uuid, prefix=prefix)
- return cache.delete(cache_key)
-
- @classmethod
- def _cache_key(cls, *, ext_uuid: uuid.UUID, prefix: str) -> str:
- return f"pindora:{prefix}:{ext_uuid}"
-
- ##################
- # Helper methods #
- ##################
-
- @classmethod
- def _parse_reservation_unit_response(cls, data: dict[str, Any]) -> PindoraReservationUnitResponse:
- try:
- return PindoraReservationUnitResponse(
- reservation_unit_id=uuid.UUID(data["reservation_unit_id"]),
- name=data["name"],
- keypad_url=data["keypad_url"],
- )
-
- except KeyError as error:
- raise PindoraMissingKeyError(entity="reservation unit", key=error) from error
-
- except (ValueError, TypeError) as error:
- raise PindoraInvalidValueError(entity="reservation unit", error=error) from error
-
- @classmethod
- def _parse_reservation_response(cls, data: dict[str, Any]) -> PindoraReservationResponse:
- try:
- return PindoraReservationResponse(
- reservation_unit_id=uuid.UUID(data["reservation_unit_id"]),
- access_code=data["access_code"],
- access_code_keypad_url=data["access_code_keypad_url"],
- access_code_phone_number=data["access_code_phone_number"],
- access_code_sms_number=data["access_code_sms_number"],
- access_code_sms_message=data["access_code_sms_message"],
- access_code_valid_minutes_before=int(data["access_code_valid_minutes_before"]),
- access_code_valid_minutes_after=int(data["access_code_valid_minutes_after"]),
- access_code_generated_at=datetime.datetime.fromisoformat(data["access_code_generated_at"]),
- access_code_is_active=bool(data["access_code_is_active"]),
- begin=datetime.datetime.fromisoformat(data["begin"]),
- end=datetime.datetime.fromisoformat(data["end"]),
- )
-
- except KeyError as error:
- raise PindoraMissingKeyError(entity="reservation", key=error) from error
-
- except (ValueError, TypeError) as error:
- raise PindoraInvalidValueError(entity="reservation", error=error) from error
-
- @classmethod
- def _parse_seasonal_booking_response(cls, data: dict[str, Any]) -> PindoraSeasonalBookingResponse:
- try:
- return PindoraSeasonalBookingResponse(
- access_code=data["access_code"],
- access_code_keypad_url=data["access_code_keypad_url"],
- access_code_phone_number=data["access_code_phone_number"],
- access_code_sms_number=data["access_code_sms_number"],
- access_code_sms_message=data["access_code_sms_message"],
- access_code_generated_at=datetime.datetime.fromisoformat(data["access_code_generated_at"]),
- access_code_is_active=bool(data["access_code_is_active"]),
- reservation_unit_code_validity=[
- PindoraSeasonalBookingAccessCodeValidity(
- reservation_unit_id=uuid.UUID(validity["reservation_unit_id"]),
- access_code_valid_minutes_before=int(validity["access_code_valid_minutes_before"]),
- access_code_valid_minutes_after=int(validity["access_code_valid_minutes_after"]),
- begin=datetime.datetime.fromisoformat(validity["begin"]),
- end=datetime.datetime.fromisoformat(validity["end"]),
- )
- for validity in data["reservation_unit_code_validity"]
- ],
- )
-
- except KeyError as error:
- raise PindoraMissingKeyError(entity="seasonal booking", key=error) from error
-
- except (ValueError, TypeError) as error:
- raise PindoraInvalidValueError(entity="seasonal booking", error=error) from error
-
@classmethod
def _parse_reservation_series_response(cls, data: dict[str, Any]) -> PindoraReservationSeriesResponse:
try:
@@ -750,100 +936,6 @@ def _parse_reservation_series_response(cls, data: dict[str, Any]) -> PindoraRese
except (ValueError, TypeError) as error:
raise PindoraInvalidValueError(entity="reservation series", error=error) from error
- @classmethod
- def _build_url(cls, endpoint: str) -> str:
- if not settings.PINDORA_API_URL:
- raise PindoraClientConfigurationError(config="PINDORA_API_URL")
-
- base_url = settings.PINDORA_API_URL.removesuffix("/")
- return f"{base_url}/{endpoint}"
-
- @classmethod
- def _get_headers(cls, headers: dict[str, Any] | None) -> dict[str, str]:
- if not settings.PINDORA_API_KEY:
- raise PindoraClientConfigurationError(config="PINDORA_API_KEY")
-
- return {
- "Pindora-Api-Key": str(settings.PINDORA_API_KEY),
- "Accept": "application/vdn.varaamo-pindora.v1+json",
- **(headers or {}),
- }
-
- @classmethod
- def _validate_reservation_unit_response(
- cls,
- response: Response,
- reservation_unit_uuid: uuid.UUID,
- *,
- action: str,
- expected_status_code: int = HTTP_200_OK,
- ) -> None:
- """Handle errors in reservation unit responses."""
- cls._validate_response(response)
-
- if response.status_code == HTTP_404_NOT_FOUND:
- raise PindoraNotFoundError(entity="Reservation unit", uuid=reservation_unit_uuid)
-
- if response.status_code != expected_status_code:
- raise PindoraUnexpectedResponseError(
- action=action,
- uuid=reservation_unit_uuid,
- status_code=response.status_code,
- text=response.text,
- )
-
- @classmethod
- def _validate_reservation_response(
- cls,
- response: Response,
- reservation_uuid: uuid.UUID,
- *,
- action: str,
- expected_status_code: int = HTTP_200_OK,
- ) -> None:
- """Handle errors in reservation responses."""
- cls._validate_response(response)
-
- if response.status_code == HTTP_404_NOT_FOUND:
- raise PindoraNotFoundError(entity="Reservation", uuid=reservation_uuid)
-
- if response.status_code == HTTP_409_CONFLICT:
- raise PindoraConflictError(entity="Reservation", uuid=reservation_uuid)
-
- if response.status_code != expected_status_code:
- raise PindoraUnexpectedResponseError(
- action=action,
- uuid=reservation_uuid,
- status_code=response.status_code,
- text=response.text,
- )
-
- @classmethod
- def _validate_seasonal_booking_response(
- cls,
- response: Response,
- application_section_uuid: uuid.UUID,
- *,
- action: str,
- expected_status_code: int = HTTP_200_OK,
- ) -> None:
- """Handle errors in seasonal booking responses."""
- cls._validate_response(response)
-
- if response.status_code == HTTP_404_NOT_FOUND:
- raise PindoraNotFoundError(entity="Seasonal booking", uuid=application_section_uuid)
-
- if response.status_code == HTTP_409_CONFLICT:
- raise PindoraConflictError(entity="Seasonal booking", uuid=application_section_uuid)
-
- if response.status_code != expected_status_code:
- raise PindoraUnexpectedResponseError(
- action=action,
- uuid=application_section_uuid,
- status_code=response.status_code,
- text=response.text,
- )
-
@classmethod
def _validate_reservation_series_response(
cls,
@@ -870,11 +962,11 @@ def _validate_reservation_series_response(
text=response.text,
)
- @classmethod
- def _validate_response(cls, response: Response) -> None:
- """Handle common errors in Pindora API responses."""
- if response.status_code == HTTP_403_FORBIDDEN:
- raise PindoraPermissionError
- if response.status_code == HTTP_400_BAD_REQUEST:
- raise PindoraBadRequestError(text=response.text)
+class PindoraClient(
+ PindoraReservationUnitClient,
+ PindoraReservationClient,
+ PindoraSeasonalBookingClient,
+ PindoraReservationSeriesClient,
+):
+ """Client for the Pindora-Tilavaraus API."""
diff --git a/backend/tilavarauspalvelu/integrations/keyless_entry/typing.py b/backend/tilavarauspalvelu/integrations/keyless_entry/typing.py
index a41e8327f7..ba933207b9 100644
--- a/backend/tilavarauspalvelu/integrations/keyless_entry/typing.py
+++ b/backend/tilavarauspalvelu/integrations/keyless_entry/typing.py
@@ -7,6 +7,7 @@
import uuid
__all__ = [
+ "PindoraAccessCodeModifyResponse",
"PindoraReservationCreateData",
"PindoraReservationRescheduleData",
"PindoraReservationResponse",
@@ -35,6 +36,11 @@ class PindoraReservationUnitResponse(TypedDict):
keypad_url: str # url
+class PindoraAccessCodeModifyResponse(TypedDict):
+ access_code_generated_at: datetime.datetime
+ access_code_is_active: bool
+
+
class PindoraReservationResponse(TypedDict):
reservation_unit_id: uuid.UUID
access_code: str
@@ -50,14 +56,17 @@ class PindoraReservationResponse(TypedDict):
end: datetime.datetime
-class PindoraSeasonalBookingAccessCodeValidity(TypedDict):
- reservation_unit_id: uuid.UUID
+class PindoraAccessCodeValidity(TypedDict):
access_code_valid_minutes_before: int
access_code_valid_minutes_after: int
begin: datetime.datetime
end: datetime.datetime
+class PindoraSeasonalBookingAccessCodeValidity(PindoraAccessCodeValidity):
+ reservation_unit_id: uuid.UUID
+
+
class PindoraSeasonalBookingResponse(TypedDict):
access_code: str
access_code_keypad_url: str # url
@@ -69,11 +78,7 @@ class PindoraSeasonalBookingResponse(TypedDict):
reservation_unit_code_validity: list[PindoraSeasonalBookingAccessCodeValidity]
-class PindoraReservationSeriesAccessCodeValidity(TypedDict):
- access_code_valid_minutes_before: int
- access_code_valid_minutes_after: int
- begin: datetime.datetime
- end: datetime.datetime
+class PindoraReservationSeriesAccessCodeValidity(PindoraAccessCodeValidity): ...
class PindoraReservationSeriesResponse(TypedDict):
diff --git a/backend/tilavarauspalvelu/management/commands/data_creation/create_reservation_units.py b/backend/tilavarauspalvelu/management/commands/data_creation/create_reservation_units.py
index 2561632c1f..cb99fe0c86 100644
--- a/backend/tilavarauspalvelu/management/commands/data_creation/create_reservation_units.py
+++ b/backend/tilavarauspalvelu/management/commands/data_creation/create_reservation_units.py
@@ -22,6 +22,7 @@
from tilavarauspalvelu.models import (
PaymentProduct,
ReservationUnit,
+ ReservationUnitAccessType,
ReservationUnitImage,
ReservationUnitPricing,
Resource,
@@ -33,6 +34,7 @@
from tests.factories import (
PaymentProductFactory,
+ ReservationUnitAccessTypeFactory,
ReservationUnitPricingFactory,
ReservationUnitTypeFactory,
ResourceFactory,
@@ -253,6 +255,7 @@ def _create_free_reservation_units(
reservation_units: list[ReservationUnit] = []
reservation_unit_spaces: list[models.Model] = []
pricings: list[ReservationUnitPricing] = []
+ access_types: list[ReservationUnitAccessType] = []
images: list[ReservationUnitImage] = []
reservation_unit_type = ReservationUnitTypeFactory.create(
@@ -321,7 +324,6 @@ def _create_free_reservation_units(
max_reservations_per_user=None,
reservation_unit_type=reservation_unit_type,
reservation_kind=reservation_kind,
- access_type=AccessType.UNRESTRICTED,
cancellation_rule=random.choice(data.cancellation_rule_info.value),
require_reservation_handling=data.handling_info.handling_required,
metadata_set=metadata_sets[set_name],
@@ -368,6 +370,13 @@ def _create_free_reservation_units(
)
pricings.append(pricing)
+ access_type = ReservationUnitAccessTypeFactory.build(
+ reservation_unit=reservation_unit,
+ begin_date=datetime.date(2021, 1, 1),
+ access_type=AccessType.UNRESTRICTED,
+ )
+ access_types.append(access_type)
+
image = _fetch_and_build_reservation_unit_image(
reservation_unit=reservation_unit,
image_url="https://images.unsplash.com/photo-1577412647305-991150c7d163",
@@ -380,6 +389,7 @@ def _create_free_reservation_units(
ReservationUnit.objects.bulk_create(reservation_units)
ReservationUnitSpacesThoughModel.objects.bulk_create(reservation_unit_spaces)
ReservationUnitPricing.objects.bulk_create(pricings)
+ ReservationUnitAccessType.objects.bulk_create(access_types)
ReservationUnitImage.objects.bulk_create(images)
@@ -462,6 +472,7 @@ def _create_paid_reservation_units(
reservation_unit_spaces: list[models.Model] = []
reservation_unit_payment_types: list[models.Model] = []
pricings: list[ReservationUnitPricing] = []
+ access_types: list[ReservationUnitAccessType] = []
payment_products: list[PaymentProduct] = []
images: list[ReservationUnitImage] = []
@@ -543,7 +554,6 @@ def _create_paid_reservation_units(
max_reservations_per_user=None,
reservation_unit_type=reservation_unit_type,
reservation_kind=reservation_kind,
- access_type=AccessType.PHYSICAL_KEY,
cancellation_rule=random.choice(data.cancellation_rule_info.value),
require_reservation_handling=data.handling_info.handling_required,
metadata_set=metadata_sets[set_name],
@@ -596,6 +606,13 @@ def _create_paid_reservation_units(
)
pricings.append(pricing)
+ access_type = ReservationUnitAccessTypeFactory.build(
+ reservation_unit=reservation_unit,
+ begin_date=datetime.date(2021, 1, 1),
+ access_type=AccessType.PHYSICAL_KEY,
+ )
+ access_types.append(access_type)
+
image = _fetch_and_build_reservation_unit_image(
reservation_unit=reservation_unit,
image_url="https://images.unsplash.com/photo-1414452110837-9dab484a417d",
diff --git a/backend/tilavarauspalvelu/migrations/0067_reservationunitaccesstype.py b/backend/tilavarauspalvelu/migrations/0067_reservationunitaccesstype.py
new file mode 100644
index 0000000000..d4bbfcbf35
--- /dev/null
+++ b/backend/tilavarauspalvelu/migrations/0067_reservationunitaccesstype.py
@@ -0,0 +1,64 @@
+# Generated by Django 5.1.4 on 2025-02-17 12:13
+from __future__ import annotations
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("tilavarauspalvelu", "0066_remove_reservationunit_require_introduction"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ReservationUnitAccessType",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "access_type",
+ models.CharField(
+ choices=[
+ ("ACCESS_CODE", "access code"),
+ ("OPENED_BY_STAFF", "opened by staff"),
+ ("PHYSICAL_KEY", "physical key"),
+ ("UNRESTRICTED", "unrestricted"),
+ ],
+ default="UNRESTRICTED",
+ max_length=255,
+ ),
+ ),
+ ("begin_date", models.DateField()),
+ (
+ "reservation_unit",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="access_types",
+ to="tilavarauspalvelu.reservationunit",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "reservation unit access type",
+ "verbose_name_plural": "reservation unit access types",
+ "db_table": "reservation_unit_access_type",
+ "ordering": ["reservation_unit", "begin_date"],
+ "base_manager_name": "objects",
+ "constraints": [
+ models.UniqueConstraint(
+ fields=("reservation_unit", "begin_date"),
+ name="single_access_type_per_day_per_reservation_unit",
+ violation_error_message="Access type already exists for this reservation unit and date",
+ )
+ ],
+ },
+ ),
+ ]
diff --git a/backend/tilavarauspalvelu/migrations/0068_remove_direct_access_type_from_reservation_unit.py b/backend/tilavarauspalvelu/migrations/0068_remove_direct_access_type_from_reservation_unit.py
new file mode 100644
index 0000000000..b3e1069fc5
--- /dev/null
+++ b/backend/tilavarauspalvelu/migrations/0068_remove_direct_access_type_from_reservation_unit.py
@@ -0,0 +1,29 @@
+# Generated by Django 5.1.4 on 2025-02-18 16:02
+from __future__ import annotations
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("tilavarauspalvelu", "0067_reservationunitaccesstype"),
+ ]
+
+ operations = [
+ migrations.RemoveConstraint(
+ model_name="reservationunit",
+ name="access_type_starts_before_ends",
+ ),
+ migrations.RemoveField(
+ model_name="reservationunit",
+ name="access_type",
+ ),
+ migrations.RemoveField(
+ model_name="reservationunit",
+ name="access_type_end_date",
+ ),
+ migrations.RemoveField(
+ model_name="reservationunit",
+ name="access_type_start_date",
+ ),
+ ]
diff --git a/backend/tilavarauspalvelu/models/__init__.py b/backend/tilavarauspalvelu/models/__init__.py
index 3b0bdb9197..4b1aed7ab7 100644
--- a/backend/tilavarauspalvelu/models/__init__.py
+++ b/backend/tilavarauspalvelu/models/__init__.py
@@ -39,6 +39,7 @@
from .reservation_statistic.model import ReservationStatistic
from .reservation_statistic_unit.model import ReservationStatisticsReservationUnit
from .reservation_unit.model import ReservationUnit
+from .reservation_unit_access_type.model import ReservationUnitAccessType
from .reservation_unit_cancellation_rule.model import ReservationUnitCancellationRule
from .reservation_unit_hierarchy.model import ReservationUnitHierarchy
from .reservation_unit_image.model import ReservationUnitImage
@@ -98,6 +99,7 @@
"ReservationStatistic",
"ReservationStatisticsReservationUnit",
"ReservationUnit",
+ "ReservationUnitAccessType",
"ReservationUnitCancellationRule",
"ReservationUnitHierarchy",
"ReservationUnitImage",
diff --git a/backend/tilavarauspalvelu/models/application_section/model.py b/backend/tilavarauspalvelu/models/application_section/model.py
index aba8f750b2..ef5877d764 100644
--- a/backend/tilavarauspalvelu/models/application_section/model.py
+++ b/backend/tilavarauspalvelu/models/application_section/model.py
@@ -6,7 +6,7 @@
from typing import TYPE_CHECKING
from django.db import models
-from django.db.models import OrderBy
+from django.db.models import Exists, OrderBy
from django.db.models.functions import Coalesce
from django.utils.translation import gettext_lazy as _
from helsinki_gdpr.models import SerializableMixin
@@ -268,3 +268,27 @@ def status_sort_order() -> int:
default=models.Value(5),
output_field=models.IntegerField(),
)
+
+ @lookup_property(skip_codegen=True)
+ def should_have_active_access_code() -> bool:
+ """Should at least one reservation in this application section contain an active access code?"""
+ from tilavarauspalvelu.models import Reservation
+
+ exists = Exists(
+ queryset=Reservation.objects.filter(
+ L(access_code_should_be_active=True),
+ recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=(
+ models.OuterRef("pk")
+ ),
+ ),
+ )
+ return exists # type: ignore[return-value] # noqa: RET504
+
+ @should_have_active_access_code.override
+ def _(self) -> bool:
+ from tilavarauspalvelu.models import Reservation
+
+ return Reservation.objects.filter(
+ L(access_code_should_be_active=True),
+ recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=self,
+ ).exists()
diff --git a/backend/tilavarauspalvelu/models/recurring_reservation/actions.py b/backend/tilavarauspalvelu/models/recurring_reservation/actions.py
index ebe5ed8c60..d16d2a4ade 100644
--- a/backend/tilavarauspalvelu/models/recurring_reservation/actions.py
+++ b/backend/tilavarauspalvelu/models/recurring_reservation/actions.py
@@ -5,9 +5,10 @@
from itertools import chain
from typing import TYPE_CHECKING, Any, TypedDict
-from tilavarauspalvelu.enums import RejectionReadinessChoice
+from tilavarauspalvelu.enums import AccessType, RejectionReadinessChoice
from tilavarauspalvelu.integrations.opening_hours.time_span_element import TimeSpanElement
from tilavarauspalvelu.models import AffectingTimeSpan, ApplicationSection, RejectedOccurrence, Reservation
+from tilavarauspalvelu.typing import PindoraValidityInfoData
from utils.date_utils import DEFAULT_TIMEZONE, combine, get_periods_between
if TYPE_CHECKING:
@@ -21,6 +22,7 @@
ReservationTypeChoice,
ReservationTypeStaffChoice,
)
+ from tilavarauspalvelu.integrations.keyless_entry.typing import PindoraAccessCodeValidity
from tilavarauspalvelu.models import (
AgeGroup,
City,
@@ -29,6 +31,7 @@
ReservationPurpose,
User,
)
+ from tilavarauspalvelu.typing import PindoraSeriesValidityInfoData
class ReservationPeriod(TypedDict):
@@ -257,30 +260,35 @@ def bulk_create_reservation_for_periods(
# Pick out the through model for the many-to-many relationship and use if for bulk creation
ThroughModel: type[models.Model] = Reservation.reservation_units.through # noqa: N806
+ reservation_unit = self.recurring_reservation.reservation_unit
+
reservations: list[Reservation] = []
through_models: list[models.Model] = []
for period in periods:
- if self.recurring_reservation.reservation_unit.reservation_block_whole_day:
+ if reservation_unit.reservation_block_whole_day:
reservation_details.setdefault(
"buffer_time_before",
- self.recurring_reservation.reservation_unit.actions.get_actual_before_buffer(period["begin"]),
+ reservation_unit.actions.get_actual_before_buffer(period["begin"]),
)
reservation_details.setdefault(
"buffer_time_after",
- self.recurring_reservation.reservation_unit.actions.get_actual_after_buffer(period["end"]),
+ reservation_unit.actions.get_actual_after_buffer(period["end"]),
)
+ access_type = reservation_unit.actions.get_access_type_at(period["begin"]) or AccessType.UNRESTRICTED
+
reservation = Reservation(
begin=period["begin"],
end=period["end"],
recurring_reservation=self.recurring_reservation,
age_group=self.recurring_reservation.age_group,
+ access_type=access_type,
**reservation_details,
)
through = ThroughModel(
reservation=reservation,
- reservationunit=self.recurring_reservation.reservation_unit,
+ reservationunit=reservation_unit,
)
reservations.append(reservation)
through_models.append(through)
@@ -338,3 +346,39 @@ def get_application_section(self) -> ApplicationSection | None:
return ApplicationSection.objects.filter(
reservation_unit_options__allocated_time_slots__recurring_reservation=self.recurring_reservation
).first()
+
+ def get_access_code_validity_info(self, info: list[PindoraAccessCodeValidity]) -> list[PindoraValidityInfoData]:
+ """
+ Given the list of access code validity info from Pindora (either for a reservation series
+ or an application section), construct a list of info objects for this reservation series with
+ the pre-calculated access code validity times as well as the reservation and series ids.
+ """
+ reservations_by_period: dict[tuple[datetime.datetime, datetime.datetime], int] = {}
+
+ for reservation in self.recurring_reservation.reservations.requires_active_access_code():
+ begin = reservation.begin.astimezone(DEFAULT_TIMEZONE)
+ end = reservation.end.astimezone(DEFAULT_TIMEZONE)
+ reservations_by_period[begin, end] = reservation.pk
+
+ access_code_validity: list[PindoraSeriesValidityInfoData] = []
+ for validity in info:
+ reservation_id = reservations_by_period.get((validity["begin"], validity["end"]))
+
+ # This will filter out other series' reservations in case info is from an application section
+ # (although it might filter out legitimate reservations in this series if dates don't match exactly).
+ if reservation_id is None:
+ continue
+
+ begins = validity["begin"] - datetime.timedelta(minutes=validity["access_code_valid_minutes_before"])
+ ends = validity["end"] + datetime.timedelta(minutes=validity["access_code_valid_minutes_after"])
+
+ access_code_validity.append(
+ PindoraValidityInfoData(
+ reservation_id=reservation_id,
+ reservation_series_id=self.recurring_reservation.pk,
+ access_code_begins_at=begins,
+ access_code_ends_at=ends,
+ )
+ )
+
+ return access_code_validity
diff --git a/backend/tilavarauspalvelu/models/recurring_reservation/model.py b/backend/tilavarauspalvelu/models/recurring_reservation/model.py
index 08b006283e..fbec919686 100644
--- a/backend/tilavarauspalvelu/models/recurring_reservation/model.py
+++ b/backend/tilavarauspalvelu/models/recurring_reservation/model.py
@@ -4,11 +4,17 @@
from functools import cached_property
from typing import TYPE_CHECKING
+from django.contrib.postgres.aggregates import ArrayAgg
+from django.contrib.postgres.fields.array import IndexTransform
from django.core.validators import validate_comma_separated_integer_list
from django.db import models
+from django.db.models import Exists
+from django.db.models.functions import Coalesce
from django.utils.translation import gettext_lazy as _
+from lookup_property import L, lookup_property
-from tilavarauspalvelu.enums import WeekdayChoice
+from tilavarauspalvelu.enums import AccessType, WeekdayChoice
+from utils.db import SubqueryArray
from .queryset import RecurringReservationManager
@@ -124,3 +130,69 @@ def validator(self) -> ReservationSeriesValidator:
from .validators import ReservationSeriesValidator
return ReservationSeriesValidator(self)
+
+ @lookup_property(skip_codegen=True)
+ def should_have_active_access_code() -> bool:
+ """Should at least one reservation in this series contain an active access code?"""
+ from tilavarauspalvelu.models import Reservation
+
+ exists = Exists(
+ queryset=Reservation.objects.filter(
+ L(access_code_should_be_active=True),
+ recurring_reservation=models.OuterRef("pk"),
+ ),
+ )
+ return exists # type: ignore[return-value] # noqa: RET504
+
+ @should_have_active_access_code.override
+ def _(self) -> bool:
+ return self.reservations.filter(L(access_code_should_be_active=True)).exists()
+
+ @lookup_property(skip_codegen=True)
+ def used_access_types() -> list[AccessType]:
+ """List of all unique access types used in the reservations of this recurring reservation."""
+ from tilavarauspalvelu.models import Reservation
+
+ sq = SubqueryArray(
+ Reservation.objects.filter(recurring_reservation=models.OuterRef("pk")).values("access_type"),
+ agg_field="access_type",
+ distinct=True,
+ coalesce_output_type="varchar",
+ output_field=models.CharField(),
+ )
+ return sq # type: ignore[return-value] # noqa: RET504
+
+ @used_access_types.override
+ def _(self) -> list[AccessType]:
+ qs = self.reservations.aggregate(used_access_types=Coalesce(ArrayAgg("access_type", distinct=True), []))
+ return [AccessType(access_type) for access_type in qs["used_access_types"]]
+
+ @lookup_property(joins=["reservations"], skip_codegen=True)
+ def access_type() -> AccessType:
+ """
+ If reservations in this reservation series have different access types,
+ return the 'MULTIVALUED' access type, otherwise return the common access type.
+ """
+ case = models.Case(
+ models.When(
+ L(used_access_types__len__gt=1),
+ then=models.Value(AccessType.MULTIVALUED.value),
+ ),
+ default=Coalesce(
+ # "used_access_types__1" doesn't work with lookup properties.
+ # Note: Postgres arrays are 1-indexed by default.
+ IndexTransform(1, models.CharField(), L("used_access_types")),
+ models.Value(AccessType.UNRESTRICTED.value), # If no reservations in series
+ ),
+ output_field=models.CharField(),
+ )
+ return case # type: ignore[return-value] # noqa: RET504
+
+ @access_type.override
+ def _(self) -> AccessType:
+ access_types: list[str] = self.used_access_types # type: ignore[attr-defined]
+ if len(access_types) == 0:
+ return AccessType.UNRESTRICTED
+ if len(access_types) == 1:
+ return AccessType(access_types[0])
+ return AccessType.MULTIVALUED
diff --git a/backend/tilavarauspalvelu/models/reservation/model.py b/backend/tilavarauspalvelu/models/reservation/model.py
index 54d386edd6..11239aacd4 100644
--- a/backend/tilavarauspalvelu/models/reservation/model.py
+++ b/backend/tilavarauspalvelu/models/reservation/model.py
@@ -77,7 +77,7 @@ class Reservation(SerializableMixin, models.Model):
# Access information
access_type: str = models.CharField(
max_length=20,
- choices=AccessType.choices,
+ choices=AccessType.model_choices,
default=AccessType.UNRESTRICTED.value,
)
access_code_generated_at: datetime.datetime | None = models.DateTimeField(null=True, blank=True)
diff --git a/backend/tilavarauspalvelu/models/reservation/queryset.py b/backend/tilavarauspalvelu/models/reservation/queryset.py
index 6af44f51fb..591d8da7aa 100644
--- a/backend/tilavarauspalvelu/models/reservation/queryset.py
+++ b/backend/tilavarauspalvelu/models/reservation/queryset.py
@@ -8,6 +8,7 @@
from django.db import models
from django.db.models.functions import Coalesce
from helsinki_gdpr.models import SerializableMixin
+from lookup_property import L
from tilavarauspalvelu.enums import OrderStatus, ReservationStateChoice, ReservationTypeChoice
from utils.date_utils import local_datetime
@@ -232,6 +233,9 @@ def filter_for_user_num_active_reservations(
type=ReservationTypeChoice.NORMAL.value,
)
+ def requires_active_access_code(self) -> Self:
+ return self.filter(L(access_code_should_be_active=True))
+
class ReservationManager(SerializableMixin.SerializableManager.from_queryset(ReservationQuerySet)):
"""Contains custom queryset methods and GDPR serialization."""
diff --git a/backend/tilavarauspalvelu/models/reservation_unit/actions.py b/backend/tilavarauspalvelu/models/reservation_unit/actions.py
index 6993c58b72..9002efd837 100644
--- a/backend/tilavarauspalvelu/models/reservation_unit/actions.py
+++ b/backend/tilavarauspalvelu/models/reservation_unit/actions.py
@@ -6,7 +6,7 @@
from django.db import models
from lookup_property import L
-from tilavarauspalvelu.enums import AccessType, ApplicationRoundStatusChoice, PaymentType, ReservationStartInterval
+from tilavarauspalvelu.enums import ApplicationRoundStatusChoice, PaymentType, ReservationStartInterval
from tilavarauspalvelu.exceptions import HaukiAPIError
from tilavarauspalvelu.integrations.opening_hours.hauki_api_client import HaukiAPIClient
from tilavarauspalvelu.integrations.opening_hours.hauki_api_types import HaukiTranslatedField
@@ -26,6 +26,7 @@
if TYPE_CHECKING:
from collections.abc import Collection
+ from tilavarauspalvelu.enums import AccessType
from tilavarauspalvelu.integrations.opening_hours.hauki_api_types import HaukiAPIResource
from tilavarauspalvelu.models import (
Location,
@@ -414,16 +415,9 @@ def get_accounting(self) -> PaymentAccounting | None:
return self.reservation_unit.unit.payment_accounting
return None
- def get_access_type_at(self, moment: datetime.datetime) -> AccessType:
- moment = moment.astimezone(DEFAULT_TIMEZONE)
-
- begin = self.reservation_unit.perceived_access_type_start_date
- end = self.reservation_unit.perceived_access_type_end_date
-
- if begin <= moment.date() <= end:
- return AccessType(self.reservation_unit.access_type)
-
- return AccessType.UNRESTRICTED
+ def get_access_type_at(self, moment: datetime.datetime) -> AccessType | None:
+ on_date = moment.astimezone(DEFAULT_TIMEZONE).date()
+ return self.reservation_unit.access_types.active(on_date=on_date).values_list("access_type", flat=True).first()
@property
def start_interval_minutes(self) -> int:
@@ -462,41 +456,37 @@ def is_reservable_at(self, moment: datetime.datetime) -> bool:
return moment < reservation_ends or reservation_begins <= moment
- def update_access_type_for_reservations(
- self,
- access_type: AccessType,
- access_type_start_date: datetime.date | None,
- access_type_end_date: datetime.date | None,
- ) -> None:
+ def update_access_types_for_reservations(self) -> None:
"""
- Update access type for reservations in the reservation unit based on
- how the given values differ from the reservation unit's current values.
-
- In case access type changes to or from 'ACCESS_CODE', background tasks
- will take care of removing or adding access codes to Pindora.
+ Update access types for future reservations in the reservation unit
+ based on currently defined access types.
"""
- # No changes
- if (
- access_type == self.reservation_unit.access_type
- and access_type_start_date == self.reservation_unit.access_type_start_date
- and access_type_end_date == self.reservation_unit.access_type_end_date
- ):
+ now = local_datetime()
+
+ access_types = list(
+ self.reservation_unit.access_types.filter(L(end_date__gt=now.date())).order_by("-begin_date")
+ )
+ # If there are no access types, we don't know that to update the reservation to
+ if not access_types:
return
- now = local_datetime()
- begin_date = access_type_start_date or datetime.date.min
- end_date = access_type_end_date or datetime.date.max
+ # Build a list or 'when' expressions that set the access type from its begin date starting from
+ # the one most in the future and moving backwards until the current active one is reached.
+ # This way reservations will get the correct access type given the currently defined ones.
+ whens: list[models.When] = [
+ models.When(
+ models.Q(begin__date__gte=access_type.begin_date),
+ then=models.Value(access_type.access_type),
+ )
+ for access_type in access_types
+ ]
- # Update all future or ongoing reservations in the reservation unit
+ # Update all future or ongoing reservations in the reservation unit to their current access types
Reservation.objects.filter(reservation_units=self.reservation_unit, end__gt=now).update(
access_type=models.Case(
- # All reservations that begin during the period should belong to the access type
- models.When(
- models.Q(begin__date__gte=begin_date, begin__date__lte=end_date),
- then=models.Value(access_type),
- ),
- # Others should have UNRESTRICTED access type by default
- default=models.Value(AccessType.UNRESTRICTED),
+ *whens,
+ # Use the active access type as the default (even though we should never reach this)
+ default=models.Value(access_types[-1].access_type),
output_field=models.CharField(),
)
)
diff --git a/backend/tilavarauspalvelu/models/reservation_unit/model.py b/backend/tilavarauspalvelu/models/reservation_unit/model.py
index 73106ad071..9e99e7e729 100644
--- a/backend/tilavarauspalvelu/models/reservation_unit/model.py
+++ b/backend/tilavarauspalvelu/models/reservation_unit/model.py
@@ -9,13 +9,12 @@
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.search import SearchVectorField
from django.db import models
-from django.db.models.functions import Coalesce, TruncDate
+from django.db.models import Subquery
from django.utils.translation import gettext_lazy as _
from lookup_property import L, lookup_property
from config.utils.auditlog_util import AuditLogger
from tilavarauspalvelu.enums import (
- AccessType,
AuthenticationType,
ReservationKind,
ReservationStartInterval,
@@ -29,6 +28,7 @@
if TYPE_CHECKING:
from decimal import Decimal
+ from tilavarauspalvelu.enums import AccessType
from tilavarauspalvelu.models import (
OriginHaukiResource,
PaymentAccounting,
@@ -91,8 +91,6 @@ class ReservationUnit(models.Model):
max_reservation_duration: datetime.timedelta | None = models.DurationField(null=True, blank=True)
buffer_time_before: datetime.timedelta = models.DurationField(default=datetime.timedelta(), blank=True)
buffer_time_after: datetime.timedelta = models.DurationField(default=datetime.timedelta(), blank=True)
- access_type_start_date: datetime.date | None = models.DateField(null=True, blank=True)
- access_type_end_date: datetime.date | None = models.DateField(null=True, blank=True)
# Booleans
@@ -122,11 +120,6 @@ class ReservationUnit(models.Model):
default=ReservationKind.DIRECT_AND_SEASON.value,
db_index=True,
)
- access_type: str = models.CharField(
- max_length=20,
- choices=AccessType.choices,
- default=AccessType.UNRESTRICTED.value,
- )
# List fields
@@ -286,17 +279,6 @@ class Meta:
verbose_name = _("reservation unit")
verbose_name_plural = _("reservation units")
ordering = ["rank", "id"]
- constraints = [
- models.CheckConstraint(
- check=(
- models.Q(access_type_start_date__isnull=True)
- | models.Q(access_type_end_date__isnull=True)
- | models.Q(access_type_start_date__lte=models.F("access_type_end_date"))
- ),
- name="access_type_starts_before_ends",
- violation_error_message=_("Access type start date must be the same or before its end date"),
- )
- ]
def __str__(self) -> str:
return f"{self.name}, {getattr(self.unit, 'name', '')}"
@@ -529,41 +511,24 @@ def reservation_state() -> ReservationUnitReservationState:
return case # type: ignore[return-value] # noqa: RET504
- @lookup_property
- def current_access_type() -> AccessType:
+ @lookup_property(skip_codegen=True)
+ def current_access_type() -> AccessType | None:
"""The access type that is currently active for the reservation unit."""
- case = models.Case(
- models.When(
- (
- L(perceived_access_type_start_date__lte=TruncDate(NowTT()))
- & L(perceived_access_type_end_date__gte=TruncDate(NowTT()))
- ),
- then=models.F("access_type"),
- ),
- default=models.Value(AccessType.UNRESTRICTED.value),
- output_field=models.CharField(),
- )
- return case # type: ignore[return-value] # noqa: RET504
+ from tilavarauspalvelu.models import ReservationUnitAccessType
- @lookup_property
- def perceived_access_type_start_date() -> datetime.date:
- """Helper lookup property for `current_access_type`"""
- expr = Coalesce(
- models.F("access_type_start_date"),
- models.Value(datetime.date.min),
- output_field=models.DateField(),
+ sq = Subquery(
+ queryset=(
+ ReservationUnitAccessType.objects.filter(reservation_unit=models.OuterRef("pk"))
+ .active()
+ .values("access_type")[:1]
+ ),
+ output_field=models.CharField(null=True),
)
- return expr # type: ignore[return-value] # noqa: RET504
+ return sq # type: ignore[return-value] # noqa: RET504
- @lookup_property
- def perceived_access_type_end_date() -> datetime.date:
- """Helper lookup property for `current_access_type`"""
- expr = Coalesce(
- models.F("access_type_end_date"),
- models.Value(datetime.date.max),
- output_field=models.DateField(),
- )
- return expr # type: ignore[return-value] # noqa: RET504
+ @current_access_type.override
+ def _(self) -> AccessType | None:
+ return self.access_types.active().values_list("access_type", flat=True).first()
AuditLogger.register(
@@ -574,7 +539,5 @@ def perceived_access_type_end_date() -> datetime.date:
"_reservation_state",
"_active_pricing_price",
"_current_access_type",
- "_perceived_access_type_start_date",
- "_perceived_access_type_end_date",
],
)
diff --git a/backend/tilavarauspalvelu/models/reservation_unit/queryset.py b/backend/tilavarauspalvelu/models/reservation_unit/queryset.py
index b0918a2352..254ffe8a39 100644
--- a/backend/tilavarauspalvelu/models/reservation_unit/queryset.py
+++ b/backend/tilavarauspalvelu/models/reservation_unit/queryset.py
@@ -9,10 +9,9 @@
from django.db.models import Q, prefetch_related_objects
from lookup_property import L
-from tilavarauspalvelu.enums import AccessType
from tilavarauspalvelu.services.first_reservable_time.first_reservable_time_helper import FirstReservableTimeHelper
from utils.date_utils import local_date
-from utils.db import ArrayUnnest, NowTT
+from utils.db import ArrayUnnest, NowTT, SubqueryArray
if TYPE_CHECKING:
from collections.abc import Callable, Generator
@@ -20,6 +19,7 @@
from query_optimizer.validators import PaginationArgs
+ from tilavarauspalvelu.enums import AccessType
from tilavarauspalvelu.models import ReservationUnit
@@ -112,35 +112,42 @@ def with_publishing_state_in(self, states: list[str]) -> Self:
def with_reservation_state_in(self, states: list[str]) -> Self:
return self.filter(L(reservation_state__in=states))
- def with_access_type_at(
+ def with_available_access_types_on_period(
self,
- allowed_access_types: list[AccessType],
- begin_date: datetime.date | None = None,
- end_date: datetime.date | None = None,
+ begin_date: datetime.date | None = None, # inclusive
+ end_date: datetime.date | None = None, # inclusive
) -> Self:
- """Filter to reservation units that have any of the allowed access types on the given date range."""
+ """Add annotation of all access types that are used on the given date range."""
+ from tilavarauspalvelu.models import ReservationUnitAccessType
+
period_start = models.Value(begin_date or local_date())
period_end = models.Value(end_date or datetime.date.max)
- # If unrestricted access is allowed, the reservation unit is included if:
- if AccessType.UNRESTRICTED in allowed_access_types:
- return self.filter(
- # 1) It's access type is "unrestricted", OR
- models.Q(access_type=AccessType.UNRESTRICTED)
- # 2) The given period extends outside the access type's period (since default is "unrestricted")
- | L(perceived_access_type_start_date__gt=period_start)
- | L(perceived_access_type_end_date__lt=period_end),
+ return self.annotate(
+ available_access_types=SubqueryArray(
+ queryset=(
+ ReservationUnitAccessType.objects.filter(reservation_unit=models.OuterRef("pk"))
+ .filter(L(end_date__gt=period_start) & models.Q(begin_date__lte=period_end))
+ .values("access_type")
+ ),
+ agg_field="access_type",
+ coalesce_output_type="varchar",
+ output_field=models.CharField(null=True),
)
-
- # Otherwise, the reservation unit is included if:
- return self.filter(
- # 1) It has one of the allowed access types, AND
- models.Q(access_type__in=allowed_access_types)
- # 2) The given period overlaps with the access type's period
- & L(perceived_access_type_start_date__lte=period_end)
- & L(perceived_access_type_end_date__gte=period_start)
)
+ def with_access_type_at(
+ self,
+ allowed_access_types: list[AccessType | str],
+ begin_date: datetime.date | None = None, # inclusive
+ end_date: datetime.date | None = None, # inclusive
+ ) -> Self:
+ """Filter to reservation units that have any of the allowed access types on the given date range."""
+ return self.with_available_access_types_on_period(
+ begin_date=begin_date,
+ end_date=end_date,
+ ).filter(available_access_types__overlap=allowed_access_types)
+
def published(self) -> Self:
return self.filter(is_draft=False, is_archived=False)
diff --git a/backend/tilavarauspalvelu/models/reservation_unit/validators.py b/backend/tilavarauspalvelu/models/reservation_unit/validators.py
index 870fda8324..b9889cc8be 100644
--- a/backend/tilavarauspalvelu/models/reservation_unit/validators.py
+++ b/backend/tilavarauspalvelu/models/reservation_unit/validators.py
@@ -218,3 +218,8 @@ def validate_can_create_reservation_type(self, reservation_type: ReservationType
if reservation_type not in ReservationTypeChoice.types_that_staff_can_create:
msg = "Staff users are not allowed to create reservations of this type."
raise ValidationError(msg, code=error_codes.RESERVATION_TYPE_NOT_ALLOWED)
+
+ def validate_has_access_type(self) -> None:
+ if not self.reservation_unit.access_types.exists():
+ msg = "Reservation unit does not have an access type defined."
+ raise ValidationError(msg, code=error_codes.RESERVATION_UNIT_HAS_NO_ACCESS_TYPE)
diff --git a/backend/tilavarauspalvelu/models/reservation_unit_access_type/__init__.py b/backend/tilavarauspalvelu/models/reservation_unit_access_type/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/backend/tilavarauspalvelu/models/reservation_unit_access_type/actions.py b/backend/tilavarauspalvelu/models/reservation_unit_access_type/actions.py
new file mode 100644
index 0000000000..8b6b364d93
--- /dev/null
+++ b/backend/tilavarauspalvelu/models/reservation_unit_access_type/actions.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+import dataclasses
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from .model import ReservationUnitAccessType
+
+
+__all__ = [
+ "ReservationUnitAccessTypeActions",
+]
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class ReservationUnitAccessTypeActions:
+ access_type: ReservationUnitAccessType
diff --git a/backend/tilavarauspalvelu/models/reservation_unit_access_type/model.py b/backend/tilavarauspalvelu/models/reservation_unit_access_type/model.py
new file mode 100644
index 0000000000..cbd279c88a
--- /dev/null
+++ b/backend/tilavarauspalvelu/models/reservation_unit_access_type/model.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import datetime
+from functools import cached_property
+from typing import TYPE_CHECKING
+
+from django.db import models
+from django.db.models.functions import Coalesce
+from django.utils.translation import gettext_lazy as _
+from lookup_property import lookup_property
+
+from tilavarauspalvelu.enums import AccessType
+from utils.utils import LazyValidator
+
+from .queryset import ReservationUnitAccessTypeManager
+
+if TYPE_CHECKING:
+ from tilavarauspalvelu.models import ReservationUnit
+
+ from .actions import ReservationUnitAccessTypeActions
+ from .validators import ReservationUnitAccessTypeValidator
+
+
+__all__ = [
+ "ReservationUnitAccessType",
+]
+
+
+class ReservationUnitAccessType(models.Model):
+ reservation_unit: ReservationUnit = models.ForeignKey(
+ "tilavarauspalvelu.ReservationUnit",
+ related_name="access_types",
+ on_delete=models.CASCADE,
+ )
+ access_type: AccessType = models.CharField(
+ max_length=255,
+ choices=AccessType.model_choices,
+ default=AccessType.UNRESTRICTED.value,
+ )
+ begin_date: datetime.date = models.DateField()
+
+ objects = ReservationUnitAccessTypeManager()
+
+ class Meta:
+ db_table = "reservation_unit_access_type"
+ base_manager_name = "objects"
+ verbose_name = _("reservation unit access type")
+ verbose_name_plural = _("reservation unit access types")
+ ordering = ["reservation_unit", "begin_date"]
+ constraints = [
+ models.UniqueConstraint(
+ fields=["reservation_unit", "begin_date"],
+ name="single_access_type_per_day_per_reservation_unit",
+ violation_error_message=_("Access type already exists for this reservation unit and date"),
+ )
+ ]
+
+ def __str__(self) -> str:
+ return f"{AccessType(self.access_type).label} for {self.reservation_unit} from {self.begin_date.isoformat()}"
+
+ @cached_property
+ def actions(self) -> ReservationUnitAccessTypeActions:
+ """Actions that can be executed on a ReservationUnitAccessType."""
+ # Import actions inline to defer loading them.
+ # This allows us to avoid circular imports.
+ from .actions import ReservationUnitAccessTypeActions
+
+ return ReservationUnitAccessTypeActions(self)
+
+ validator: ReservationUnitAccessTypeValidator = LazyValidator()
+
+ @lookup_property(skip_codegen=True)
+ def end_date() -> datetime.date:
+ """End date of the access type (exclusive)."""
+ sq = Coalesce(
+ models.Subquery(
+ queryset=(
+ ReservationUnitAccessType.objects.filter(begin_date__gt=models.OuterRef("begin_date"))
+ .order_by("begin_date")
+ .values("begin_date")[:1]
+ ),
+ output_field=models.DateField(),
+ ),
+ models.Value(datetime.date.max),
+ )
+ return sq # type: ignore[return-value] # noqa: RET504
+
+ @end_date.override
+ def _(self) -> datetime.date:
+ access_type = (
+ ReservationUnitAccessType.objects.filter(begin_date__gt=self.begin_date).order_by("begin_date").first()
+ )
+ if access_type is None:
+ return datetime.date.max
+ return access_type.begin_date
diff --git a/backend/tilavarauspalvelu/models/reservation_unit_access_type/queryset.py b/backend/tilavarauspalvelu/models/reservation_unit_access_type/queryset.py
new file mode 100644
index 0000000000..40150fc2b5
--- /dev/null
+++ b/backend/tilavarauspalvelu/models/reservation_unit_access_type/queryset.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Self
+
+from django.db import models
+from lookup_property import L
+
+from utils.date_utils import local_date
+
+if TYPE_CHECKING:
+ import datetime
+
+
+__all__ = [
+ "ReservationUnitAccessTypeManager",
+ "ReservationUnitAccessTypeQuerySet",
+]
+
+
+class ReservationUnitAccessTypeQuerySet(models.QuerySet):
+ def active(self, *, on_date: datetime.date | None = None) -> Self:
+ """Get only the active access types for each reservation unit."""
+ on_date = on_date or local_date()
+ return self.filter(models.Q(begin_date__lte=on_date) & L(end_date__gt=on_date))
+
+
+class ReservationUnitAccessTypeManager(models.Manager.from_queryset(ReservationUnitAccessTypeQuerySet)): ...
diff --git a/backend/tilavarauspalvelu/models/reservation_unit_access_type/validators.py b/backend/tilavarauspalvelu/models/reservation_unit_access_type/validators.py
new file mode 100644
index 0000000000..dbc37d04aa
--- /dev/null
+++ b/backend/tilavarauspalvelu/models/reservation_unit_access_type/validators.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+import dataclasses
+from typing import TYPE_CHECKING
+
+from rest_framework.exceptions import ValidationError
+
+from tilavarauspalvelu.api.graphql.extensions import error_codes
+from tilavarauspalvelu.enums import AccessType
+from utils.date_utils import local_date
+
+if TYPE_CHECKING:
+ import datetime
+
+ from .model import ReservationUnitAccessType
+
+
+__all__ = [
+ "ReservationUnitAccessTypeValidator",
+]
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class ReservationUnitAccessTypeValidator:
+ access_type: ReservationUnitAccessType
+
+ @classmethod
+ def validate_new_not_in_past(cls, begin_date: datetime.date) -> None:
+ if begin_date < local_date():
+ msg = "Access type cannot be created in the past."
+ raise ValidationError(msg, code=error_codes.ACCESS_TYPE_BEGIN_DATE_IN_PAST)
+
+ @classmethod
+ def validate_not_access_code(cls, access_type: AccessType | str) -> None:
+ if access_type == AccessType.ACCESS_CODE:
+ msg = "Cannot set access type to access code on reservation unit create."
+ raise ValidationError(msg, code=error_codes.ACCESS_TYPE_ACCESS_CODE_ON_CREATE)
+
+ def validate_not_past(self, begin_date: datetime.date) -> None:
+ if self.access_type.begin_date <= local_date() and self.access_type.begin_date != begin_date:
+ msg = "Past of active access type begin date cannot be changed."
+ raise ValidationError(msg, code=error_codes.ACCESS_TYPE_CANNOT_BE_MOVED)
+
+ def validate_not_moved_to_past(self, begin_date: datetime.date) -> None:
+ if begin_date < local_date() and self.access_type.begin_date != begin_date:
+ msg = "Access type cannot be moved to the past."
+ raise ValidationError(msg, code=error_codes.ACCESS_TYPE_BEGIN_DATE_IN_PAST)
+
+ def validate_deleted_not_active_or_past(self) -> None:
+ if self.access_type.begin_date <= local_date():
+ msg = "Cannot delete past or active access type."
+ raise ValidationError(msg, code=error_codes.ACCESS_TYPE_CANNOT_DELETE_PAST_OR_ACTIVE)
diff --git a/backend/tilavarauspalvelu/services/permission_resolver.py b/backend/tilavarauspalvelu/services/permission_resolver.py
index a31bbd7a0a..549af272da 100644
--- a/backend/tilavarauspalvelu/services/permission_resolver.py
+++ b/backend/tilavarauspalvelu/services/permission_resolver.py
@@ -434,12 +434,17 @@ def can_view_application(self, application: Application, *, reserver_needs_role:
role_choices=role_choices,
)
- def can_view_recurring_reservation(self, recurring_reservation: RecurringReservation) -> bool:
+ def can_view_recurring_reservation(
+ self,
+ recurring_reservation: RecurringReservation,
+ *,
+ reserver_needs_role: bool = False,
+ ) -> bool:
if self.is_user_anonymous_or_inactive():
return False
if self.user.is_superuser:
return True
- if self.user == recurring_reservation.user:
+ if self.user == recurring_reservation.user and (self.has_any_role() if reserver_needs_role else True):
return True
role_choices = UserRoleChoice.can_view_reservations()
diff --git a/backend/tilavarauspalvelu/typing.py b/backend/tilavarauspalvelu/typing.py
index dca2d7f59d..7f73f9cc61 100644
--- a/backend/tilavarauspalvelu/typing.py
+++ b/backend/tilavarauspalvelu/typing.py
@@ -2,7 +2,7 @@
from dataclasses import dataclass
from decimal import Decimal
-from typing import TYPE_CHECKING, Any, Literal, NotRequired, Protocol, TypedDict
+from typing import TYPE_CHECKING, Any, Literal, NamedTuple, NotRequired, Protocol, TypedDict
from django.contrib.auth.models import AnonymousUser
from django.core.handlers import wsgi
@@ -316,3 +316,50 @@ class StaffCreateReservationData(TypedDict):
class StaffReservationData(StaffCreateReservationData):
pk: int
+
+
+class PindoraReservationInfoData(NamedTuple):
+ access_code: str
+ access_code_generated_at: datetime.datetime
+ access_code_is_active: bool
+
+ access_code_keypad_url: str
+ access_code_phone_number: str
+ access_code_sms_number: str
+ access_code_sms_message: str
+
+ access_code_begins_at: datetime.datetime
+ access_code_ends_at: datetime.datetime
+
+
+class PindoraValidityInfoData(NamedTuple):
+ reservation_id: int
+ reservation_series_id: int
+ access_code_begins_at: datetime.datetime
+ access_code_ends_at: datetime.datetime
+
+
+class PindoraSeriesInfoData(NamedTuple):
+ access_code: str
+ access_code_generated_at: datetime.datetime
+ access_code_is_active: bool
+
+ access_code_keypad_url: str
+ access_code_phone_number: str
+ access_code_sms_number: str
+ access_code_sms_message: str
+
+ access_code_validity: list[PindoraValidityInfoData]
+
+
+class PindoraSectionInfoData(NamedTuple):
+ access_code: str
+ access_code_generated_at: datetime.datetime
+ access_code_is_active: bool
+
+ access_code_keypad_url: str
+ access_code_phone_number: str
+ access_code_sms_number: str
+ access_code_sms_message: str
+
+ access_code_validity: list[PindoraValidityInfoData]
diff --git a/backend/utils/db.py b/backend/utils/db.py
index b3e7da5225..d4fbf9fb45 100644
--- a/backend/utils/db.py
+++ b/backend/utils/db.py
@@ -39,12 +39,14 @@ def __init__(
self,
queryset: models.QuerySet,
*,
+ aggregate: str | None = None,
aggregate_field: str | None = None,
alias: str | None = None,
output_field: models.Field | None = None,
**kwargs: Any,
) -> None:
- self.template = self.template.format(function=self.aggregate)
+ aggregate = aggregate or self.aggregate
+ self.template = self.template.format(function=aggregate)
kwargs["aggregate_field"] = aggregate_field or self.aggregate_field
kwargs["alias"] = alias or self.default_alias
super().__init__(queryset, output_field, **kwargs)
diff --git a/backend/utils/utils.py b/backend/utils/utils.py
index 1f0f9889cd..f4ed335999 100644
--- a/backend/utils/utils.py
+++ b/backend/utils/utils.py
@@ -4,32 +4,46 @@
import datetime
import hashlib
import hmac
+import inspect
import json
import operator
import re
+import sys
import unicodedata
import urllib.parse
+from contextlib import contextmanager
+from functools import cached_property
from typing import TYPE_CHECKING, Any, Generic, TypeVar
from django.conf import settings
from django.core.cache import cache
+from django.core.exceptions import ValidationError as DjangoValidationError
+from django.utils.module_loading import import_string
from django.utils.translation import get_language_from_path, get_language_from_request
from html2text import HTML2Text # noqa: TID251
+from rest_framework.exceptions import ValidationError
+from rest_framework.fields import get_error_detail
from tilavarauspalvelu.enums import Language
from utils.date_utils import local_datetime
if TYPE_CHECKING:
from collections.abc import Generator, Iterable
+ from types import FrameType
from django.http import HttpRequest
from tilavarauspalvelu.typing import AnyUser, Lang, TextSearchLang
__all__ = [
+ "LazyValidator",
"comma_sep_str",
"get_text_search_language",
+ "only_django_validation_errors",
+ "only_drf_validation_errors",
"to_ascii",
+ "to_django_validation_error",
+ "to_drf_validation_error",
"update_query_params",
"with_indices",
]
@@ -269,3 +283,108 @@ def get_jwt_payload(json_web_token: str) -> dict[str, Any]:
payload_part += "=" * divmod(len(payload_part), 4)[1] # Add padding to the payload if needed
payload: str = base64.urlsafe_b64decode(payload_part).decode() # Decode the payload
return json.loads(payload) # Return the payload as a dict
+
+
+def to_django_validation_error(error: ValidationError) -> DjangoValidationError:
+ """Given a django-rest-framework ValidationError, return a Django ValidationError."""
+ return DjangoValidationError(message=str(error.detail[0]), code=error.detail[0].code)
+
+
+def to_drf_validation_error(error: DjangoValidationError) -> ValidationError:
+ """Given a Django ValidationError, return a django-rest-framework ValidationError."""
+ return ValidationError(get_error_detail(exc_info=error))
+
+
+@contextmanager
+def only_django_validation_errors() -> Generator[None]:
+ """Converts all raised errors in the context to Django ValidationErrors."""
+ try:
+ yield
+ except DjangoValidationError:
+ raise
+ except ValidationError as error:
+ raise to_django_validation_error(error) from error
+ except Exception as error:
+ raise DjangoValidationError(message=str(error)) from error
+
+
+@contextmanager
+def only_drf_validation_errors() -> Generator[None]:
+ """Converts all raised errors in the context to django-rest-framework ValidationErrors."""
+ try:
+ yield
+ except ValidationError:
+ raise
+ except DjangoValidationError as error:
+ raise to_drf_validation_error(error) from error
+ except Exception as error:
+ raise ValidationError(detail=str(error)) from error
+
+
+class LazyValidator:
+ """
+ Descriptor for accessing a Model's validator class lazily on the class or instance level.
+ Lazily evaluating the validator mitigates issues with cyclical imports that may happen
+ if validation requires importing other models for the validation logic.
+
+ Usage:
+
+ >>> from typing import TYPE_CHECKING
+ >>>
+ >>> if TYPE_CHECKING:
+ ... from .validators import MyModelValidator
+ >>>
+ >>> class MyModel(models.Model):
+ ... validator: MyModelValidator = LazyValidator()
+
+ Using the specific validator as a type hint is required and should be imported
+ in a `TYPE_CHECKING` block, just as written in the example above.
+
+ This descriptor works because of the specific structure in this project:
+ 1. Model needs to be in a module inside a package.
+ 2. Validator needs to be in a module named "validators" inside the same package.
+ 3. Validator takes a single argument, which is the model instance begin validated.
+
+ For validators for updating or deleting existing instances of the model, define (regular) methods
+ on the validator class, and use the validator through an instance of the Model class.
+
+ >>> MyModel().validator.validate_can_update()
+
+ For validators for creating new instances of the model, define classmethods on the validator class,
+ and use the validator through the Model class itself.
+
+ >>> MyModel.validator.validate_can_create()
+
+ Due to limitations of the Python typing system, the returned type of `.validator` in this case will be
+ an instance of the validator class, but the actual return value is the validator class itself.
+ Developers should only use the appropriate validation methods (class/regular) depending on
+ which type of validation they are performing (create/update/delete).
+
+ This approach is used instead of a more conventional decorator-descriptor approach because
+ some type checkers (PyCharm in particular) do not infer types from descriptor-decorators
+ correctly (at least when this was written).
+ """
+
+ def __init__(self) -> None:
+ # Perform some python black magic to find the package where this class is instantiated,
+ # as well as the name of the type annotation of the class attribute this is being assigned to.
+ # Note that the class attribute should be defined on a single line for this to work.
+ frame: FrameType = sys._getframe(1) # noqa: SLF001
+ source_code = inspect.findsource(frame)[0]
+ line = source_code[frame.f_lineno - 1]
+ definition = line.split("=", maxsplit=1)[0]
+
+ module: str = frame.f_locals["__module__"]
+ package: str = module.rsplit(".", maxsplit=1)[0]
+ class_name = definition.split(":", maxsplit=1)[1].strip()
+
+ self.path = f"{package}.validators.{class_name}"
+
+ def __get__(self, instance: Any | None, owner: type[Any]) -> Any:
+ if instance is None:
+ return self.get_class
+ return self.get_class(instance)
+
+ @cached_property
+ def get_class(self) -> type:
+ return import_string(self.path)