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)