+ )}
);
}
diff --git a/kompassi-v2-frontend/src/components/forms/SubmitButton.tsx b/kompassi-v2-frontend/src/components/forms/SubmitButton.tsx
index 294b6d608..422d41f07 100644
--- a/kompassi-v2-frontend/src/components/forms/SubmitButton.tsx
+++ b/kompassi-v2-frontend/src/components/forms/SubmitButton.tsx
@@ -39,6 +39,13 @@ export default function SubmitButton({
disabled={disabled || pending}
onClick={confirmationMessage ? onClick : undefined}
>
+ {pending && (
+
+ )}
{children}
);
diff --git a/kompassi-v2-frontend/src/components/tickets/TicketsAdminTabs.tsx b/kompassi-v2-frontend/src/components/tickets/TicketsAdminTabs.tsx
index e2c1958a3..45fc6dc16 100644
--- a/kompassi-v2-frontend/src/components/tickets/TicketsAdminTabs.tsx
+++ b/kompassi-v2-frontend/src/components/tickets/TicketsAdminTabs.tsx
@@ -10,6 +10,7 @@ export interface TicketsAdminTabsProps {
| "products"
| "quotas"
| "reports"
+ | "preferences"
| "ticketControl";
translations: Translations;
searchParams: Record;
@@ -49,6 +50,11 @@ export default function TicketsAdminTabs({
title: t.tabs.reports,
href: `/${eventSlug}/tickets-reports`,
},
+ {
+ slug: "preferences",
+ title: t.tabs.preferences,
+ href: `/${eventSlug}/tickets-preferences`,
+ },
{
slug: "webShop",
title: t.tabs.webShop,
diff --git a/kompassi-v2-frontend/src/services/tickets.ts b/kompassi-v2-frontend/src/services/tickets.ts
index 7f5db1e86..1a4b78063 100644
--- a/kompassi-v2-frontend/src/services/tickets.ts
+++ b/kompassi-v2-frontend/src/services/tickets.ts
@@ -166,6 +166,8 @@ export interface Order {
status: PaymentStatus;
createdAt: string;
totalPrice: string;
+ canRequestCancellation: boolean;
+ cancellationDeadline: string | null;
products: {
title: string;
price: string;
diff --git a/kompassi-v2-frontend/src/translations/en.tsx b/kompassi-v2-frontend/src/translations/en.tsx
index 96bd8a9cf..a90d70d9c 100644
--- a/kompassi-v2-frontend/src/translations/en.tsx
+++ b/kompassi-v2-frontend/src/translations/en.tsx
@@ -630,6 +630,79 @@ const translations = {
<>Email confirmation failed. Please try again later.>
),
},
+ cancelMessages: {
+ cancelled: (
+ <>
+ Your order has been cancelled. If the order was paid, the payment
+ will be refunded to the original payment method.
+ >
+ ),
+ failedToCancel: (
+ <>Failed to cancel the order. Please try again later.>
+ ),
+ cancellationRequested: (
+ <>
+ A cancellation link has been sent to the email address of the order.
+ Please check your inbox and open the link in the message to confirm
+ the cancellation.
+ >
+ ),
+ cancellationRequestFailed: (
+ <>
+ Failed to send the cancellation link. Please try again later or
+ contact ticket sales.
+ >
+ ),
+ cancellationFailed: (
+ <>
+ Failed to cancel the order. The cancellation link may have expired
+ or already been used. Please try again or contact ticket sales.
+ >
+ ),
+ },
+ cancelPage: {
+ title: "Cancel order",
+ explanation: (
+ <>
+
+ In order to cancel your order, the cancellation needs to be
+ confirmed using the email address the order was placed with.
+ Pressing the button below will send a cancellation link to the
+ email address of the order.
+
+
+ Your order will remain valid unless you open the link in the
+ message and confirm the cancellation there.
+
+ >
+ ),
+ deadline: (deadline: ReactNode) => (
+ <>The order can be cancelled until {deadline}.>
+ ),
+ notCancellable: "This order cannot be cancelled.",
+ actions: {
+ sendConfirmationEmail: "Send cancellation link",
+ returnToOrderPage: "Return to the order page",
+ },
+ confirm: {
+ title: "Confirm order cancellation",
+ warning: (
+ <>
+
You are about to cancel your order.
+
+ Any tickets contained in the order will be invalidated, and any
+ payments will be refunded to the original payment method.
+
+
+ This action cannot be undone.
+
+ >
+ ),
+ actions: {
+ cancelOrder: "Cancel order",
+ },
+ },
+ },
showingOrders: (numOrdersShown: number, numTotalOrders: number) => (
<>
Showing {numOrdersShown} order{numOrdersShown === 1 ? "" : "s"} (total{" "}
@@ -886,6 +959,21 @@ const translations = {
cancel: "Close without cancelling",
},
},
+ requestCancellation: {
+ title: "Cancel order",
+ contactTicketSales: (email: string | null) => (
+ <>
+ If you wish to cancel this order, please contact ticket sales
+ {email ? (
+ <>
+ : {email}
+ >
+ ) : (
+ "."
+ )}
+ >
+ ),
+ },
saveContactInformation: "Save contact information",
resendOrderConfirmation: {
title: "Resend order confirmation",
@@ -1170,9 +1258,30 @@ const translations = {
products: "Products",
quotas: "Quotas",
reports: "Reports",
+ preferences: "Settings",
ticketControl: "Ticket control",
webShop: "Web shop",
},
+ preferences: {
+ title: "Ticket shop settings",
+ attributes: {
+ contactEmail: {
+ title: "Contact email",
+ helpText:
+ "Format: Name . Replies to order confirmations and other emails sent to customers are directed to this address.",
+ },
+ termsAndConditionsUrl: {
+ en: "Terms and conditions URL (English)",
+ fi: "Terms and conditions URL (Finnish)",
+ sv: "Terms and conditions URL (Swedish)",
+ },
+ cancellationPeriodDays: {
+ title: "Cancellation period (days)",
+ helpText:
+ "Number of days from placing the order during which the customer can cancel a paid order themselves. The cancellation period ends when the event starts at the latest. Set to 0 to disable customer self-service cancellation.",
+ },
+ },
+ },
messages: {
orderCreated: (
<>
diff --git a/kompassi-v2-frontend/src/translations/fi.tsx b/kompassi-v2-frontend/src/translations/fi.tsx
index 9a2586bc9..9bd99a705 100644
--- a/kompassi-v2-frontend/src/translations/fi.tsx
+++ b/kompassi-v2-frontend/src/translations/fi.tsx
@@ -624,6 +624,79 @@ const translations: Translations = {
<>Sähköpostin vahvistaminen epäonnistui. Yritä myöhemmin uudelleen.>
),
},
+ cancelMessages: {
+ cancelled: (
+ <>
+ Tilauksesi on peruttu. Jos tilaus oli maksettu, maksu palautetaan
+ alkuperäiselle maksutavalle.
+ >
+ ),
+ failedToCancel: (
+ <>Tilauksen peruminen epäonnistui. Yritä myöhemmin uudelleen.>
+ ),
+ cancellationRequested: (
+ <>
+ Peruutuslinkki on lähetetty tilauksen sähköpostiosoitteeseen.
+ Tarkista sähköpostisi ja vahvista peruutus avaamalla viestissä oleva
+ linkki.
+ >
+ ),
+ cancellationRequestFailed: (
+ <>
+ Peruutuslinkin lähettäminen epäonnistui. Yritä myöhemmin uudelleen
+ tai ota yhteyttä lipunmyyntiin.
+ >
+ ),
+ cancellationFailed: (
+ <>
+ Tilauksen peruminen epäonnistui. Peruutuslinkki on saattanut
+ vanhentua tai se on jo käytetty. Yritä uudelleen tai ota yhteyttä
+ lipunmyyntiin.
+ >
+ ),
+ },
+ cancelPage: {
+ title: "Peruuta tilaus",
+ explanation: (
+ <>
+
+ Tilauksen peruminen täytyy vahvistaa sillä sähköpostiosoitteella,
+ jolla tilaus on tehty. Alla olevaa nappia painamalla tilauksen
+ sähköpostiosoitteeseen lähetetään peruutuslinkki.
+
+
+ Tilauksesi pysyy voimassa, ellet avaa viestissä olevaa linkkiä ja
+ vahvista peruutusta.
+
+ Tilaukseen sisältyvät liput mitätöidään, ja mahdolliset maksut
+ palautetaan alkuperäiselle maksutavalle.
+
+
+ Tilauksen peruutusta ei voi perua.
+
+ >
+ ),
+ actions: {
+ cancelOrder: "Peruuta tilaus",
+ },
+ },
+ },
showingOrders: (numOrdersShown: number, numTotalOrders: number) => (
<>
Näytetään {numOrdersShown} tilaus{numOrdersShown === 1 ? "" : "ta"}{" "}
@@ -882,6 +955,21 @@ const translations: Translations = {
cancel: "Sulje peruuttamatta",
},
},
+ requestCancellation: {
+ title: "Peruuta tilaus",
+ contactTicketSales: (email: string | null) => (
+ <>
+ Jos haluat peruuttaa tämän tilauksen, ota yhteyttä lipunmyyntiin
+ {email ? (
+ <>
+ : {email}
+ >
+ ) : (
+ "."
+ )}
+ >
+ ),
+ },
resendOrderConfirmation: {
title: "Lähetä uudelleen",
message: (emailAddress: string) => (
@@ -1165,9 +1253,30 @@ const translations: Translations = {
products: "Tuotteet",
quotas: "Kiintiöt",
reports: "Raportit",
+ preferences: "Asetukset",
ticketControl: "Lipuntarkastus",
webShop: "Lippukauppaan",
},
+ preferences: {
+ title: "Lippukaupan asetukset",
+ attributes: {
+ contactEmail: {
+ title: "Yhteysosoite",
+ helpText:
+ "Muoto: Nimi . Vastaukset tilausvahvistuksiin ja muihin asiakkaille lähetettyihin sähköposteihin ohjataan tähän osoitteeseen.",
+ },
+ termsAndConditionsUrl: {
+ en: "Toimitusehtojen osoite (englanniksi)",
+ fi: "Toimitusehtojen osoite (suomeksi)",
+ sv: "Toimitusehtojen osoite (ruotsiksi)",
+ },
+ cancellationPeriodDays: {
+ title: "Peruutusaika (päivää)",
+ helpText:
+ "Kuinka monta päivää tilauksen tekemisestä asiakas voi itse peruuttaa maksetun tilauksen. Peruutusaika päättyy viimeistään tapahtuman alkaessa. Aseta arvoksi 0 poistaaksesi omatoimisen peruutuksen käytöstä.",
+ },
+ },
+ },
messages: {
orderCreated: (
<>
diff --git a/kompassi-v2-frontend/src/translations/sv.tsx b/kompassi-v2-frontend/src/translations/sv.tsx
index 964b745a3..780ab599e 100644
--- a/kompassi-v2-frontend/src/translations/sv.tsx
+++ b/kompassi-v2-frontend/src/translations/sv.tsx
@@ -616,6 +616,81 @@ const translations: Translations = {
<>E-postbekräftelse misslyckades. Försök igen senare.>
),
},
+ cancelMessages: {
+ cancelled: (
+ <>
+ Din beställning har avbokats. Om beställningen var betald
+ återbetalas betalningen till det ursprungliga betalningssättet.
+ >
+ ),
+ failedToCancel: (
+ <>Avbokningen av beställningen misslyckades. Försök igen senare.>
+ ),
+ cancellationRequested: (
+ <>
+ En avbokningslänk har skickats till beställningens e-postadress.
+ Kontrollera din inkorg och bekräfta avbokningen genom att öppna
+ länken i meddelandet.
+ >
+ ),
+ cancellationRequestFailed: (
+ <>
+ Det gick inte att skicka avbokningslänken. Försök igen senare eller
+ kontakta biljettförsäljningen.
+ >
+ ),
+ cancellationFailed: (
+ <>
+ Avbokningen av beställningen misslyckades. Avbokningslänken kan ha
+ gått ut eller redan ha använts. Försök igen eller kontakta
+ biljettförsäljningen.
+ >
+ ),
+ },
+ cancelPage: {
+ title: "Avboka beställning",
+ explanation: (
+ <>
+
+ För att avboka beställningen måste avbokningen bekräftas med den
+ e-postadress som beställningen gjordes med. Genom att trycka på
+ knappen nedan skickas en avbokningslänk till beställningens
+ e-postadress.
+
+
+ Din beställning förblir giltig om du inte öppnar länken i
+ meddelandet och bekräftar avbokningen där.
+
+ >
+ ),
+ deadline: (deadline: ReactNode) => (
+ <>Beställningen kan avbokas fram till {deadline}.>
+ ),
+ notCancellable: "Denna beställning kan inte avbokas.",
+ actions: {
+ sendConfirmationEmail: "Skicka avbokningslänk",
+ returnToOrderPage: "Tillbaka till beställningssidan",
+ },
+ confirm: {
+ title: "Bekräfta avbokning av beställning",
+ warning: (
+ <>
+
Du håller på att avboka din beställning.
+
+ Alla biljetter som ingår i beställningen ogiltigförklaras, och
+ eventuella betalningar återbetalas till det ursprungliga
+ betalningssättet.
+
+
+ Denna åtgärd kan inte ångras.
+
+ >
+ ),
+ actions: {
+ cancelOrder: "Avboka beställning",
+ },
+ },
+ },
showingOrders: (numOrdersShown: number, numTotalOrders: number) => (
<>
Visar {numOrdersShown} beställning
@@ -863,6 +938,21 @@ const translations: Translations = {
cancel: "Stäng utan att avboka",
},
},
+ requestCancellation: {
+ title: "Avboka beställning",
+ contactTicketSales: (email: string | null) => (
+ <>
+ Om du vill avboka denna beställning, kontakta biljettförsäljningen
+ {email ? (
+ <>
+ : {email}
+ >
+ ) : (
+ "."
+ )}
+ >
+ ),
+ },
saveContactInformation: "Spara kontaktinformation",
resendOrderConfirmation: {
title: "Skicka om orderbekräftelse",
@@ -1149,9 +1239,30 @@ const translations: Translations = {
products: "Produkter",
quotas: "Kvoter",
reports: "Rapporter",
+ preferences: "Inställningar",
ticketControl: "Biljettkontroll",
webShop: "Webbshop",
},
+ preferences: {
+ title: "Biljettbutikens inställningar",
+ attributes: {
+ contactEmail: {
+ title: "Kontaktadress",
+ helpText:
+ "Format: Namn . Svar på orderbekräftelser och andra e-postmeddelanden som skickas till kunder styrs till denna adress.",
+ },
+ termsAndConditionsUrl: {
+ en: "Adress till leveransvillkoren (på engelska)",
+ fi: "Adress till leveransvillkoren (på finska)",
+ sv: "Adress till leveransvillkoren (på svenska)",
+ },
+ cancellationPeriodDays: {
+ title: "Avbokningstid (dagar)",
+ helpText:
+ "Antal dagar från beställningen under vilka kunden själv kan avboka en betald beställning. Avbokningstiden går ut senast när evenemanget börjar. Ange 0 för att inaktivera självbetjäningsavbokning.",
+ },
+ },
+ },
messages: {
orderCreated: (
<>
diff --git a/kompassi/graphql_api/schema.py b/kompassi/graphql_api/schema.py
index 327583426..0244db67c 100644
--- a/kompassi/graphql_api/schema.py
+++ b/kompassi/graphql_api/schema.py
@@ -58,6 +58,7 @@
from kompassi.program_v2.graphql.mutations.update_program_preferences import UpdateProgramPreferences
from kompassi.tickets_v2.graphql.mutations.cancel_and_refund_order import CancelAndRefundOrder
from kompassi.tickets_v2.graphql.mutations.cancel_own_unpaid_order import CancelOwnUnpaidOrder
+from kompassi.tickets_v2.graphql.mutations.confirm_order_cancellation import ConfirmOrderCancellation
from kompassi.tickets_v2.graphql.mutations.create_order import CreateOrder
from kompassi.tickets_v2.graphql.mutations.create_product import CreateProduct
from kompassi.tickets_v2.graphql.mutations.create_quota import CreateQuota
@@ -65,10 +66,12 @@
from kompassi.tickets_v2.graphql.mutations.delete_quota import DeleteQuota
from kompassi.tickets_v2.graphql.mutations.mark_order_as_paid import MarkOrderAsPaid
from kompassi.tickets_v2.graphql.mutations.reorder_products import ReorderProducts
+from kompassi.tickets_v2.graphql.mutations.request_order_cancellation import RequestOrderCancellation
from kompassi.tickets_v2.graphql.mutations.resend_order_confirmation import ResendOrderConfirmation
from kompassi.tickets_v2.graphql.mutations.update_order import UpdateOrder
from kompassi.tickets_v2.graphql.mutations.update_product import UpdateProduct
from kompassi.tickets_v2.graphql.mutations.update_quota import UpdateQuota
+from kompassi.tickets_v2.graphql.mutations.update_tickets_preferences import UpdateTicketsPreferences
from .language import DEFAULT_LANGUAGE, Language
@@ -213,5 +216,9 @@ class Mutation(graphene.ObjectType):
cancel_own_unpaid_order = CancelOwnUnpaidOrder.Field()
mark_order_as_paid = MarkOrderAsPaid.Field()
+ request_order_cancellation = RequestOrderCancellation.Field()
+ confirm_order_cancellation = ConfirmOrderCancellation.Field()
+ update_tickets_preferences = UpdateTicketsPreferences.Field()
+
schema = graphene.Schema(query=Query, mutation=Mutation)
diff --git a/kompassi/tickets_v2/event_log_entry_types.py b/kompassi/tickets_v2/event_log_entry_types.py
index 692039994..40ea20e72 100644
--- a/kompassi/tickets_v2/event_log_entry_types.py
+++ b/kompassi/tickets_v2/event_log_entry_types.py
@@ -20,6 +20,11 @@
message="Order {order_number} in {event} was cancelled by {actor_type} {actor}",
)
+registry.register(
+ name="tickets_v2.order.cancellation_requested",
+ message="Cancellation of order {order_number} in {event} was requested by the customer",
+)
+
registry.register(
name="tickets_v2.order.refunded.provider",
message="Order {order_number} in {event} was provider refunded by {actor_type} {actor}",
diff --git a/kompassi/tickets_v2/graphql/meta.py b/kompassi/tickets_v2/graphql/meta.py
index ff0a648d2..5e8cf8e4c 100644
--- a/kompassi/tickets_v2/graphql/meta.py
+++ b/kompassi/tickets_v2/graphql/meta.py
@@ -22,7 +22,14 @@
class TicketsV2EventMetaType(DjangoObjectType):
class Meta:
model = TicketsV2EventMeta
- fields = ("provider_id",)
+ fields = (
+ "provider_id",
+ "contact_email",
+ "terms_and_conditions_url_en",
+ "terms_and_conditions_url_fi",
+ "terms_and_conditions_url_sv",
+ "cancellation_period_days",
+ )
@graphql_query_cbac_required
@staticmethod
diff --git a/kompassi/tickets_v2/graphql/mutations/confirm_order_cancellation.py b/kompassi/tickets_v2/graphql/mutations/confirm_order_cancellation.py
new file mode 100644
index 000000000..9414a600a
--- /dev/null
+++ b/kompassi/tickets_v2/graphql/mutations/confirm_order_cancellation.py
@@ -0,0 +1,71 @@
+import graphene
+from django.db import transaction
+
+from kompassi.core.models.event import Event
+
+from ...models.enums import ActorType
+from ...models.order_cancellation_token import OrderCancellationToken
+from ...optimized_server.models.enums import RefundType
+
+
+class ConfirmOrderCancellationInput(graphene.InputObjectType):
+ event_slug = graphene.String(required=True)
+ order_id = graphene.String(required=True)
+ code = graphene.String(required=True)
+
+
+class ConfirmOrderCancellation(graphene.Mutation):
+ """
+ Customer self-service cancellation, step 2 of 2: consume the one-time code
+ from the confirmation email and cancel the order, initiating an automated
+ refund via the payment provider if money was paid.
+
+ May be called without authentication: the one-time code proves control of
+ the email address of the order.
+
+ If the provider refund request fails after the code is consumed, the order
+ is left in REFUND_FAILED state for ticket sales to resolve with the
+ existing admin refund tooling.
+
+ NOTE: Must not return any PII (the caller may be anonymous).
+ """
+
+ class Arguments:
+ input = ConfirmOrderCancellationInput(required=True)
+
+ success = graphene.NonNull(graphene.Boolean)
+
+ @staticmethod
+ def mutate(
+ _root,
+ info,
+ input: ConfirmOrderCancellationInput,
+ ):
+ event = Event.objects.get(slug=input.event_slug)
+
+ with transaction.atomic():
+ # select_for_update + state="valid" makes concurrent double submits safe:
+ # the second transaction blocks here and then fails to find a valid token.
+ token = OrderCancellationToken.objects.select_for_update().get(
+ event=event,
+ order_id=input.order_id,
+ code=input.code,
+ state="valid",
+ )
+
+ order = token.order
+ if not order.can_be_cancelled_by_customer():
+ raise ValueError("This order can no longer be cancelled.")
+
+ token.mark_used()
+
+ # NOTE: cancel_and_refund manages its own transactions and performs an
+ # external HTTP call to the payment provider, so it must stay outside
+ # the token transaction.
+ order.cancel_and_refund(
+ RefundType.PROVIDER if order.cached_price > 0 else RefundType.NONE,
+ actor_type=ActorType.CUSTOMER,
+ actor_user=None,
+ )
+
+ return ConfirmOrderCancellation(success=True) # type: ignore
diff --git a/kompassi/tickets_v2/graphql/mutations/request_order_cancellation.py b/kompassi/tickets_v2/graphql/mutations/request_order_cancellation.py
new file mode 100644
index 000000000..e25bac7a7
--- /dev/null
+++ b/kompassi/tickets_v2/graphql/mutations/request_order_cancellation.py
@@ -0,0 +1,64 @@
+import graphene
+from django.utils.timezone import now
+
+from kompassi.core.models.event import Event
+
+from ...models.enums import ActorType
+from ...models.order import Order
+from ...models.order_cancellation_token import OrderCancellationToken
+
+
+class RequestOrderCancellationInput(graphene.InputObjectType):
+ event_slug = graphene.String(required=True)
+ order_id = graphene.String(required=True)
+
+
+class RequestOrderCancellation(graphene.Mutation):
+ """
+ Customer self-service cancellation, step 1 of 2: send a confirmation link
+ to the email address of the order.
+
+ May be called without authentication: possession of the order UUID is considered
+ sufficient proof of being party to the order (same trust model as the anonymous
+ order page), and the confirmation email closes the loop.
+
+ NOTE: Must not return any PII (the caller may be anonymous).
+ """
+
+ class Arguments:
+ input = RequestOrderCancellationInput(required=True)
+
+ success = graphene.NonNull(graphene.Boolean)
+
+ @staticmethod
+ def mutate(
+ _root,
+ info,
+ input: RequestOrderCancellationInput,
+ ):
+ event = Event.objects.get(slug=input.event_slug)
+ order = Order.objects.get(event=event, id=input.order_id)
+
+ if not order.can_be_cancelled_by_customer():
+ raise ValueError("This order cannot be cancelled.")
+
+ # Only the most recently requested confirmation link is valid.
+ OrderCancellationToken.objects.filter(
+ event=event,
+ order_id=order.id,
+ state="valid",
+ ).update(state="revoked", used_at=now())
+
+ token = OrderCancellationToken.objects.create(
+ event=event,
+ order_id=order.id,
+ language=order.language,
+ )
+ token.send()
+
+ order.emit_event_log_entry(
+ "tickets_v2.order.cancellation_requested",
+ actor_type=ActorType.CUSTOMER,
+ )
+
+ return RequestOrderCancellation(success=True) # type: ignore
diff --git a/kompassi/tickets_v2/graphql/mutations/update_tickets_preferences.py b/kompassi/tickets_v2/graphql/mutations/update_tickets_preferences.py
new file mode 100644
index 000000000..1a3d352b7
--- /dev/null
+++ b/kompassi/tickets_v2/graphql/mutations/update_tickets_preferences.py
@@ -0,0 +1,72 @@
+import graphene
+from django.core.exceptions import ValidationError
+
+from kompassi.access.cbac import graphql_check_instance
+from kompassi.core.models.contact_email_mixin import contact_email_validator
+
+from ...models.meta import TicketsV2EventMeta
+from ..meta import TicketsV2EventMetaType
+
+
+class UpdateTicketsPreferencesInput(graphene.InputObjectType):
+ event_slug = graphene.String(required=True)
+ contact_email = graphene.String()
+ terms_and_conditions_url_en = graphene.String()
+ terms_and_conditions_url_fi = graphene.String()
+ terms_and_conditions_url_sv = graphene.String()
+ cancellation_period_days = graphene.Int()
+
+
+class UpdateTicketsPreferences(graphene.Mutation):
+ """
+ Updates the tickets settings that are exposed to event admins.
+ NOTE: provider_id is deliberately not settable here (super admin only).
+ """
+
+ class Arguments:
+ input = UpdateTicketsPreferencesInput(required=True)
+
+ preferences = graphene.Field(TicketsV2EventMetaType)
+
+ @staticmethod
+ def mutate(
+ _root,
+ info,
+ input: UpdateTicketsPreferencesInput,
+ ):
+ meta = TicketsV2EventMeta.objects.get(event__slug=input.event_slug)
+
+ graphql_check_instance(
+ meta,
+ info,
+ app="tickets_v2",
+ operation="update",
+ )
+
+ contact_email = (input.contact_email or "").strip() # type: ignore
+ if contact_email:
+ try:
+ contact_email_validator(contact_email)
+ except ValidationError as e:
+ raise ValueError("Invalid contact email (expected format: Name )") from e
+
+ cancellation_period_days: int = input.cancellation_period_days or 0 # type: ignore
+ if cancellation_period_days < 0:
+ raise ValueError("Cancellation period cannot be negative")
+
+ meta.contact_email = contact_email
+ meta.terms_and_conditions_url_en = input.terms_and_conditions_url_en or "" # type: ignore
+ meta.terms_and_conditions_url_fi = input.terms_and_conditions_url_fi or "" # type: ignore
+ meta.terms_and_conditions_url_sv = input.terms_and_conditions_url_sv or "" # type: ignore
+ meta.cancellation_period_days = cancellation_period_days
+ meta.save(
+ update_fields=[
+ "contact_email",
+ "terms_and_conditions_url_en",
+ "terms_and_conditions_url_fi",
+ "terms_and_conditions_url_sv",
+ "cancellation_period_days",
+ ]
+ )
+
+ return UpdateTicketsPreferences(preferences=meta) # type: ignore
diff --git a/kompassi/tickets_v2/graphql/order_profile.py b/kompassi/tickets_v2/graphql/order_profile.py
index 5e2f6996a..cfe889f26 100644
--- a/kompassi/tickets_v2/graphql/order_profile.py
+++ b/kompassi/tickets_v2/graphql/order_profile.py
@@ -70,6 +70,32 @@ def resolve_can_cancel(order: Order, info):
description=normalize_whitespace(resolve_can_cancel.__doc__ or ""),
)
+ @staticmethod
+ def resolve_can_request_cancellation(order: Order, info):
+ """
+ Returns true if the customer can cancel this order themselves
+ via the email confirmed cancellation flow.
+ """
+ return order.can_be_cancelled_by_customer()
+
+ can_request_cancellation = graphene.NonNull(
+ graphene.Boolean,
+ description=normalize_whitespace(resolve_can_request_cancellation.__doc__ or ""),
+ )
+
+ @staticmethod
+ def resolve_cancellation_deadline(order: Order, info):
+ """
+ The customer may cancel their order themselves until this deadline.
+ Null if customer self-service cancellation is not enabled for the event.
+ """
+ return order.cancellation_deadline
+
+ cancellation_deadline = graphene.Field(
+ graphene.DateTime,
+ description=normalize_whitespace(resolve_cancellation_deadline.__doc__ or ""),
+ )
+
@staticmethod
def resolve_tickets_contact_email(order: Order, info):
"""
diff --git a/kompassi/tickets_v2/migrations/0009_ticketsv2eventmeta_cancellation_period_days_and_more.py b/kompassi/tickets_v2/migrations/0009_ticketsv2eventmeta_cancellation_period_days_and_more.py
new file mode 100644
index 000000000..22ce216cb
--- /dev/null
+++ b/kompassi/tickets_v2/migrations/0009_ticketsv2eventmeta_cancellation_period_days_and_more.py
@@ -0,0 +1,53 @@
+# Generated by Django 6.0.6 on 2026-06-12 16:22
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("core", "0044_organization_business_id"),
+ ("tickets_v2", "0008_product_vat_percentage"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ticketsv2eventmeta",
+ name="cancellation_period_days",
+ field=models.PositiveSmallIntegerField(
+ default=0,
+ help_text="Number of days from order creation during which the customer can cancel a paid order themselves. The period is further capped at event start. 0 = customer self-service cancellation disabled.",
+ ),
+ ),
+ migrations.CreateModel(
+ name="OrderCancellationToken",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("order_id", models.UUIDField()),
+ ("code", models.CharField(max_length=63, unique=True)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("used_at", models.DateTimeField(blank=True, null=True)),
+ (
+ "state",
+ models.CharField(
+ choices=[("valid", "Valid"), ("used", "Used"), ("revoked", "Revoked")],
+ default="valid",
+ max_length=8,
+ ),
+ ),
+ (
+ "language",
+ models.CharField(
+ choices=[("en", "English"), ("fi", "Finnish"), ("sv", "Swedish")], default="en", max_length=2
+ ),
+ ),
+ (
+ "event",
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="+", to="core.event"),
+ ),
+ ],
+ options={
+ "indexes": [models.Index(fields=["event", "order_id", "state"], name="tickets_v2__event_i_7b245d_idx")],
+ },
+ ),
+ ]
diff --git a/kompassi/tickets_v2/models/__init__.py b/kompassi/tickets_v2/models/__init__.py
index 680d3b622..bfa4ce9f4 100644
--- a/kompassi/tickets_v2/models/__init__.py
+++ b/kompassi/tickets_v2/models/__init__.py
@@ -1,5 +1,6 @@
from .meta import TicketsV2EventMeta
from .order import Order
+from .order_cancellation_token import OrderCancellationToken
from .payment_stamp import PaymentStamp
from .product import Product
from .quota import Quota
diff --git a/kompassi/tickets_v2/models/enums.py b/kompassi/tickets_v2/models/enums.py
index f7664ab4b..8667ce89c 100644
--- a/kompassi/tickets_v2/models/enums.py
+++ b/kompassi/tickets_v2/models/enums.py
@@ -5,3 +5,4 @@ class ActorType(Enum):
ADMIN = "admin"
OWNER = "owner"
SYSTEM = "system"
+ CUSTOMER = "customer"
diff --git a/kompassi/tickets_v2/models/meta.py b/kompassi/tickets_v2/models/meta.py
index 90509a401..7ee7b7d2e 100644
--- a/kompassi/tickets_v2/models/meta.py
+++ b/kompassi/tickets_v2/models/meta.py
@@ -35,6 +35,15 @@ class TicketsV2EventMeta(ContactEmailMixin, EventMetaBase):
help_text="Foo Bar <foo.bar@example.com>",
)
+ cancellation_period_days = models.PositiveSmallIntegerField(
+ default=0,
+ help_text=(
+ "Number of days from order creation during which the customer can cancel "
+ "a paid order themselves. The period is further capped at event start. "
+ "0 = customer self-service cancellation disabled."
+ ),
+ )
+
use_cbac = True
def __str__(self):
diff --git a/kompassi/tickets_v2/models/order.py b/kompassi/tickets_v2/models/order.py
index fe5b9af61..85ff7cdce 100644
--- a/kompassi/tickets_v2/models/order.py
+++ b/kompassi/tickets_v2/models/order.py
@@ -14,6 +14,7 @@
from django.http import HttpRequest
from django.urls import reverse
from django.utils.timezone import get_current_timezone
+from django.utils.timezone import now as django_now
from lippukala.models.code import Code as LippukalaCode
from lippukala.models.order import Order as LippukalaOrder
@@ -330,6 +331,42 @@ def can_be_cancelled_by_owner(self, user: User):
self.status.is_owner_cancelable and self.owner is not None and user.is_authenticated and user == self.owner
)
+ @property
+ def cancellation_deadline(self) -> datetime | None:
+ """
+ Deadline for customer self-service cancellation, or None if disabled for the event.
+ The cancellation period starts at order creation and is capped at event start.
+ """
+ period_days = self.meta.cancellation_period_days
+ if not period_days:
+ return None
+
+ deadline = self.timestamp + timedelta(days=period_days)
+
+ if (start_time := self.event.start_time) is not None:
+ deadline = min(deadline, start_time)
+
+ return deadline
+
+ def can_be_cancelled_by_customer(self) -> bool:
+ """
+ Customer self-service cancellation (confirmed via email) is allowed for paid orders
+ within the cancellation period, provided that any money paid can be automatically
+ refunded via the payment provider. Customers holding orders that fail these criteria
+ are directed to contact ticket sales instead.
+ """
+ if self.status != PaymentStatus.PAID:
+ return False
+
+ deadline = self.cancellation_deadline
+ if deadline is None or django_now() >= deadline:
+ return False
+
+ if self.cached_price == 0:
+ return True
+
+ return self.payment_stamps.filter(status=PaymentStatus.PAID).exclude(provider_id=PaymentProvider.NONE).exists()
+
def can_be_provider_refunded_by(self, request: HttpRequest):
# TODO should this method do all the same checks that cancel_and_refund does?
return (
diff --git a/kompassi/tickets_v2/models/order_cancellation_token.py b/kompassi/tickets_v2/models/order_cancellation_token.py
new file mode 100644
index 000000000..b8e0dc92f
--- /dev/null
+++ b/kompassi/tickets_v2/models/order_cancellation_token.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+import logging
+from functools import cached_property
+from random import choice
+
+from django.conf import settings
+from django.db import models
+from django.template.loader import render_to_string
+from django.utils import timezone
+
+from kompassi.core.models.event import Event
+from kompassi.core.models.one_time_code import (
+ ONE_TIME_CODE_ALPHABET,
+ ONE_TIME_CODE_LENGTH,
+ ONE_TIME_CODE_STATE_CHOICES,
+)
+from kompassi.graphql_api.language import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGE_CODES, get_language_choices
+
+logger = logging.getLogger(__name__)
+
+CANCELLATION_REQUEST_SUBJECT = dict(
+ fi="Tilauksen peruutuspyyntö",
+ en="Order cancellation request",
+)
+
+
+class OrderCancellationToken(models.Model):
+ """
+ A one-time code sent to the email address of an order to confirm
+ customer self-service cancellation of the order.
+
+ NOTE: Order is partitioned by event, so we cannot have a foreign key to it.
+ Instead, we use the same (event, order_id) pattern as Receipt.
+ """
+
+ event = models.ForeignKey(
+ Event,
+ on_delete=models.CASCADE,
+ related_name="+",
+ )
+
+ order_id = models.UUIDField()
+
+ code = models.CharField(max_length=63, unique=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+ used_at = models.DateTimeField(null=True, blank=True)
+ state = models.CharField(
+ max_length=8,
+ default="valid",
+ choices=ONE_TIME_CODE_STATE_CHOICES,
+ )
+ language = models.CharField(
+ max_length=2,
+ default=DEFAULT_LANGUAGE,
+ choices=get_language_choices(),
+ )
+
+ class Meta:
+ indexes = [
+ models.Index(fields=["event", "order_id", "state"]),
+ ]
+
+ def __str__(self):
+ return self.code
+
+ def save(self, *args, **kwargs):
+ if not self.code:
+ self.code = "".join(choice(ONE_TIME_CODE_ALPHABET) for _ in range(ONE_TIME_CODE_LENGTH))
+
+ return super().save(*args, **kwargs)
+
+ @cached_property
+ def order(self):
+ """
+ Direct the query to the correct partition.
+ """
+ from .order import Order
+
+ return Order.objects.get(event=self.event, id=self.order_id)
+
+ @property
+ def confirmation_url(self) -> str:
+ return (
+ f"{settings.KOMPASSI_V2_BASE_URL}/{self.language}/{self.event.slug}"
+ f"/orders/{self.order_id}/cancel/{self.code}"
+ )
+
+ def mark_used(self):
+ if self.state != "valid":
+ raise ValueError("Must be valid to mark used")
+
+ self.used_at = timezone.now()
+ self.state = "used"
+ self.save(update_fields=["used_at", "state"])
+
+ def revoke(self):
+ if self.state != "valid":
+ raise ValueError("Must be valid to revoke")
+
+ self.used_at = timezone.now()
+ self.state = "revoked"
+ self.save(update_fields=["used_at", "state"])
+
+ @property
+ def message_language(self) -> str:
+ """
+ TODO Missing Swedish message template (see PendingReceipt.validate_language)
+ """
+ language = self.language.lower()
+ if language == "sv" or language not in SUPPORTED_LANGUAGE_CODES:
+ return DEFAULT_LANGUAGE
+
+ return language
+
+ def send(self):
+ from kompassi.core.tasks import send_email
+
+ order = self.order
+ meta = order.meta
+ event = self.event
+ language = self.message_language
+
+ deadline = order.cancellation_deadline
+ if deadline is not None:
+ deadline = deadline.astimezone(event.timezone)
+
+ body = render_to_string(
+ f"tickets_v2_cancellation_request_{language}.eml",
+ dict(
+ event_name=event.name,
+ order_number=order.order_number,
+ confirmation_url=self.confirmation_url,
+ deadline=deadline,
+ seller_name=event.organization.name,
+ seller_email=meta.plain_contact_email,
+ seller_business_id=event.organization.business_id,
+ ),
+ )
+
+ subject = f"{event.name}: {CANCELLATION_REQUEST_SUBJECT[language]} ({order.formatted_order_number})"
+ mail_domain = settings.DEFAULT_FROM_EMAIL.split("@", 1)[1].rstrip(">")
+ from_email = f"{event.name} ({settings.KOMPASSI_INSTALLATION_NAME}) <{event.slug}-tickets@{mail_domain}>"
+ reply_to = (contact_email,) if (contact_email := meta.contact_email) else ()
+ to = (f"{order.first_name} {order.last_name} <{order.email}>",)
+
+ send_email.delay( # type: ignore
+ subject=subject,
+ body=body,
+ from_email=from_email,
+ reply_to=reply_to,
+ to=to,
+ )
diff --git a/kompassi/tickets_v2/models/receipt.py b/kompassi/tickets_v2/models/receipt.py
index 32e0b21c7..a826ea608 100644
--- a/kompassi/tickets_v2/models/receipt.py
+++ b/kompassi/tickets_v2/models/receipt.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
+from datetime import datetime, timedelta
from decimal import Decimal
from functools import cached_property
from pathlib import Path
@@ -13,6 +14,7 @@
from django.core.mail import EmailMessage
from django.db import connection, models, transaction
from django.template.loader import render_to_string
+from django.utils.timezone import now as django_now
from lippukala.models import Code
from lippukala.models import Order as LippukalaOrder
from lippukala.printing import OrderPrinter
@@ -22,7 +24,7 @@
from kompassi.graphql_api.language import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGE_CODES
from kompassi.tickets_v2.lippukala_integration import Queue as LippukalaQueue
-from ..optimized_server.models.enums import PaymentStatus, ReceiptStatus, ReceiptType
+from ..optimized_server.models.enums import PaymentProvider, PaymentStatus, ReceiptStatus, ReceiptType
from ..utils.event_partitions import EventPartitionsMixin
from .meta import TicketsV2EventMeta
from .order import Order, OrderMixin
@@ -130,6 +132,10 @@ def timezone(self):
EVENT_CACHE: dict[int, Event] = {}
+# The cached Event also reaches TicketsV2EventMeta (eg. cancellation_period_days),
+# which the admin can change at any time, so the cache needs to expire on its own.
+EVENT_CACHE_TTL = timedelta(minutes=5)
+
class PendingReceipt(OrderMixin, pydantic.BaseModel, arbitrary_types_allowed=True, frozen=True):
"""
@@ -156,6 +162,8 @@ class PendingReceipt(OrderMixin, pydantic.BaseModel, arbitrary_types_allowed=Tru
query: ClassVar[str] = (Path(__file__).parent / "sql" / "claim_pending_receipts.sql").read_text()
batch_size: ClassVar[int] = 100
+ event_cache_refreshed_at: ClassVar[datetime | None] = None
+
@property
def order_date(self):
return uuid7_to_datetime(self.order_id)
@@ -184,9 +192,14 @@ def validate_product_data(value: str | dict[str, int]):
@classmethod
def _get_event(cls, event_id: int):
- if found := EVENT_CACHE.get(event_id):
+ now = django_now()
+ cache_is_fresh = (
+ cls.event_cache_refreshed_at is not None and now - cls.event_cache_refreshed_at < EVENT_CACHE_TTL
+ )
+ if cache_is_fresh and (found := EVENT_CACHE.get(event_id)):
return found
+ EVENT_CACHE.clear()
EVENT_CACHE.update(
{
event.id: event
@@ -196,11 +209,13 @@ def _get_event(cls, event_id: int):
"name",
"slug",
"timezone_name",
+ "start_time",
"organization__name",
"organization__business_id",
)
}
)
+ cls.event_cache_refreshed_at = now
return EVENT_CACHE[event_id]
@@ -215,6 +230,49 @@ def meta(self) -> TicketsV2EventMeta:
return self.event.tickets_v2_event_meta
+ @cached_property
+ def cancellation_deadline(self) -> datetime | None:
+ """
+ Keep in sync with Order.cancellation_deadline.
+ """
+ period_days = self.meta.cancellation_period_days
+ if not period_days:
+ return None
+
+ deadline = self.order_date + timedelta(days=period_days)
+
+ if (start_time := self.event.start_time) is not None:
+ deadline = min(deadline, start_time)
+
+ return deadline
+
+ @property
+ def is_customer_cancellable(self) -> bool:
+ """
+ Keep in sync with Order.can_be_cancelled_by_customer.
+ """
+ from .payment_stamp import PaymentStamp
+
+ if self.receipt_type != ReceiptType.PAID:
+ return False
+
+ deadline = self.cancellation_deadline
+ if deadline is None or django_now() >= deadline:
+ return False
+
+ if self.total_price == 0:
+ return True
+
+ return (
+ PaymentStamp.objects.filter(
+ event_id=self.event_id,
+ order_id=self.order_id,
+ status=PaymentStatus.PAID,
+ )
+ .exclude(provider_id=PaymentProvider.NONE)
+ .exists()
+ )
+
@classmethod
def claim_pending_receipts(cls, event_id: int, batch_size: int = batch_size) -> tuple[list[Self], bool]:
"""
@@ -315,7 +373,12 @@ def body(self) -> str:
raise ValueError("Unknown receipt type")
organization = self.event.organization
+ cancellation_deadline = self.cancellation_deadline if self.is_customer_cancellable else None
vars = dict(
+ cancellation_deadline=(
+ cancellation_deadline.astimezone(self.event.timezone) if cancellation_deadline else None
+ ),
+ cancellation_url=f"{KOMPASSI_V2_BASE_URL}/{self.language}/{self.event.slug}/orders/{self.order_id}/cancel",
event_name=self.event.name,
order_number=self.order_number,
order_date=self.order_date,
diff --git a/kompassi/tickets_v2/optimized_server/app.py b/kompassi/tickets_v2/optimized_server/app.py
index 5d3b5964a..068b029d7 100644
--- a/kompassi/tickets_v2/optimized_server/app.py
+++ b/kompassi/tickets_v2/optimized_server/app.py
@@ -144,6 +144,8 @@ async def get_order(
order: _Order,
_api_key_verified: _ApiKeyVerified,
):
+ order.populate_cancellation(event)
+
return {
"event": {
"name": event.name,
diff --git a/kompassi/tickets_v2/optimized_server/models/event.py b/kompassi/tickets_v2/optimized_server/models/event.py
index ab532a8b6..255448ac1 100644
--- a/kompassi/tickets_v2/optimized_server/models/event.py
+++ b/kompassi/tickets_v2/optimized_server/models/event.py
@@ -2,6 +2,7 @@
import re
from asyncio import Future, ensure_future
+from datetime import UTC, datetime, timedelta
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
@@ -38,14 +39,25 @@ class Event(pydantic.BaseModel):
contact_email: str
organization_business_id: str
+ cancellation_period_days: int
+ start_time: datetime | None
+
cache: ClassVar[dict[str | int, Event]] = {}
cache_refresh: ClassVar[Future[dict[str | int, Event]] | None] = None
+ cache_refreshed_at: ClassVar[datetime | None] = None
+
+ # The admin can change settings that affect eg. customer cancellation eligibility
+ # at any time, so the cache needs to expire on its own.
+ cache_ttl: ClassVar[timedelta] = timedelta(minutes=5)
query: ClassVar[bytes] = (Path(__file__).parent / "sql" / "get_events.sql").read_bytes()
@classmethod
async def get(cls, db: AsyncConnection, slug: str) -> Event | None:
- if cls.cache is None or slug not in cls.cache:
+ cache_is_fresh = (
+ cls.cache_refreshed_at is not None and datetime.now(UTC) - cls.cache_refreshed_at < cls.cache_ttl
+ )
+ if slug not in cls.cache or not cache_is_fresh:
cls.cache = await cls._refresh_cache(db)
return cls.cache.get(slug)
@@ -76,6 +88,8 @@ async def _do_refresh_cache(cls, db: AsyncConnection):
event = cls(**dict(zip(cls.model_fields, row, strict=True))) # type: ignore
cls.cache[event.slug] = cls.cache[event.id] = event
+ cls.cache_refreshed_at = datetime.now(UTC)
+
return cls.cache
def model_dump(self, *args, **kwargs) -> Any:
diff --git a/kompassi/tickets_v2/optimized_server/models/order.py b/kompassi/tickets_v2/optimized_server/models/order.py
index 06c518668..9100fe746 100644
--- a/kompassi/tickets_v2/optimized_server/models/order.py
+++ b/kompassi/tickets_v2/optimized_server/models/order.py
@@ -2,7 +2,7 @@
import json
from collections import defaultdict
-from datetime import datetime
+from datetime import UTC, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Annotated, Any, ClassVar, Self
@@ -20,6 +20,7 @@
from ..utils.formatting import format_order_number, order_number_to_reference
from .customer import Customer
from .enums import PaymentStatus
+from .event import Event
from .ticket import reserve_tickets
@@ -167,6 +168,19 @@ class Order(pydantic.BaseModel, populate_by_name=True):
language: str
products: list[OrderProduct]
+ can_request_cancellation: bool = pydantic.Field(
+ default=False,
+ serialization_alias="canRequestCancellation",
+ validation_alias="canRequestCancellation",
+ )
+ cancellation_deadline: datetime | None = pydantic.Field(
+ default=None,
+ serialization_alias="cancellationDeadline",
+ validation_alias="cancellationDeadline",
+ )
+
+ paid_by_provider: bool = pydantic.Field(default=False, exclude=True)
+
query: ClassVar[bytes] = (Path(__file__).parent / "sql" / "get_order.sql").read_bytes()
@pydantic.computed_field(alias="formattedOrderNumber")
@@ -200,13 +214,25 @@ async def get(cls, db: AsyncConnection, event_id: int, order_id: UUID) -> Order
total_price = Decimal(0)
status = PaymentStatus.NOT_STARTED
order_number = 0
- language_ = ""
+ language = ""
+ paid_by_provider = False
- async for total_, order_number_, language_, title, price, quantity, vat_percentage, status_ in cursor:
+ async for (
+ total_,
+ order_number_,
+ language_,
+ title,
+ price,
+ quantity,
+ vat_percentage,
+ status_,
+ paid_by_provider_,
+ ) in cursor:
order_products.append(
OrderProduct(title=title, price=price, quantity=quantity, vat_percentage=vat_percentage)
)
total_price, order_number, language, status = total_, order_number_, language_, status_
+ paid_by_provider = paid_by_provider_
if not order_products:
return None
@@ -218,6 +244,7 @@ async def get(cls, db: AsyncConnection, event_id: int, order_id: UUID) -> Order
status=status,
order_number=order_number,
products=order_products,
+ paid_by_provider=paid_by_provider,
)
@property
@@ -227,6 +254,26 @@ def reference(self):
def get_url(self, event_slug: str):
return f"{KOMPASSI_V2_BASE_URL}/{event_slug}/orders/{self.id}"
+ def populate_cancellation(self, event: Event) -> None:
+ """
+ Keep in sync with the Django side:
+ kompassi.tickets_v2.models.order.Order.cancellation_deadline
+ and can_be_cancelled_by_customer.
+ """
+ if not event.cancellation_period_days:
+ return
+
+ deadline = self.created_at + timedelta(days=event.cancellation_period_days)
+ if event.start_time is not None:
+ deadline = min(deadline, event.start_time)
+
+ self.cancellation_deadline = deadline
+ self.can_request_cancellation = (
+ self.status == PaymentStatus.PAID
+ and datetime.now(UTC) < deadline
+ and (self.total_price == 0 or self.paid_by_provider)
+ )
+
class OrderWithCustomer(Order):
customer: Customer
diff --git a/kompassi/tickets_v2/optimized_server/models/sql/get_events.sql b/kompassi/tickets_v2/optimized_server/models/sql/get_events.sql
index 8bd3000cd..add1261a7 100644
--- a/kompassi/tickets_v2/optimized_server/models/sql/get_events.sql
+++ b/kompassi/tickets_v2/optimized_server/models/sql/get_events.sql
@@ -10,7 +10,9 @@ select
coalesce(p.checkout_password, '') as paytrail_password,
o.name as organization_name,
coalesce(m.contact_email, '') as contact_email,
- o.business_id as organization_business_id
+ o.business_id as organization_business_id,
+ m.cancellation_period_days,
+ e.start_time
from
core_event e
join tickets_v2_ticketsv2eventmeta m on (e.id = m.event_id)
diff --git a/kompassi/tickets_v2/optimized_server/models/sql/get_order.sql b/kompassi/tickets_v2/optimized_server/models/sql/get_order.sql
index 8f79bc183..eac6cb431 100644
--- a/kompassi/tickets_v2/optimized_server/models/sql/get_order.sql
+++ b/kompassi/tickets_v2/optimized_server/models/sql/get_order.sql
@@ -6,7 +6,16 @@ select
p2.price,
p2.quantity,
p2.vat_percentage,
- o.cached_status as status
+ o.cached_status as status,
+ exists (
+ select 1
+ from tickets_v2_paymentstamp ps
+ where
+ ps.event_id = o.event_id
+ and ps.order_id = o.id
+ and ps.status = 3 -- PaymentStatus.PAID
+ and ps.provider_id <> 0 -- PaymentProvider.NONE
+ ) as paid_by_provider
from
tickets_v2_order o
join lateral (
diff --git a/kompassi/tickets_v2/templates/tickets_v2_cancellation_request_en.eml b/kompassi/tickets_v2/templates/tickets_v2_cancellation_request_en.eml
new file mode 100644
index 000000000..0a1512c02
--- /dev/null
+++ b/kompassi/tickets_v2/templates/tickets_v2_cancellation_request_en.eml
@@ -0,0 +1,19 @@
+{% load tickets_v2_tags %}Dear recipient,
+
+Cancellation of order {{ order_number|format_order_number }} in the {{ event_name }}
+online shop was requested.
+
+To confirm the cancellation, open the following link:
+
+{{ confirmation_url }}
+
+Cancelling the order cannot be undone. Any tickets contained in the order will
+be invalidated, and any payments will be refunded to the original payment
+method.{% if deadline %}
+
+The order can be cancelled until {{ deadline|date:"Y-m-d H:i" }}.{% endif %}
+
+If you did not request the cancellation, you can safely ignore this message,
+and your order will remain valid.
+
+{% include "tickets_v2_footer_en.eml" %}
diff --git a/kompassi/tickets_v2/templates/tickets_v2_cancellation_request_fi.eml b/kompassi/tickets_v2/templates/tickets_v2_cancellation_request_fi.eml
new file mode 100644
index 000000000..bd05b16d7
--- /dev/null
+++ b/kompassi/tickets_v2/templates/tickets_v2_cancellation_request_fi.eml
@@ -0,0 +1,18 @@
+{% load tickets_v2_tags %}Hyvä vastaanottaja,
+
+{{ event_name }} -verkkokaupassa tehdylle tilaukselle numero
+{{ order_number|format_order_number }} on pyydetty peruutusta.
+
+Vahvista peruutus avaamalla seuraava linkki:
+
+{{ confirmation_url }}
+
+Tilauksen peruutusta ei voi perua. Tilaukseen sisältyvät liput mitätöidään,
+ja mahdolliset maksut palautetaan alkuperäiselle maksutavalle.{% if deadline %}
+
+Tilauksen voi peruuttaa {{ deadline|date:"d.m.Y" }} klo {{ deadline|date:"H:i" }} saakka.{% endif %}
+
+Jos et pyytänyt peruutusta, voit jättää tämän viestin huomiotta,
+jolloin tilauksesi pysyy voimassa.
+
+{% include "tickets_v2_footer_fi.eml" %}
diff --git a/kompassi/tickets_v2/templates/tickets_v2_receipt_en.eml b/kompassi/tickets_v2/templates/tickets_v2_receipt_en.eml
index 88e28aec5..0dd7da883 100644
--- a/kompassi/tickets_v2/templates/tickets_v2_receipt_en.eml
+++ b/kompassi/tickets_v2/templates/tickets_v2_receipt_en.eml
@@ -26,6 +26,11 @@ The contact information you supplied:
{{ email }}
If there are any errors in the details above, please contact us without delay
-by replying to this message.
+by replying to this message.{% if cancellation_deadline %}
+
+If you wish to cancel your order, you may do so until
+{{ cancellation_deadline|date:"Y-m-d H:i" }} at the following address:
+
+{{ cancellation_url }}{% endif %}
{% include "tickets_v2_footer_en.eml" %}
diff --git a/kompassi/tickets_v2/templates/tickets_v2_receipt_fi.eml b/kompassi/tickets_v2/templates/tickets_v2_receipt_fi.eml
index c2d47e151..b4e25f91e 100644
--- a/kompassi/tickets_v2/templates/tickets_v2_receipt_fi.eml
+++ b/kompassi/tickets_v2/templates/tickets_v2_receipt_fi.eml
@@ -27,6 +27,11 @@ Antamasi yhteystiedot:
{{ email }}
Jos yllä olevissa tiedoissa on virheitä, otathan välittömästi yhteyttä
-lipunmyyntiin vastaamalla tähän viestiin.
+lipunmyyntiin vastaamalla tähän viestiin.{% if cancellation_deadline %}
+
+Mikäli haluat peruuttaa tilauksesi, voit tehdä sen
+{{ cancellation_deadline|date:"d.m.Y" }} klo {{ cancellation_deadline|date:"H:i" }} saakka osoitteessa:
+
+{{ cancellation_url }}{% endif %}
{% include "tickets_v2_footer_fi.eml" %}
diff --git a/kompassi/tickets_v2/tests.py b/kompassi/tickets_v2/tests.py
index 6dbfeb913..c63632ac6 100644
--- a/kompassi/tickets_v2/tests.py
+++ b/kompassi/tickets_v2/tests.py
@@ -1,20 +1,32 @@
import json
-from datetime import UTC, datetime
+from datetime import UTC, datetime, timedelta
from decimal import Decimal
from functools import cached_property
+from types import SimpleNamespace
from typing import Any, Literal
from uuid import UUID, uuid4
import pydantic
import pytest
import requests
+from django.core import mail
from kompassi.core.models.event import Event
+from kompassi.tickets_v2.graphql.mutations.confirm_order_cancellation import ConfirmOrderCancellation
+from kompassi.tickets_v2.graphql.mutations.request_order_cancellation import RequestOrderCancellation
+from kompassi.tickets_v2.models.meta import TicketsV2EventMeta
from kompassi.tickets_v2.models.order import Order
+from kompassi.tickets_v2.models.order_cancellation_token import OrderCancellationToken
+from kompassi.tickets_v2.models.payment_stamp import PaymentStamp
from kompassi.tickets_v2.models.product import Product
+from kompassi.tickets_v2.models.receipt import EVENT_CACHE, PendingReceipt
from kompassi.tickets_v2.optimized_server.models.api import CreateOrderResponse, GetOrderResponse, GetProductsResponse
from kompassi.tickets_v2.optimized_server.models.customer import Customer
-from kompassi.tickets_v2.optimized_server.models.enums import PaymentStatus
+from kompassi.tickets_v2.optimized_server.models.enums import (
+ PaymentProvider,
+ PaymentStampType,
+ PaymentStatus,
+)
from kompassi.tickets_v2.optimized_server.models.order import CreateOrderRequest, OrderProduct, VatBreakdownLine
from kompassi.tickets_v2.optimized_server.providers.paytrail import PaymentCallback, PaytrailStatus
from kompassi.tickets_v2.optimized_server.utils.formatting import format_vat_rate
@@ -241,6 +253,11 @@ def test_make_order(tickets_v2_client: TicketsV2Client):
get_order_response = tickets_v2_client.get_order(order_response.order_id)
assert get_order_response.order.status == PaymentStatus.PAID
+ # the dev environment event has cancellation_period_days = 0 (the default),
+ # so customer self-service cancellation is not offered
+ assert get_order_response.order.can_request_cancellation is False
+ assert get_order_response.order.cancellation_deadline is None
+
# ---------------------------------------------------------------------------
# VAT formatting / breakdown unit tests
@@ -324,6 +341,7 @@ def _make_order(
when: datetime,
product_data: dict[int, int],
status: PaymentStatus = PaymentStatus.PAID,
+ total_price: Decimal = Decimal(0),
) -> UUID:
"""
Insert a tickets_v2_order row directly via SQL.
@@ -349,7 +367,7 @@ def _make_order(
str(order_id),
event.id,
status.value,
- "0",
+ str(total_price),
"en",
json.dumps({str(pid): qty for pid, qty in product_data.items()}),
"Test",
@@ -516,3 +534,279 @@ def test_vat_by_month_report_localized_titles(vat_report_event: Event):
vat_col_fi = next(c for c in fi_report.columns if c.slug == "vat_25.50")
assert vat_col_en.title["en"] == "25.5%"
assert vat_col_fi.title["fi"] == "25,5%"
+
+
+# ---------------------------------------------------------------------------
+# Customer self-service cancellation tests
+# ---------------------------------------------------------------------------
+
+
+def _add_provider_paid_stamp(event: Event, order_id: UUID):
+ """
+ Simulate a successful payment via a payment provider. The payment stamp
+ trigger updates the cached status of the order to PAID.
+ """
+ PaymentStamp(
+ event=event,
+ order_id=order_id,
+ correlation_id=uuid7(),
+ provider_id=PaymentProvider.PAYTRAIL,
+ type=PaymentStampType.PAYMENT_CALLBACK,
+ status=PaymentStatus.PAID,
+ data={},
+ ).save()
+
+
+def _request_input(event: Event, order_id: UUID):
+ return SimpleNamespace(event_slug=event.slug, order_id=str(order_id))
+
+
+def _confirm_input(event: Event, order_id: UUID, code: str):
+ return SimpleNamespace(event_slug=event.slug, order_id=str(order_id), code=code)
+
+
+@pytest.fixture
+def cancellation_event(db):
+ from kompassi.event_log_v2.models.entry import Entry
+
+ event, _ = Event.get_or_create_dummy(name="Cancellation test event")
+ (admin_group,) = TicketsV2EventMeta.get_or_create_groups(event, ["admins"])
+ meta, _ = TicketsV2EventMeta.objects.update_or_create(
+ event=event,
+ defaults=dict(
+ admin_group=admin_group,
+ contact_email="Test Ticket Sales ",
+ cancellation_period_days=14,
+ ),
+ )
+ meta.ensure_partitions()
+ Entry.ensure_partitions()
+ return event
+
+
+def _get_order(event: Event, order_id: UUID) -> Order:
+ return Order.objects.get(event=event, id=order_id)
+
+
+@pytest.mark.django_db
+def test_can_be_cancelled_by_customer_zero_price(cancellation_event: Event):
+ event = cancellation_event
+ now = datetime.now(UTC)
+
+ order_id = _make_order(event, now, {}, status=PaymentStatus.PAID)
+ order = _get_order(event, order_id)
+
+ # event starts in 60 days, so the deadline comes from the cancellation period
+ assert order.cancellation_deadline == order.timestamp + timedelta(days=14)
+ assert order.can_be_cancelled_by_customer()
+
+
+@pytest.mark.django_db
+def test_can_be_cancelled_by_customer_provider_paid(cancellation_event: Event):
+ event = cancellation_event
+ now = datetime.now(UTC)
+
+ order_id = _make_order(event, now, {}, status=PaymentStatus.PENDING, total_price=Decimal("10.00"))
+ _add_provider_paid_stamp(event, order_id)
+
+ order = _get_order(event, order_id)
+ assert order.status == PaymentStatus.PAID
+ assert order.can_be_cancelled_by_customer()
+
+
+@pytest.mark.django_db
+def test_can_be_cancelled_by_customer_manually_paid(cancellation_event: Event):
+ """
+ A paid order with money paid outside a payment provider (eg. marked as paid
+ by an admin) cannot be self-service cancelled because the money cannot be
+ automatically refunded.
+ """
+ event = cancellation_event
+ now = datetime.now(UTC)
+
+ order_id = _make_order(event, now, {}, status=PaymentStatus.PAID, total_price=Decimal("10.00"))
+ assert not _get_order(event, order_id).can_be_cancelled_by_customer()
+
+
+@pytest.mark.django_db
+def test_can_be_cancelled_by_customer_period_expired(cancellation_event: Event):
+ event = cancellation_event
+ when = datetime.now(UTC) - timedelta(days=15)
+
+ order_id = _make_order(event, when, {}, status=PaymentStatus.PAID)
+ assert not _get_order(event, order_id).can_be_cancelled_by_customer()
+
+
+@pytest.mark.django_db
+def test_can_be_cancelled_by_customer_event_started(cancellation_event: Event):
+ event = cancellation_event
+ event.start_time = datetime.now(UTC) - timedelta(hours=1)
+ event.save(update_fields=["start_time"])
+
+ order_id = _make_order(event, datetime.now(UTC), {}, status=PaymentStatus.PAID)
+ assert not _get_order(event, order_id).can_be_cancelled_by_customer()
+
+
+@pytest.mark.django_db
+def test_can_be_cancelled_by_customer_no_start_time(cancellation_event: Event):
+ event = cancellation_event
+ event.start_time = None
+ event.end_time = None
+ event.save(update_fields=["start_time", "end_time"])
+
+ order_id = _make_order(event, datetime.now(UTC), {}, status=PaymentStatus.PAID)
+ order = _get_order(event, order_id)
+ assert order.cancellation_deadline == order.timestamp + timedelta(days=14)
+ assert order.can_be_cancelled_by_customer()
+
+
+@pytest.mark.django_db
+def test_can_be_cancelled_by_customer_disabled(cancellation_event: Event):
+ event = cancellation_event
+ meta = TicketsV2EventMeta.objects.get(event=event)
+ meta.cancellation_period_days = 0
+ meta.save(update_fields=["cancellation_period_days"])
+
+ order_id = _make_order(event, datetime.now(UTC), {}, status=PaymentStatus.PAID)
+ order = _get_order(event, order_id)
+ assert order.cancellation_deadline is None
+ assert not order.can_be_cancelled_by_customer()
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "status",
+ [
+ PaymentStatus.NOT_STARTED,
+ PaymentStatus.PENDING,
+ PaymentStatus.FAILED,
+ PaymentStatus.CANCELLED,
+ PaymentStatus.REFUND_REQUESTED,
+ PaymentStatus.REFUND_FAILED,
+ PaymentStatus.REFUNDED,
+ ],
+)
+def test_can_be_cancelled_by_customer_wrong_status(cancellation_event: Event, status: PaymentStatus):
+ event = cancellation_event
+ order_id = _make_order(event, datetime.now(UTC), {}, status=status)
+ assert not _get_order(event, order_id).can_be_cancelled_by_customer()
+
+
+@pytest.mark.django_db
+def test_request_order_cancellation(cancellation_event: Event):
+ event = cancellation_event
+ order_id = _make_order(event, datetime.now(UTC), {}, status=PaymentStatus.PAID)
+
+ RequestOrderCancellation.mutate(None, None, _request_input(event, order_id))
+
+ token = OrderCancellationToken.objects.get(event=event, order_id=order_id, state="valid")
+ assert len(mail.outbox) == 1
+ message = mail.outbox[0]
+ assert token.confirmation_url in message.body
+ assert message.to == ["Test Customer "]
+ assert message.reply_to == ["Test Ticket Sales "]
+
+ # requesting again revokes the previous token and sends a new one
+ RequestOrderCancellation.mutate(None, None, _request_input(event, order_id))
+ token.refresh_from_db()
+ assert token.state == "revoked"
+ assert OrderCancellationToken.objects.filter(event=event, order_id=order_id, state="valid").count() == 1
+ assert len(mail.outbox) == 2
+
+
+@pytest.mark.django_db
+def test_request_order_cancellation_ineligible(cancellation_event: Event):
+ event = cancellation_event
+ order_id = _make_order(event, datetime.now(UTC), {}, status=PaymentStatus.PENDING)
+
+ with pytest.raises(ValueError):
+ RequestOrderCancellation.mutate(None, None, _request_input(event, order_id))
+
+ assert not OrderCancellationToken.objects.filter(event=event, order_id=order_id).exists()
+ assert len(mail.outbox) == 0
+
+
+@pytest.mark.django_db
+def test_confirm_order_cancellation(cancellation_event: Event):
+ event = cancellation_event
+ order_id = _make_order(event, datetime.now(UTC), {}, status=PaymentStatus.PAID)
+
+ RequestOrderCancellation.mutate(None, None, _request_input(event, order_id))
+ token = OrderCancellationToken.objects.get(event=event, order_id=order_id, state="valid")
+
+ # wrong code is rejected
+ with pytest.raises(OrderCancellationToken.DoesNotExist):
+ ConfirmOrderCancellation.mutate(None, None, _confirm_input(event, order_id, "bogus"))
+
+ ConfirmOrderCancellation.mutate(None, None, _confirm_input(event, order_id, token.code))
+
+ token.refresh_from_db()
+ assert token.state == "used"
+ assert _get_order(event, order_id).status == PaymentStatus.CANCELLED
+
+ # the token cannot be used again
+ with pytest.raises(OrderCancellationToken.DoesNotExist):
+ ConfirmOrderCancellation.mutate(None, None, _confirm_input(event, order_id, token.code))
+
+
+@pytest.mark.django_db
+def test_confirm_order_cancellation_no_longer_eligible(cancellation_event: Event):
+ """
+ Eligibility is re-checked at confirmation time. If the order is no longer
+ eligible (eg. the cancellation period was changed or the event has started),
+ the token is rejected and remains valid (so that nothing happened is clear).
+ """
+ event = cancellation_event
+ order_id = _make_order(event, datetime.now(UTC), {}, status=PaymentStatus.PAID)
+
+ RequestOrderCancellation.mutate(None, None, _request_input(event, order_id))
+ token = OrderCancellationToken.objects.get(event=event, order_id=order_id, state="valid")
+
+ meta = TicketsV2EventMeta.objects.get(event=event)
+ meta.cancellation_period_days = 0
+ meta.save(update_fields=["cancellation_period_days"])
+
+ with pytest.raises(ValueError):
+ ConfirmOrderCancellation.mutate(None, None, _confirm_input(event, order_id, token.code))
+
+ token.refresh_from_db()
+ assert token.state == "valid"
+ assert _get_order(event, order_id).status == PaymentStatus.PAID
+
+
+@pytest.mark.django_db
+def test_receipt_cancellation_link(cancellation_event: Event):
+ event = cancellation_event
+ product = Product.objects.create(
+ event=event,
+ title="Test ticket",
+ description="",
+ price=Decimal("10.00"),
+ vat_percentage=Decimal("25.50"),
+ )
+ order_id = _make_order(
+ event,
+ datetime.now(UTC),
+ {product.id: 1},
+ status=PaymentStatus.PENDING,
+ total_price=Decimal("10.00"),
+ )
+ _add_provider_paid_stamp(event, order_id)
+ order = _get_order(event, order_id)
+
+ EVENT_CACHE.clear()
+ PendingReceipt.event_cache_refreshed_at = None
+ receipt = PendingReceipt.from_order(order)
+ assert receipt.is_customer_cancellable
+ assert f"/orders/{order_id}/cancel" in receipt.body
+
+ # when self-service cancellation is disabled, the receipt has no cancellation link
+ meta = TicketsV2EventMeta.objects.get(event=event)
+ meta.cancellation_period_days = 0
+ meta.save(update_fields=["cancellation_period_days"])
+
+ EVENT_CACHE.clear()
+ PendingReceipt.event_cache_refreshed_at = None
+ receipt = PendingReceipt.from_order(order)
+ assert not receipt.is_customer_cancellable
+ assert "/cancel" not in receipt.body
From 1ec31f37be4973e701f736c0190f7c66c041b53f Mon Sep 17 00:00:00 2001
From: Luka Pajukanta
Date: Sat, 13 Jun 2026 00:27:15 +0300
Subject: [PATCH 02/10] fix(tickets_v2): harden self-service cancellation per
code review
Correctness and security:
- Refund the latest *provider* paid stamp instead of the latest paid stamp,
which could be a provider NONE stamp (eg. order additionally marked as
paid by an admin), burning the confirmation code without refunding
- Surface provider-rejected refunds to the customer (success=False ->
"contact ticket sales to receive your refund") instead of claiming the
refund is on its way; restore the confirmation code if cancellation
failed without changing the order
- Render email datetimes in the event timezone (the |date template filter
re-converts aware datetimes to the active timezone, which defeated
astimezone())
- Rate limit cancellation request emails to one per order per minute
(anonymous mutation; prevents email bombing)
- Expire cancellation confirmation links after 24 hours
- Require CBAC for TicketsV2EventMeta.contact_email in GraphQL (was
newly readable by anonymous clients in Name form)
- UpdateTicketsPreferences leaves omitted fields unchanged instead of
silently wiping them
Performance:
- Compute paid_by_provider in claim_pending_receipts.sql instead of one
PaymentStamp EXISTS query per receipt in the worker batch loop
Maintainability:
- Single source of truth for cancellation deadline/eligibility
(optimized_server/utils/cancellation.py, Django-free) used by the
Order model, the receipt worker and the optimized server
- OrderCancellationToken now reuses OneTimeCodeMixin instead of copying
it; shared tickets_v2/utils/mail.py for sender addressing and the
sv->en email template fallback
- Extract OrderCancellationView and RequestCancellationSection components
to deduplicate the two cancel pages and the two order pages
- Consolidate the receipt worker event cache state into ClassVars
Co-Authored-By: Claude Fable 5
---
.../src/__generated__/graphql.ts | 16 +--
.../orders/[orderId]/cancel/[code]/page.tsx | 85 +++++----------
.../orders/[orderId]/cancel/actions.ts | 31 ++++--
.../orders/[orderId]/cancel/page.tsx | 97 ++++++-----------
.../[eventSlug]/orders/[orderId]/page.tsx | 25 ++---
.../orders/[eventSlug]/[orderId]/page.tsx | 26 ++---
.../dimensions/DimensionFilters.tsx | 2 +-
.../DimensionValueSelectionForm.tsx | 2 +-
.../google-material-symbols/README.md | 1 -
.../tickets/OrderCancellationView.tsx | 68 ++++++++++++
.../tickets/RequestCancellationSection.tsx | 40 +++++++
kompassi-v2-frontend/src/translations/en.tsx | 6 +
kompassi-v2-frontend/src/translations/fi.tsx | 6 +
kompassi-v2-frontend/src/translations/sv.tsx | 7 ++
kompassi/tickets_v2/graphql/meta.py | 17 ++-
.../mutations/confirm_order_cancellation.py | 57 ++++++++--
.../mutations/request_order_cancellation.py | 20 +++-
.../mutations/update_tickets_preferences.py | 60 +++++-----
kompassi/tickets_v2/models/order.py | 57 +++++-----
.../models/order_cancellation_token.py | 92 ++++++----------
kompassi/tickets_v2/models/receipt.py | 103 ++++++------------
.../models/sql/claim_pending_receipts.sql | 11 +-
.../optimized_server/models/order.py | 31 +++---
.../optimized_server/utils/cancellation.py | 67 ++++++++++++
kompassi/tickets_v2/tests.py | 35 +++++-
kompassi/tickets_v2/utils/mail.py | 34 ++++++
26 files changed, 597 insertions(+), 399 deletions(-)
create mode 100644 kompassi-v2-frontend/src/components/tickets/OrderCancellationView.tsx
create mode 100644 kompassi-v2-frontend/src/components/tickets/RequestCancellationSection.tsx
create mode 100644 kompassi/tickets_v2/optimized_server/utils/cancellation.py
create mode 100644 kompassi/tickets_v2/utils/mail.py
diff --git a/kompassi-v2-frontend/src/__generated__/graphql.ts b/kompassi-v2-frontend/src/__generated__/graphql.ts
index 0cbf7800e..9ba405f7b 100644
--- a/kompassi-v2-frontend/src/__generated__/graphql.ts
+++ b/kompassi-v2-frontend/src/__generated__/graphql.ts
@@ -195,9 +195,9 @@ export type ConfirmEmailInput = {
* May be called without authentication: the one-time code proves control of
* the email address of the order.
*
- * If the provider refund request fails after the code is consumed, the order
- * is left in REFUND_FAILED state for ticket sales to resolve with the
- * existing admin refund tooling.
+ * Returns success=False if the order was cancelled but the provider rejected
+ * the refund request (order left in REFUND_FAILED for ticket sales to resolve
+ * with the existing admin refund tooling).
*
* NOTE: Must not return any PII (the caller may be anonymous).
*/
@@ -1606,9 +1606,9 @@ export type Mutation = {
* May be called without authentication: the one-time code proves control of
* the email address of the order.
*
- * If the provider refund request fails after the code is consumed, the order
- * is left in REFUND_FAILED state for ticket sales to resolve with the
- * existing admin refund tooling.
+ * Returns success=False if the order was cancelled but the provider rejected
+ * the refund request (order left in REFUND_FAILED for ticket sales to resolve
+ * with the existing admin refund tooling).
*
* NOTE: Must not return any PII (the caller may be anonymous).
*/
@@ -1690,6 +1690,7 @@ export type Mutation = {
updateSurveyDefaultDimensions?: Maybe;
/**
* Updates the tickets settings that are exposed to event admins.
+ * Fields omitted from the input are left unchanged (clear with an empty value).
* NOTE: provider_id is deliberately not settable here (super admin only).
*/
updateTicketsPreferences?: Maybe;
@@ -2744,7 +2745,7 @@ export type TicketsV2EventMetaType = {
__typename?: 'TicketsV2EventMetaType';
/** Number of days from order creation during which the customer can cancel a paid order themselves. The period is further capped at event start. 0 = customer self-service cancellation disabled. */
cancellationPeriodDays: Scalars['Int']['output'];
- /** Foo Bar <foo.bar@example.com> */
+ /** Ticket sales contact email in the Name Surname format. Admin oriented view; customers get the plain seller email via the order API. */
contactEmail: Scalars['String']['output'];
/** Returns the total number of orders made to this event. Admin oriented view; customers will access order information through `profile.tickets`. */
countTotalOrders: Scalars['Int']['output'];
@@ -3032,6 +3033,7 @@ export type UpdateSurveyInput = {
/**
* Updates the tickets settings that are exposed to event admins.
+ * Fields omitted from the input are left unchanged (clear with an empty value).
* NOTE: provider_id is deliberately not settable here (super admin only).
*/
export type UpdateTicketsPreferences = {
diff --git a/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/orders/[orderId]/cancel/[code]/page.tsx b/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/orders/[orderId]/cancel/[code]/page.tsx
index a0c80823e..95f3fd093 100644
--- a/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/orders/[orderId]/cancel/[code]/page.tsx
+++ b/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/orders/[orderId]/cancel/[code]/page.tsx
@@ -1,11 +1,6 @@
-import Link from "next/link";
-
import { confirmOrderCancellation } from "../actions";
-import { PaymentStatus } from "@/__generated__/graphql";
-import Messages from "@/components/errors/Messages";
import SubmitButton from "@/components/forms/SubmitButton";
-import ViewContainer from "@/components/ViewContainer";
-import ViewHeading from "@/components/ViewHeading";
+import OrderCancellationView from "@/components/tickets/OrderCancellationView";
import { getOrder } from "@/services/tickets";
import { getTranslations } from "@/translations";
@@ -21,62 +16,42 @@ interface Props {
export const revalidate = 0;
-/// Order cancellation confirmation page, reached via the link in the confirmation email.
-/// NOTE: This page can be accessed without authentication (ie. we don't know the accessor
-/// is the person who ordered) so absolutely no PII.
+/// Order cancellation confirmation page, reached via the link in the confirmation
+/// email. See OrderCancellationView for PII concerns.
export default async function OrderCancellationConfirmationPage(props: Props) {
const { locale, eventSlug, orderId, code } = await props.params;
const searchParams = await props.searchParams;
- const { order, event, seller } = await getOrder(eventSlug, orderId);
+ const data = await getOrder(eventSlug, orderId);
const translations = getTranslations(locale);
const t = translations.Tickets.Order;
const confirmT = t.cancelPage.confirm;
return (
-
-
- {confirmT.title}
-
- {t.singleTitle(
- order.formattedOrderNumber,
- t.attributes.status.choices[order.status].shortTitle,
- )}
- {", "}
- {event.name}
-
-
-
-
-
- {order.canRequestCancellation ? (
- <>
- {confirmT.warning}
-
-
- >
- ) : order.status === PaymentStatus.Paid ? (
-
;
+ }
+
+ return null;
+}
diff --git a/kompassi-v2-frontend/src/translations/en.tsx b/kompassi-v2-frontend/src/translations/en.tsx
index a90d70d9c..9620e933a 100644
--- a/kompassi-v2-frontend/src/translations/en.tsx
+++ b/kompassi-v2-frontend/src/translations/en.tsx
@@ -659,6 +659,12 @@ const translations = {
or already been used. Please try again or contact ticket sales.
>
),
+ refundFailed: (
+ <>
+ Your order has been cancelled, but the refund could not be
+ initiated. Please contact ticket sales to receive your refund.
+ >
+ ),
},
cancelPage: {
title: "Cancel order",
diff --git a/kompassi-v2-frontend/src/translations/fi.tsx b/kompassi-v2-frontend/src/translations/fi.tsx
index 9bd99a705..dfe239f38 100644
--- a/kompassi-v2-frontend/src/translations/fi.tsx
+++ b/kompassi-v2-frontend/src/translations/fi.tsx
@@ -654,6 +654,12 @@ const translations: Translations = {
lipunmyyntiin.
>
),
+ refundFailed: (
+ <>
+ Tilauksesi on peruttu, mutta maksun palautusta ei voitu käynnistää.
+ Ota yhteyttä lipunmyyntiin saadaksesi maksunpalautuksen.
+ >
+ ),
},
cancelPage: {
title: "Peruuta tilaus",
diff --git a/kompassi-v2-frontend/src/translations/sv.tsx b/kompassi-v2-frontend/src/translations/sv.tsx
index 780ab599e..9797ac7e8 100644
--- a/kompassi-v2-frontend/src/translations/sv.tsx
+++ b/kompassi-v2-frontend/src/translations/sv.tsx
@@ -646,6 +646,13 @@ const translations: Translations = {
biljettförsäljningen.
>
),
+ refundFailed: (
+ <>
+ Din beställning har avbokats, men återbetalningen kunde inte
+ påbörjas. Kontakta biljettförsäljningen för att få din
+ återbetalning.
+ >
+ ),
},
cancelPage: {
title: "Avboka beställning",
diff --git a/kompassi/tickets_v2/graphql/meta.py b/kompassi/tickets_v2/graphql/meta.py
index 5e8cf8e4c..76e5e9dad 100644
--- a/kompassi/tickets_v2/graphql/meta.py
+++ b/kompassi/tickets_v2/graphql/meta.py
@@ -22,15 +22,30 @@
class TicketsV2EventMetaType(DjangoObjectType):
class Meta:
model = TicketsV2EventMeta
+ # NOTE: T&C URLs and the cancellation period are public information
+ # (already served to anonymous users via the REST API and order pages).
fields = (
"provider_id",
- "contact_email",
"terms_and_conditions_url_en",
"terms_and_conditions_url_fi",
"terms_and_conditions_url_sv",
"cancellation_period_days",
)
+ @graphql_query_cbac_required
+ @staticmethod
+ def resolve_contact_email(meta: TicketsV2EventMeta, info):
+ """
+ Ticket sales contact email in the Name Surname format.
+ Admin oriented view; customers get the plain seller email via the order API.
+ """
+ return meta.contact_email
+
+ contact_email = graphene.NonNull(
+ graphene.String,
+ description=normalize_whitespace(resolve_contact_email.__doc__ or ""),
+ )
+
@graphql_query_cbac_required
@staticmethod
def resolve_products(meta: TicketsV2EventMeta, info):
diff --git a/kompassi/tickets_v2/graphql/mutations/confirm_order_cancellation.py b/kompassi/tickets_v2/graphql/mutations/confirm_order_cancellation.py
index 9414a600a..e1e9743b6 100644
--- a/kompassi/tickets_v2/graphql/mutations/confirm_order_cancellation.py
+++ b/kompassi/tickets_v2/graphql/mutations/confirm_order_cancellation.py
@@ -1,11 +1,22 @@
+import logging
+from datetime import timedelta
+
import graphene
from django.db import transaction
+from django.utils.timezone import now
from kompassi.core.models.event import Event
from ...models.enums import ActorType
+from ...models.order import Order
from ...models.order_cancellation_token import OrderCancellationToken
-from ...optimized_server.models.enums import RefundType
+from ...optimized_server.models.enums import PaymentStatus, RefundType
+
+logger = logging.getLogger(__name__)
+
+# An emailed confirmation link can be used for this long after it was requested.
+# (A new link can always be requested as long as the order remains cancellable.)
+TOKEN_VALIDITY = timedelta(hours=24)
class ConfirmOrderCancellationInput(graphene.InputObjectType):
@@ -23,9 +34,9 @@ class ConfirmOrderCancellation(graphene.Mutation):
May be called without authentication: the one-time code proves control of
the email address of the order.
- If the provider refund request fails after the code is consumed, the order
- is left in REFUND_FAILED state for ticket sales to resolve with the
- existing admin refund tooling.
+ Returns success=False if the order was cancelled but the provider rejected
+ the refund request (order left in REFUND_FAILED for ticket sales to resolve
+ with the existing admin refund tooling).
NOTE: Must not return any PII (the caller may be anonymous).
"""
@@ -53,6 +64,9 @@ def mutate(
state="valid",
)
+ if token.created_at < now() - TOKEN_VALIDITY:
+ raise ValueError("The cancellation link has expired. Please request a new one.")
+
order = token.order
if not order.can_be_cancelled_by_customer():
raise ValueError("This order can no longer be cancelled.")
@@ -62,10 +76,31 @@ def mutate(
# NOTE: cancel_and_refund manages its own transactions and performs an
# external HTTP call to the payment provider, so it must stay outside
# the token transaction.
- order.cancel_and_refund(
- RefundType.PROVIDER if order.cached_price > 0 else RefundType.NONE,
- actor_type=ActorType.CUSTOMER,
- actor_user=None,
- )
-
- return ConfirmOrderCancellation(success=True) # type: ignore
+ try:
+ order.cancel_and_refund(
+ RefundType.PROVIDER if order.cached_price > 0 else RefundType.NONE,
+ actor_type=ActorType.CUSTOMER,
+ actor_user=None,
+ )
+ except Exception:
+ # If the order was left untouched, give the token back so that the
+ # customer can retry with the same link instead of hitting a dead end.
+ fresh_order = Order.objects.get(event=event, id=order.id)
+ if fresh_order.status == PaymentStatus.PAID:
+ logger.warning(
+ "Customer cancellation of order %s failed without changing the order. Restoring token.",
+ order.id,
+ exc_info=True,
+ )
+ token.state = "valid"
+ token.used_at = None
+ token.save(update_fields=["state", "used_at"])
+ raise
+
+ # The refund request may have been rejected by the provider without raising
+ # (recorded as a REFUND_FAILED payment stamp). The customer must not be told
+ # that the refund is on its way when it is not.
+ fresh_order = Order.objects.get(event=event, id=order.id)
+ refund_failed = fresh_order.status == PaymentStatus.REFUND_FAILED
+
+ return ConfirmOrderCancellation(success=not refund_failed) # type: ignore
diff --git a/kompassi/tickets_v2/graphql/mutations/request_order_cancellation.py b/kompassi/tickets_v2/graphql/mutations/request_order_cancellation.py
index e25bac7a7..fc362dcd5 100644
--- a/kompassi/tickets_v2/graphql/mutations/request_order_cancellation.py
+++ b/kompassi/tickets_v2/graphql/mutations/request_order_cancellation.py
@@ -1,3 +1,5 @@
+from datetime import timedelta
+
import graphene
from django.utils.timezone import now
@@ -7,6 +9,9 @@
from ...models.order import Order
from ...models.order_cancellation_token import OrderCancellationToken
+# Crude rate limit: at most one cancellation request email per order per this period.
+REQUEST_THROTTLE = timedelta(minutes=1)
+
class RequestOrderCancellationInput(graphene.InputObjectType):
event_slug = graphene.String(required=True)
@@ -42,19 +47,28 @@ def mutate(
if not order.can_be_cancelled_by_customer():
raise ValueError("This order cannot be cancelled.")
+ if OrderCancellationToken.objects.filter(
+ event=event,
+ order_id=order.id,
+ created_at__gte=now() - REQUEST_THROTTLE,
+ ).exists():
+ raise ValueError("Cancellation was requested recently. Please check your email.")
+
# Only the most recently requested confirmation link is valid.
- OrderCancellationToken.objects.filter(
+ for stale_token in OrderCancellationToken.objects.filter(
event=event,
order_id=order.id,
state="valid",
- ).update(state="revoked", used_at=now())
+ ):
+ stale_token.revoke()
token = OrderCancellationToken.objects.create(
event=event,
order_id=order.id,
language=order.language,
)
- token.send()
+ token.order = order # seed the cached_property to avoid a redundant query
+ token.send_confirmation()
order.emit_event_log_entry(
"tickets_v2.order.cancellation_requested",
diff --git a/kompassi/tickets_v2/graphql/mutations/update_tickets_preferences.py b/kompassi/tickets_v2/graphql/mutations/update_tickets_preferences.py
index 1a3d352b7..00238290c 100644
--- a/kompassi/tickets_v2/graphql/mutations/update_tickets_preferences.py
+++ b/kompassi/tickets_v2/graphql/mutations/update_tickets_preferences.py
@@ -7,6 +7,13 @@
from ...models.meta import TicketsV2EventMeta
from ..meta import TicketsV2EventMetaType
+TEXT_FIELDS = [
+ "contact_email",
+ "terms_and_conditions_url_en",
+ "terms_and_conditions_url_fi",
+ "terms_and_conditions_url_sv",
+]
+
class UpdateTicketsPreferencesInput(graphene.InputObjectType):
event_slug = graphene.String(required=True)
@@ -20,6 +27,7 @@ class UpdateTicketsPreferencesInput(graphene.InputObjectType):
class UpdateTicketsPreferences(graphene.Mutation):
"""
Updates the tickets settings that are exposed to event admins.
+ Fields omitted from the input are left unchanged (clear with an empty value).
NOTE: provider_id is deliberately not settable here (super admin only).
"""
@@ -43,30 +51,32 @@ def mutate(
operation="update",
)
- contact_email = (input.contact_email or "").strip() # type: ignore
- if contact_email:
- try:
- contact_email_validator(contact_email)
- except ValidationError as e:
- raise ValueError("Invalid contact email (expected format: Name )") from e
-
- cancellation_period_days: int = input.cancellation_period_days or 0 # type: ignore
- if cancellation_period_days < 0:
- raise ValueError("Cancellation period cannot be negative")
-
- meta.contact_email = contact_email
- meta.terms_and_conditions_url_en = input.terms_and_conditions_url_en or "" # type: ignore
- meta.terms_and_conditions_url_fi = input.terms_and_conditions_url_fi or "" # type: ignore
- meta.terms_and_conditions_url_sv = input.terms_and_conditions_url_sv or "" # type: ignore
- meta.cancellation_period_days = cancellation_period_days
- meta.save(
- update_fields=[
- "contact_email",
- "terms_and_conditions_url_en",
- "terms_and_conditions_url_fi",
- "terms_and_conditions_url_sv",
- "cancellation_period_days",
- ]
- )
+ update_fields = []
+
+ for field in TEXT_FIELDS:
+ if (value := getattr(input, field)) is None:
+ continue
+
+ value = value.strip()
+
+ if field == "contact_email" and value:
+ try:
+ contact_email_validator(value)
+ except ValidationError as e:
+ raise ValueError("Invalid contact email (expected format: Name )") from e
+
+ setattr(meta, field, value)
+ update_fields.append(field)
+
+ cancellation_period_days: int | None = input.cancellation_period_days # type: ignore
+ if cancellation_period_days is not None:
+ if cancellation_period_days < 0:
+ raise ValueError("Cancellation period cannot be negative")
+
+ meta.cancellation_period_days = cancellation_period_days
+ update_fields.append("cancellation_period_days")
+
+ if update_fields:
+ meta.save(update_fields=update_fields)
return UpdateTicketsPreferences(preferences=meta) # type: ignore
diff --git a/kompassi/tickets_v2/models/order.py b/kompassi/tickets_v2/models/order.py
index 85ff7cdce..c6d0a5fec 100644
--- a/kompassi/tickets_v2/models/order.py
+++ b/kompassi/tickets_v2/models/order.py
@@ -27,6 +27,7 @@
from ..optimized_server.models.enums import PaymentProvider, PaymentStampType, PaymentStatus, RefundType
from ..optimized_server.models.order import OrderProduct, VatBreakdownLine
+from ..optimized_server.utils.cancellation import get_cancellation_deadline, is_cancellable_by_customer
from ..optimized_server.utils.formatting import format_order_number
from ..optimized_server.utils.uuid7 import uuid7
from ..utils.event_partitions import EventPartitionsMixin
@@ -332,47 +333,36 @@ def can_be_cancelled_by_owner(self, user: User):
)
@property
- def cancellation_deadline(self) -> datetime | None:
+ def provider_paid_stamps(self) -> models.QuerySet[PaymentStamp]:
"""
- Deadline for customer self-service cancellation, or None if disabled for the event.
- The cancellation period starts at order creation and is capped at event start.
+ PAID stamps recorded by an actual payment provider (as opposed to eg.
+ an admin marking the order as paid). Only these can be provider refunded.
"""
- period_days = self.meta.cancellation_period_days
- if not period_days:
- return None
-
- deadline = self.timestamp + timedelta(days=period_days)
-
- if (start_time := self.event.start_time) is not None:
- deadline = min(deadline, start_time)
+ return self.payment_stamps.filter(status=PaymentStatus.PAID).exclude(provider_id=PaymentProvider.NONE)
- return deadline
+ @property
+ def cancellation_deadline(self) -> datetime | None:
+ return get_cancellation_deadline(
+ order_created_at=self.timestamp,
+ cancellation_period_days=self.meta.cancellation_period_days,
+ event_start_time=self.event.start_time,
+ )
def can_be_cancelled_by_customer(self) -> bool:
- """
- Customer self-service cancellation (confirmed via email) is allowed for paid orders
- within the cancellation period, provided that any money paid can be automatically
- refunded via the payment provider. Customers holding orders that fail these criteria
- are directed to contact ticket sales instead.
- """
- if self.status != PaymentStatus.PAID:
- return False
-
- deadline = self.cancellation_deadline
- if deadline is None or django_now() >= deadline:
- return False
-
- if self.cached_price == 0:
- return True
-
- return self.payment_stamps.filter(status=PaymentStatus.PAID).exclude(provider_id=PaymentProvider.NONE).exists()
+ return is_cancellable_by_customer(
+ status=self.status,
+ cancellation_deadline=self.cancellation_deadline,
+ now=django_now(),
+ total_price=self.cached_price,
+ is_paid_by_provider=self.provider_paid_stamps.exists,
+ )
def can_be_provider_refunded_by(self, request: HttpRequest):
# TODO should this method do all the same checks that cancel_and_refund does?
return (
self.status.is_refundable
and self.cached_price > 0
- and self.payment_stamps.filter(status=PaymentStatus.PAID).exclude(provider_id=PaymentProvider.NONE).exists()
+ and self.provider_paid_stamps.exists()
and is_graphql_allowed_for_model(
request.user,
instance=self,
@@ -473,9 +463,12 @@ def cancel_and_refund(
if self.cached_status == PaymentStatus.REFUNDED:
raise ValueError("Order is already refunded")
- paid_stamp = self.payment_stamps.filter(status=PaymentStatus.PAID).order_by("-id").first()
+ # NOTE: must be a provider stamp — the order may additionally have been
+ # marked as paid by an admin, recording a PAID stamp with provider NONE
+ # that cannot be refunded via the provider.
+ paid_stamp = self.provider_paid_stamps.order_by("-id").first()
if not paid_stamp:
- raise ValueError("Cannot refund an order that has not been paid")
+ raise ValueError("Cannot refund an order that has not been paid via a payment provider")
prepared_request = self.meta.provider.prepare_refund(paid_stamp)
request_stamp = prepared_request.request_stamp
diff --git a/kompassi/tickets_v2/models/order_cancellation_token.py b/kompassi/tickets_v2/models/order_cancellation_token.py
index b8e0dc92f..9e71c04c6 100644
--- a/kompassi/tickets_v2/models/order_cancellation_token.py
+++ b/kompassi/tickets_v2/models/order_cancellation_token.py
@@ -2,20 +2,17 @@
import logging
from functools import cached_property
-from random import choice
from django.conf import settings
from django.db import models
from django.template.loader import render_to_string
-from django.utils import timezone
+from django.utils.timezone import override as timezone_override
from kompassi.core.models.event import Event
-from kompassi.core.models.one_time_code import (
- ONE_TIME_CODE_ALPHABET,
- ONE_TIME_CODE_LENGTH,
- ONE_TIME_CODE_STATE_CHOICES,
-)
-from kompassi.graphql_api.language import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGE_CODES, get_language_choices
+from kompassi.core.models.one_time_code import ONE_TIME_CODE_STATE_CHOICES, OneTimeCodeMixin
+from kompassi.graphql_api.language import DEFAULT_LANGUAGE, get_language_choices
+
+from ..utils.mail import email_template_language, tickets_from_email
logger = logging.getLogger(__name__)
@@ -25,12 +22,14 @@
)
-class OrderCancellationToken(models.Model):
+class OrderCancellationToken(models.Model, OneTimeCodeMixin):
"""
A one-time code sent to the email address of an order to confirm
customer self-service cancellation of the order.
- NOTE: Order is partitioned by event, so we cannot have a foreign key to it.
+ NOTE: Cannot extend the abstract OneTimeCode model because it requires a
+ Person, and orders need not be associated with a user account. NOTE: Order
+ is partitioned by event, so we cannot have a foreign key to it either.
Instead, we use the same (event, order_id) pattern as Receipt.
"""
@@ -61,12 +60,9 @@ class Meta:
models.Index(fields=["event", "order_id", "state"]),
]
- def __str__(self):
- return self.code
-
def save(self, *args, **kwargs):
if not self.code:
- self.code = "".join(choice(ONE_TIME_CODE_ALPHABET) for _ in range(ONE_TIME_CODE_LENGTH))
+ self.code = self.generate_code()
return super().save(*args, **kwargs)
@@ -86,68 +82,42 @@ def confirmation_url(self) -> str:
f"/orders/{self.order_id}/cancel/{self.code}"
)
- def mark_used(self):
- if self.state != "valid":
- raise ValueError("Must be valid to mark used")
-
- self.used_at = timezone.now()
- self.state = "used"
- self.save(update_fields=["used_at", "state"])
-
- def revoke(self):
- if self.state != "valid":
- raise ValueError("Must be valid to revoke")
-
- self.used_at = timezone.now()
- self.state = "revoked"
- self.save(update_fields=["used_at", "state"])
-
- @property
- def message_language(self) -> str:
+ def send_confirmation(self):
"""
- TODO Missing Swedish message template (see PendingReceipt.validate_language)
+ NOTE: Deliberately not OneTimeCodeMixin.send, which addresses the email
+ via a Person and a request; this token has neither.
"""
- language = self.language.lower()
- if language == "sv" or language not in SUPPORTED_LANGUAGE_CODES:
- return DEFAULT_LANGUAGE
-
- return language
-
- def send(self):
from kompassi.core.tasks import send_email
order = self.order
meta = order.meta
event = self.event
- language = self.message_language
-
- deadline = order.cancellation_deadline
- if deadline is not None:
- deadline = deadline.astimezone(event.timezone)
-
- body = render_to_string(
- f"tickets_v2_cancellation_request_{language}.eml",
- dict(
- event_name=event.name,
- order_number=order.order_number,
- confirmation_url=self.confirmation_url,
- deadline=deadline,
- seller_name=event.organization.name,
- seller_email=meta.plain_contact_email,
- seller_business_id=event.organization.business_id,
- ),
- )
+ language = email_template_language(self.language)
+
+ # NOTE: the |date template filter renders aware datetimes in the active
+ # timezone, so the deadline must be rendered in the event timezone.
+ with timezone_override(event.timezone):
+ body = render_to_string(
+ f"tickets_v2_cancellation_request_{language}.eml",
+ dict(
+ event_name=event.name,
+ order_number=order.order_number,
+ confirmation_url=self.confirmation_url,
+ deadline=order.cancellation_deadline,
+ seller_name=event.organization.name,
+ seller_email=meta.plain_contact_email,
+ seller_business_id=event.organization.business_id,
+ ),
+ )
subject = f"{event.name}: {CANCELLATION_REQUEST_SUBJECT[language]} ({order.formatted_order_number})"
- mail_domain = settings.DEFAULT_FROM_EMAIL.split("@", 1)[1].rstrip(">")
- from_email = f"{event.name} ({settings.KOMPASSI_INSTALLATION_NAME}) <{event.slug}-tickets@{mail_domain}>"
reply_to = (contact_email,) if (contact_email := meta.contact_email) else ()
to = (f"{order.first_name} {order.last_name} <{order.email}>",)
send_email.delay( # type: ignore
subject=subject,
body=body,
- from_email=from_email,
+ from_email=tickets_from_email(event),
reply_to=reply_to,
to=to,
)
diff --git a/kompassi/tickets_v2/models/receipt.py b/kompassi/tickets_v2/models/receipt.py
index a826ea608..31743ca75 100644
--- a/kompassi/tickets_v2/models/receipt.py
+++ b/kompassi/tickets_v2/models/receipt.py
@@ -15,17 +15,19 @@
from django.db import connection, models, transaction
from django.template.loader import render_to_string
from django.utils.timezone import now as django_now
+from django.utils.timezone import override as timezone_override
from lippukala.models import Code
from lippukala.models import Order as LippukalaOrder
from lippukala.printing import OrderPrinter
from kompassi.core.models.event import Event
from kompassi.event_log_v2.utils.monthly_partitions import UUID7Mixin, uuid7, uuid7_to_datetime
-from kompassi.graphql_api.language import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGE_CODES
from kompassi.tickets_v2.lippukala_integration import Queue as LippukalaQueue
-from ..optimized_server.models.enums import PaymentProvider, PaymentStatus, ReceiptStatus, ReceiptType
+from ..optimized_server.models.enums import PaymentStatus, ReceiptStatus, ReceiptType
+from ..optimized_server.utils.cancellation import get_cancellation_deadline, is_cancellable_by_customer
from ..utils.event_partitions import EventPartitionsMixin
+from ..utils.mail import email_template_language, tickets_from_email
from .meta import TicketsV2EventMeta
from .order import Order, OrderMixin
from .product import Product
@@ -48,10 +50,8 @@
en="Order cancelled",
)
-FROM_EMAIL: str = settings.DEFAULT_FROM_EMAIL
KOMPASSI_V2_BASE_URL: str = settings.KOMPASSI_V2_BASE_URL
SHOP_HOSTNAME = urlparse(KOMPASSI_V2_BASE_URL).hostname # 'v2.kompassi.eu'
-MAIL_DOMAIN = FROM_EMAIL.split("@", 1)[1].rstrip(">")
LIPPUKALA_PREFIX = LippukalaQueue.ONE_QUEUE
ETICKET_FILENAME = "e-ticket.pdf"
@@ -130,13 +130,6 @@ def timezone(self):
return self.event.timezone
-EVENT_CACHE: dict[int, Event] = {}
-
-# The cached Event also reaches TicketsV2EventMeta (eg. cancellation_period_days),
-# which the admin can change at any time, so the cache needs to expire on its own.
-EVENT_CACHE_TTL = timedelta(minutes=5)
-
-
class PendingReceipt(OrderMixin, pydantic.BaseModel, arbitrary_types_allowed=True, frozen=True):
"""
Responsible for sending email receipts and generating eticket PDFs.
@@ -157,12 +150,17 @@ class PendingReceipt(OrderMixin, pydantic.BaseModel, arbitrary_types_allowed=Tru
product_data: dict[int, int]
order_number: int
total_price: Decimal
+ paid_by_provider: bool = False
# NOTE: fields and their order must match fields returned by the query
query: ClassVar[str] = (Path(__file__).parent / "sql" / "claim_pending_receipts.sql").read_text()
batch_size: ClassVar[int] = 100
+ # The cached Event also reaches TicketsV2EventMeta (eg. cancellation_period_days),
+ # which the admin can change at any time, so the cache needs to expire on its own.
+ event_cache: ClassVar[dict[int, Event]] = {}
event_cache_refreshed_at: ClassVar[datetime | None] = None
+ event_cache_ttl: ClassVar[timedelta] = timedelta(minutes=5)
@property
def order_date(self):
@@ -171,16 +169,7 @@ def order_date(self):
@pydantic.field_validator("language", mode="before")
@staticmethod
def validate_language(value: str):
- value = value.lower()
-
- # TODO Missing Swedish message template
- if value == "sv":
- return "en"
-
- if value not in SUPPORTED_LANGUAGE_CODES:
- return DEFAULT_LANGUAGE
-
- return value
+ return email_template_language(value)
@pydantic.field_validator("product_data", mode="before")
@staticmethod
@@ -194,13 +183,13 @@ def validate_product_data(value: str | dict[str, int]):
def _get_event(cls, event_id: int):
now = django_now()
cache_is_fresh = (
- cls.event_cache_refreshed_at is not None and now - cls.event_cache_refreshed_at < EVENT_CACHE_TTL
+ cls.event_cache_refreshed_at is not None and now - cls.event_cache_refreshed_at < cls.event_cache_ttl
)
- if cache_is_fresh and (found := EVENT_CACHE.get(event_id)):
+ if cache_is_fresh and (found := cls.event_cache.get(event_id)):
return found
- EVENT_CACHE.clear()
- EVENT_CACHE.update(
+ cls.event_cache.clear()
+ cls.event_cache.update(
{
event.id: event
for event in Event.objects.filter(ticketsv2eventmeta__isnull=False)
@@ -217,7 +206,7 @@ def _get_event(cls, event_id: int):
)
cls.event_cache_refreshed_at = now
- return EVENT_CACHE[event_id]
+ return cls.event_cache[event_id]
@cached_property
def event(self) -> Event:
@@ -232,45 +221,23 @@ def meta(self) -> TicketsV2EventMeta:
@cached_property
def cancellation_deadline(self) -> datetime | None:
- """
- Keep in sync with Order.cancellation_deadline.
- """
- period_days = self.meta.cancellation_period_days
- if not period_days:
- return None
-
- deadline = self.order_date + timedelta(days=period_days)
-
- if (start_time := self.event.start_time) is not None:
- deadline = min(deadline, start_time)
-
- return deadline
+ return get_cancellation_deadline(
+ order_created_at=self.order_date,
+ cancellation_period_days=self.meta.cancellation_period_days,
+ event_start_time=self.event.start_time,
+ )
@property
def is_customer_cancellable(self) -> bool:
- """
- Keep in sync with Order.can_be_cancelled_by_customer.
- """
- from .payment_stamp import PaymentStamp
-
if self.receipt_type != ReceiptType.PAID:
return False
- deadline = self.cancellation_deadline
- if deadline is None or django_now() >= deadline:
- return False
-
- if self.total_price == 0:
- return True
-
- return (
- PaymentStamp.objects.filter(
- event_id=self.event_id,
- order_id=self.order_id,
- status=PaymentStatus.PAID,
- )
- .exclude(provider_id=PaymentProvider.NONE)
- .exists()
+ return is_cancellable_by_customer(
+ status=PaymentStatus.PAID,
+ cancellation_deadline=self.cancellation_deadline,
+ now=django_now(),
+ total_price=self.total_price,
+ is_paid_by_provider=lambda: self.paid_by_provider,
)
@classmethod
@@ -373,11 +340,8 @@ def body(self) -> str:
raise ValueError("Unknown receipt type")
organization = self.event.organization
- cancellation_deadline = self.cancellation_deadline if self.is_customer_cancellable else None
vars = dict(
- cancellation_deadline=(
- cancellation_deadline.astimezone(self.event.timezone) if cancellation_deadline else None
- ),
+ cancellation_deadline=self.cancellation_deadline if self.is_customer_cancellable else None,
cancellation_url=f"{KOMPASSI_V2_BASE_URL}/{self.language}/{self.event.slug}/orders/{self.order_id}/cancel",
event_name=self.event.name,
order_number=self.order_number,
@@ -395,7 +359,11 @@ def body(self) -> str:
seller_email=self.meta.plain_contact_email,
seller_business_id=organization.business_id,
)
- return render_to_string(template_name, vars)
+
+ # NOTE: the |date template filter renders aware datetimes in the active
+ # timezone, so deadlines etc. must be rendered in the event timezone.
+ with timezone_override(self.event.timezone):
+ return render_to_string(template_name, vars)
@property
def subject(self) -> str:
@@ -411,10 +379,8 @@ def subject(self) -> str:
return f"{self.event.name}: {subject} ({self.formatted_order_number})"
- def send_receipt(self, from_email: str = FROM_EMAIL, mail_domain: str = MAIL_DOMAIN):
- from_email = (
- f"{self.event.name} ({settings.KOMPASSI_INSTALLATION_NAME}) <{self.event.slug}-tickets@{mail_domain}>"
- )
+ def send_receipt(self):
+ from_email = tickets_from_email(self.event)
reply_to_emails = (contact_email,) if (contact_email := self.meta.contact_email) else ()
to_emails = (f"{self.first_name} {self.last_name} <{self.email}>",)
@@ -457,6 +423,7 @@ def from_order(cls, order: Order) -> Self:
product_data=order.product_data,
order_number=order.order_number,
total_price=order.cached_price,
+ paid_by_provider=order.provider_paid_stamps.exists(),
)
diff --git a/kompassi/tickets_v2/models/sql/claim_pending_receipts.sql b/kompassi/tickets_v2/models/sql/claim_pending_receipts.sql
index 0f10c667a..1002c0877 100644
--- a/kompassi/tickets_v2/models/sql/claim_pending_receipts.sql
+++ b/kompassi/tickets_v2/models/sql/claim_pending_receipts.sql
@@ -41,7 +41,16 @@ select
o.phone,
o.product_data,
o.order_number,
- o.cached_price as total_price
+ o.cached_price as total_price,
+ exists (
+ select 1
+ from tickets_v2_paymentstamp ps
+ where
+ ps.event_id = o.event_id
+ and ps.order_id = o.id
+ and ps.status = 3 -- PaymentStatus.PAID
+ and ps.provider_id <> 0 -- PaymentProvider.NONE
+ ) as paid_by_provider
from
claimed_receipts cr
join tickets_v2_order o on (cr.order_id = o.id)
diff --git a/kompassi/tickets_v2/optimized_server/models/order.py b/kompassi/tickets_v2/optimized_server/models/order.py
index 9100fe746..f8a5620cf 100644
--- a/kompassi/tickets_v2/optimized_server/models/order.py
+++ b/kompassi/tickets_v2/optimized_server/models/order.py
@@ -2,7 +2,7 @@
import json
from collections import defaultdict
-from datetime import UTC, datetime, timedelta
+from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from typing import Annotated, Any, ClassVar, Self
@@ -14,6 +14,7 @@
from kompassi.graphql_api.language import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGE_CODES
+from ...optimized_server.utils.cancellation import get_cancellation_deadline, is_cancellable_by_customer
from ...optimized_server.utils.uuid7 import uuid7, uuid7_to_datetime
from ..config import KOMPASSI_V2_BASE_URL
from ..excs import InvalidProducts, UnsaneSituation
@@ -255,23 +256,17 @@ def get_url(self, event_slug: str):
return f"{KOMPASSI_V2_BASE_URL}/{event_slug}/orders/{self.id}"
def populate_cancellation(self, event: Event) -> None:
- """
- Keep in sync with the Django side:
- kompassi.tickets_v2.models.order.Order.cancellation_deadline
- and can_be_cancelled_by_customer.
- """
- if not event.cancellation_period_days:
- return
-
- deadline = self.created_at + timedelta(days=event.cancellation_period_days)
- if event.start_time is not None:
- deadline = min(deadline, event.start_time)
-
- self.cancellation_deadline = deadline
- self.can_request_cancellation = (
- self.status == PaymentStatus.PAID
- and datetime.now(UTC) < deadline
- and (self.total_price == 0 or self.paid_by_provider)
+ self.cancellation_deadline = get_cancellation_deadline(
+ order_created_at=self.created_at,
+ cancellation_period_days=event.cancellation_period_days,
+ event_start_time=event.start_time,
+ )
+ self.can_request_cancellation = is_cancellable_by_customer(
+ status=self.status,
+ cancellation_deadline=self.cancellation_deadline,
+ now=datetime.now(UTC),
+ total_price=self.total_price,
+ is_paid_by_provider=lambda: self.paid_by_provider,
)
diff --git a/kompassi/tickets_v2/optimized_server/utils/cancellation.py b/kompassi/tickets_v2/optimized_server/utils/cancellation.py
new file mode 100644
index 000000000..172671efa
--- /dev/null
+++ b/kompassi/tickets_v2/optimized_server/utils/cancellation.py
@@ -0,0 +1,67 @@
+"""
+Customer self-service cancellation eligibility.
+
+This is the single source of truth for the cancellation deadline arithmetic
+and the eligibility rule. It is used by three layers that each fetch the
+inputs differently:
+
+- kompassi.tickets_v2.models.order.Order (GraphQL mutations, authoritative)
+- kompassi.tickets_v2.models.receipt.PendingReceipt (receipt emails)
+- kompassi.tickets_v2.optimized_server.models.order.Order (anonymous order API)
+
+NOTE: Must remain free of Django imports (used by the optimized server).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from datetime import datetime, timedelta
+from decimal import Decimal
+
+from ..models.enums import PaymentStatus
+
+
+def get_cancellation_deadline(
+ order_created_at: datetime,
+ cancellation_period_days: int,
+ event_start_time: datetime | None,
+) -> datetime | None:
+ """
+ Deadline for customer self-service cancellation, or None if disabled.
+ The cancellation period starts at order creation and is capped at event start.
+ """
+ if not cancellation_period_days:
+ return None
+
+ deadline = order_created_at + timedelta(days=cancellation_period_days)
+
+ if event_start_time is not None:
+ deadline = min(deadline, event_start_time)
+
+ return deadline
+
+
+def is_cancellable_by_customer(
+ *,
+ status: PaymentStatus,
+ cancellation_deadline: datetime | None,
+ now: datetime,
+ total_price: Decimal,
+ is_paid_by_provider: Callable[[], bool],
+) -> bool:
+ """
+ Customer self-service cancellation (confirmed via email) is allowed for paid
+ orders within the cancellation period, provided that any money paid can be
+ automatically refunded via the payment provider. Customers holding orders
+ that fail these criteria are directed to contact ticket sales instead.
+
+ is_paid_by_provider is a callable so that callers backed by a database
+ query only pay for it when the cheaper criteria have already passed.
+ """
+ if status != PaymentStatus.PAID:
+ return False
+
+ if cancellation_deadline is None or now >= cancellation_deadline:
+ return False
+
+ return total_price == 0 or is_paid_by_provider()
diff --git a/kompassi/tickets_v2/tests.py b/kompassi/tickets_v2/tests.py
index c63632ac6..c878e6d5a 100644
--- a/kompassi/tickets_v2/tests.py
+++ b/kompassi/tickets_v2/tests.py
@@ -19,7 +19,7 @@
from kompassi.tickets_v2.models.order_cancellation_token import OrderCancellationToken
from kompassi.tickets_v2.models.payment_stamp import PaymentStamp
from kompassi.tickets_v2.models.product import Product
-from kompassi.tickets_v2.models.receipt import EVENT_CACHE, PendingReceipt
+from kompassi.tickets_v2.models.receipt import PendingReceipt
from kompassi.tickets_v2.optimized_server.models.api import CreateOrderResponse, GetOrderResponse, GetProductsResponse
from kompassi.tickets_v2.optimized_server.models.customer import Customer
from kompassi.tickets_v2.optimized_server.models.enums import (
@@ -706,7 +706,16 @@ def test_request_order_cancellation(cancellation_event: Event):
assert message.to == ["Test Customer "]
assert message.reply_to == ["Test Ticket Sales "]
- # requesting again revokes the previous token and sends a new one
+ # requesting again right away is throttled (email bombing prevention)
+ with pytest.raises(ValueError):
+ RequestOrderCancellation.mutate(None, None, _request_input(event, order_id))
+ token.refresh_from_db()
+ assert token.state == "valid"
+ assert len(mail.outbox) == 1
+
+ # once the throttle period has passed, requesting again
+ # revokes the previous token and sends a new one
+ OrderCancellationToken.objects.filter(pk=token.pk).update(created_at=datetime.now(UTC) - timedelta(minutes=2))
RequestOrderCancellation.mutate(None, None, _request_input(event, order_id))
token.refresh_from_db()
assert token.state == "revoked"
@@ -794,7 +803,6 @@ def test_receipt_cancellation_link(cancellation_event: Event):
_add_provider_paid_stamp(event, order_id)
order = _get_order(event, order_id)
- EVENT_CACHE.clear()
PendingReceipt.event_cache_refreshed_at = None
receipt = PendingReceipt.from_order(order)
assert receipt.is_customer_cancellable
@@ -805,8 +813,27 @@ def test_receipt_cancellation_link(cancellation_event: Event):
meta.cancellation_period_days = 0
meta.save(update_fields=["cancellation_period_days"])
- EVENT_CACHE.clear()
PendingReceipt.event_cache_refreshed_at = None
receipt = PendingReceipt.from_order(order)
assert not receipt.is_customer_cancellable
assert "/cancel" not in receipt.body
+
+
+@pytest.mark.django_db
+def test_confirm_order_cancellation_expired_token(cancellation_event: Event):
+ """
+ A confirmation link can only be used for a limited time after it was
+ requested, even if the order remains cancellable. A new link can always
+ be requested.
+ """
+ event = cancellation_event
+ order_id = _make_order(event, datetime.now(UTC), {}, status=PaymentStatus.PAID)
+
+ RequestOrderCancellation.mutate(None, None, _request_input(event, order_id))
+ token = OrderCancellationToken.objects.get(event=event, order_id=order_id, state="valid")
+ OrderCancellationToken.objects.filter(pk=token.pk).update(created_at=datetime.now(UTC) - timedelta(hours=25))
+
+ with pytest.raises(ValueError):
+ ConfirmOrderCancellation.mutate(None, None, _confirm_input(event, order_id, token.code))
+
+ assert _get_order(event, order_id).status == PaymentStatus.PAID
diff --git a/kompassi/tickets_v2/utils/mail.py b/kompassi/tickets_v2/utils/mail.py
new file mode 100644
index 000000000..f286332ec
--- /dev/null
+++ b/kompassi/tickets_v2/utils/mail.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from django.conf import settings
+
+from kompassi.graphql_api.language import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGE_CODES
+
+if TYPE_CHECKING:
+ from kompassi.core.models.event import Event
+
+MAIL_DOMAIN = settings.DEFAULT_FROM_EMAIL.split("@", 1)[1].rstrip(">")
+
+
+def tickets_from_email(event: Event) -> str:
+ """
+ The sender address for emails sent to ticket shop customers of an event.
+ """
+ return f"{event.name} ({settings.KOMPASSI_INSTALLATION_NAME}) <{event.slug}-tickets@{MAIL_DOMAIN}>"
+
+
+def email_template_language(language: str) -> str:
+ """
+ The language of the .eml templates to use for a customer who speaks `language`.
+
+ TODO Missing Swedish message templates. When they are added, update all
+ users of this function at once.
+ """
+ language = language.lower()
+
+ if language == "sv" or language not in SUPPORTED_LANGUAGE_CODES:
+ return DEFAULT_LANGUAGE
+
+ return language
From 0f32c4f258355600c6d9165cc37b461627d46ced Mon Sep 17 00:00:00 2001
From: Luka Pajukanta
Date: Sat, 13 Jun 2026 00:33:59 +0300
Subject: [PATCH 03/10] chore: prettier
---
.../src/components/dimensions/DimensionFilters.tsx | 2 +-
.../src/components/dimensions/DimensionValueSelectionForm.tsx | 2 +-
.../src/components/google-material-symbols/README.md | 1 +
3 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/kompassi-v2-frontend/src/components/dimensions/DimensionFilters.tsx b/kompassi-v2-frontend/src/components/dimensions/DimensionFilters.tsx
index 5c3a57dc6..419357592 100644
--- a/kompassi-v2-frontend/src/components/dimensions/DimensionFilters.tsx
+++ b/kompassi-v2-frontend/src/components/dimensions/DimensionFilters.tsx
@@ -43,7 +43,7 @@ type Props = PropsWithoutProgramFilters | PropsWithProgramFilters;
export function DimensionFilters(props: Props) {
const { dimensions, programFilters, search, messages } = props;
const searchParams = useSearchParams();
- const searchTerm = search ? (searchParams.get("search") ?? "") : "";
+ const searchTerm = search ? searchParams.get("search") ?? "" : "";
const { replace } = useRouter();
const onChange = useCallback(
diff --git a/kompassi-v2-frontend/src/components/dimensions/DimensionValueSelectionForm.tsx b/kompassi-v2-frontend/src/components/dimensions/DimensionValueSelectionForm.tsx
index 8e48d206d..56c100da1 100644
--- a/kompassi-v2-frontend/src/components/dimensions/DimensionValueSelectionForm.tsx
+++ b/kompassi-v2-frontend/src/components/dimensions/DimensionValueSelectionForm.tsx
@@ -55,7 +55,7 @@ export function buildDimensionField(
type = "MultiSelect";
}
- const value = type === "SingleSelect" ? (valueList[0] ?? "") : valueList;
+ const value = type === "SingleSelect" ? valueList[0] ?? "" : valueList;
const readOnly = technicalDimensions === "readonly" && dimension.isTechnical;
const title = dimension.title || dimension.slug;
diff --git a/kompassi-v2-frontend/src/components/google-material-symbols/README.md b/kompassi-v2-frontend/src/components/google-material-symbols/README.md
index f541006fc..cfa4266db 100644
--- a/kompassi-v2-frontend/src/components/google-material-symbols/README.md
+++ b/kompassi-v2-frontend/src/components/google-material-symbols/README.md
@@ -8,6 +8,7 @@ Current approach is as follows:
1. Find a symbol you want in the [icon search](https://fonts.google.com/icons)
2. Configure the following settings:
+
- **Weight**: 400
- **Grade**: 0
- **Style**: Material Symbols (New), Outlined
From dd385d6162c0936cc4d848ec2f8706aa16bc6add Mon Sep 17 00:00:00 2001
From: Luka Pajukanta
Date: Sat, 13 Jun 2026 10:31:22 +0300
Subject: [PATCH 04/10] fix(tickets_v2): cancellation-safe single-flight for
optimized server event cache
The TTL added for self-service cancellation makes the optimized server's
event cache refresh on a hot path under high concurrency. The pre-existing
shared-Future single-flight was not cancellation-safe: a single client
disconnect would cancel the shared refresh task (failing every other
in-flight request), and the per-awaiter `finally` could release the lock
mid-refresh and spawn a duplicate refresh on a borrowed connection.
Replace it with double-checked locking on an asyncio.Lock (one per worker
process). Each refresh uses the lock-holder's own live connection; a
cancelled waiter just drops out of the queue, and a cancelled holder
cleanly releases the lock for the next request to retry. Build the cache
dict and swap it in atomically to avoid torn reads mid-refresh.
Add two async regression tests (no DB / no pytest-asyncio) covering
single-flight under load and refresh survival across waiter cancellation.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../optimized_server/models/event.py | 46 +++++-----
kompassi/tickets_v2/tests.py | 89 +++++++++++++++++++
2 files changed, 113 insertions(+), 22 deletions(-)
diff --git a/kompassi/tickets_v2/optimized_server/models/event.py b/kompassi/tickets_v2/optimized_server/models/event.py
index 255448ac1..8ca525432 100644
--- a/kompassi/tickets_v2/optimized_server/models/event.py
+++ b/kompassi/tickets_v2/optimized_server/models/event.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import re
-from asyncio import Future, ensure_future
+from asyncio import Lock
from datetime import UTC, datetime, timedelta
from functools import cached_property
from pathlib import Path
@@ -43,9 +43,13 @@ class Event(pydantic.BaseModel):
start_time: datetime | None
cache: ClassVar[dict[str | int, Event]] = {}
- cache_refresh: ClassVar[Future[dict[str | int, Event]] | None] = None
cache_refreshed_at: ClassVar[datetime | None] = None
+ # Serializes cache refreshes within a single worker process (one event loop).
+ # The optimized server handles hundreds of concurrent requests, so without this
+ # an expired cache would let every in-flight request kick off its own refresh.
+ cache_lock: ClassVar[Lock] = Lock()
+
# The admin can change settings that affect eg. customer cancellation eligibility
# at any time, so the cache needs to expire on its own.
cache_ttl: ClassVar[timedelta] = timedelta(minutes=5)
@@ -53,41 +57,39 @@ class Event(pydantic.BaseModel):
query: ClassVar[bytes] = (Path(__file__).parent / "sql" / "get_events.sql").read_bytes()
@classmethod
- async def get(cls, db: AsyncConnection, slug: str) -> Event | None:
- cache_is_fresh = (
- cls.cache_refreshed_at is not None and datetime.now(UTC) - cls.cache_refreshed_at < cls.cache_ttl
- )
- if slug not in cls.cache or not cache_is_fresh:
- cls.cache = await cls._refresh_cache(db)
-
- return cls.cache.get(slug)
+ def _cache_is_fresh(cls) -> bool:
+ return cls.cache_refreshed_at is not None and datetime.now(UTC) - cls.cache_refreshed_at < cls.cache_ttl
@classmethod
- async def _refresh_cache(cls, db: AsyncConnection) -> dict[str | int, Event]:
- """
- Ensure only one refresh is running at a time.
- """
- if cls.cache_refresh is None:
- cls.cache_refresh = ensure_future(cls._do_refresh_cache(db))
+ async def get(cls, db: AsyncConnection, slug: str) -> Event | None:
+ if cls._cache_is_fresh() and slug in cls.cache:
+ return cls.cache.get(slug)
- try:
- return await cls.cache_refresh
- finally:
- cls.cache_refresh = None
+ async with cls.cache_lock:
+ # Another request may have refreshed the cache while we waited for the lock.
+ # Re-check under the lock so only the first arrival actually hits the database;
+ # the rest fall straight through to the fresh cache.
+ if not (cls._cache_is_fresh() and slug in cls.cache):
+ await cls._do_refresh_cache(db)
+
+ return cls.cache.get(slug)
@classmethod
async def _do_refresh_cache(cls, db: AsyncConnection):
"""
Actually refresh the cache.
"""
+ cache: dict[str | int, Event] = {}
async with db.cursor() as cursor:
await cursor.execute(cls.query)
- cls.cache.clear()
async for row in cursor:
event = cls(**dict(zip(cls.model_fields, row, strict=True))) # type: ignore
- cls.cache[event.slug] = cls.cache[event.id] = event
+ cache[event.slug] = cache[event.id] = event
+ # Swap in the fully-built cache atomically so concurrent readers never observe
+ # a half-populated dict mid-refresh.
+ cls.cache = cache
cls.cache_refreshed_at = datetime.now(UTC)
return cls.cache
diff --git a/kompassi/tickets_v2/tests.py b/kompassi/tickets_v2/tests.py
index c878e6d5a..19dea8170 100644
--- a/kompassi/tickets_v2/tests.py
+++ b/kompassi/tickets_v2/tests.py
@@ -837,3 +837,92 @@ def test_confirm_order_cancellation_expired_token(cancellation_event: Event):
ConfirmOrderCancellation.mutate(None, None, _confirm_input(event, order_id, token.code))
assert _get_order(event, order_id).status == PaymentStatus.PAID
+
+
+def test_optimized_server_event_cache_single_flight():
+ """
+ The optimized server serves hundreds of requests concurrently on a single event
+ loop. When the event cache TTL expires, only the first request that notices should
+ refresh the cache from the database; the rest must await that one refresh rather
+ than each kicking off its own (a cache stampede).
+ """
+ import asyncio
+ from unittest.mock import patch
+
+ from kompassi.tickets_v2.optimized_server.models.event import Event as OptimizedEvent
+
+ async def scenario():
+ refresh_calls = 0
+
+ async def fake_refresh(db):
+ nonlocal refresh_calls
+ refresh_calls += 1
+ # Yield control to let every other queued request pile up while this one
+ # is "hitting the database"; a broken lock would let them refresh too.
+ await asyncio.sleep(0.05)
+ OptimizedEvent.cache = {"tracon": object()}
+ OptimizedEvent.cache_refreshed_at = datetime.now(UTC)
+ return OptimizedEvent.cache
+
+ # Start from an empty, never-refreshed cache and a lock bound to this loop.
+ OptimizedEvent.cache = {}
+ OptimizedEvent.cache_refreshed_at = None
+ OptimizedEvent.cache_lock = asyncio.Lock()
+
+ with patch.object(OptimizedEvent, "_do_refresh_cache", staticmethod(fake_refresh)):
+ results = await asyncio.gather(*(OptimizedEvent.get(None, "tracon") for _ in range(200)))
+
+ return refresh_calls, results
+
+ refresh_calls, results = asyncio.run(scenario())
+
+ assert refresh_calls == 1
+ assert all(result is not None for result in results)
+
+
+def test_optimized_server_event_cache_refresh_survives_waiter_cancellation():
+ """
+ A request that gives up while waiting for an in-flight cache refresh (e.g. the
+ client disconnected, cancelling the asyncio task) must not abort that refresh for
+ the requests still waiting on it, nor cause a duplicate refresh. The earlier
+ shared-Future approach broke here: cancelling any waiter cancelled the single
+ shared refresh task, failing every other in-flight request.
+ """
+ import asyncio
+ from unittest.mock import patch
+
+ from kompassi.tickets_v2.optimized_server.models.event import Event as OptimizedEvent
+
+ async def scenario():
+ refresh_calls = 0
+
+ async def fake_refresh(db):
+ nonlocal refresh_calls
+ refresh_calls += 1
+ await asyncio.sleep(0.05)
+ OptimizedEvent.cache = {"tracon": object()}
+ OptimizedEvent.cache_refreshed_at = datetime.now(UTC)
+ return OptimizedEvent.cache
+
+ OptimizedEvent.cache = {}
+ OptimizedEvent.cache_refreshed_at = None
+ OptimizedEvent.cache_lock = asyncio.Lock()
+
+ with patch.object(OptimizedEvent, "_do_refresh_cache", staticmethod(fake_refresh)):
+ tasks = [asyncio.ensure_future(OptimizedEvent.get(None, "tracon")) for _ in range(50)]
+ # Let the first task acquire the lock and start refreshing while the rest queue up.
+ await asyncio.sleep(0.01)
+ # The latter half of the waiters "disconnect" and give up mid-refresh.
+ for task in tasks[25:]:
+ task.cancel()
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+
+ return refresh_calls, results
+
+ refresh_calls, results = asyncio.run(scenario())
+
+ # Exactly one refresh, despite half the waiters bailing out.
+ assert refresh_calls == 1
+ # The waiters that did not cancel still got a valid result.
+ survivors = results[:25]
+ assert all(not isinstance(result, BaseException) and result is not None for result in survivors)
From 2907429f29f3b6fb0c1fbd1cb5eceebfb23dfbc7 Mon Sep 17 00:00:00 2001
From: Luka Pajukanta
Date: Sat, 13 Jun 2026 10:51:02 +0300
Subject: [PATCH 05/10] refactor(tickets_v2): replace hand-rolled event cache
lock with async-lru
Adopt async-lru (alru_cache) for the optimized server's event cache instead
of the hand-rolled asyncio.Lock single-flight. The library gives us TTL expiry
plus single-flight within a worker process, and crucially is cancellation-safe:
a waiter giving up (client disconnect) does not abort the shared load nor
trigger a duplicate, which our earlier shared-Future approach got wrong.
The cached loader takes no arguments (alru_cache keys on args, and a per-request
pooled connection would be a different object every call), so it owns its own
pooled connection via get_connection_pool(). This also decouples the refresh
from any single request's connection lifecycle. Event.get() drops its now-unused
db parameter accordingly.
Port the two single-flight regression tests to drive the alru loader, including
the waiter-cancellation guard.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
kompassi/tickets_v2/optimized_server/app.py | 4 +-
kompassi/tickets_v2/optimized_server/db.py | 6 +
.../optimized_server/models/event.py | 79 ++++++-------
kompassi/tickets_v2/tests.py | 89 +++++++-------
pyproject.toml | 1 +
uv.lock | 110 ++++++++++--------
6 files changed, 146 insertions(+), 143 deletions(-)
diff --git a/kompassi/tickets_v2/optimized_server/app.py b/kompassi/tickets_v2/optimized_server/app.py
index 068b029d7..0211902f5 100644
--- a/kompassi/tickets_v2/optimized_server/app.py
+++ b/kompassi/tickets_v2/optimized_server/app.py
@@ -26,8 +26,8 @@
API_KEY = os.getenv("KOMPASSI_TICKETS_V2_API_KEY")
-async def _event(event_slug: EventSlug, db: DB) -> Event:
- event = await Event.get(db, event_slug)
+async def _event(event_slug: EventSlug) -> Event:
+ event = await Event.get(event_slug)
if event is None:
raise HTTPException(status_code=400, detail="EVENT_NOT_FOUND")
return event
diff --git a/kompassi/tickets_v2/optimized_server/db.py b/kompassi/tickets_v2/optimized_server/db.py
index 16ccd8937..698a45fdb 100644
--- a/kompassi/tickets_v2/optimized_server/db.py
+++ b/kompassi/tickets_v2/optimized_server/db.py
@@ -31,6 +31,12 @@ async def lifespan(app: FastAPI):
_pool = None
+def get_connection_pool() -> AsyncConnectionPool:
+ if _pool is None:
+ raise RuntimeError("connection pool not initialised (lifespan not entered)")
+ return _pool
+
+
async def db() -> AsyncIterator[AsyncConnection]:
async with _pool.connection() as conn: # type: ignore
yield conn
diff --git a/kompassi/tickets_v2/optimized_server/models/event.py b/kompassi/tickets_v2/optimized_server/models/event.py
index 8ca525432..c0b8291f8 100644
--- a/kompassi/tickets_v2/optimized_server/models/event.py
+++ b/kompassi/tickets_v2/optimized_server/models/event.py
@@ -1,23 +1,24 @@
from __future__ import annotations
import re
-from asyncio import Lock
-from datetime import UTC, datetime, timedelta
+from datetime import datetime
from functools import cached_property
from pathlib import Path
-from typing import TYPE_CHECKING, Any, ClassVar
+from typing import Any, ClassVar
import pydantic
+from async_lru import alru_cache
from .enums import PaymentProvider
-if TYPE_CHECKING:
- from psycopg import AsyncConnection
-
# Keep in sync with kompassi.core.models.contact_email_mixin.CONTACT_EMAIL_RE
# (not imported to keep the optimized server free of Django imports)
CONTACT_EMAIL_RE = re.compile(r"(?P.+) <(?P.+@.+\..+)>")
+# The admin can change settings that affect eg. customer cancellation eligibility
+# at any time, so the cache needs to expire on its own.
+CACHE_TTL_SECONDS = 5 * 60
+
class Event(pydantic.BaseModel):
id: int
@@ -42,57 +43,45 @@ class Event(pydantic.BaseModel):
cancellation_period_days: int
start_time: datetime | None
- cache: ClassVar[dict[str | int, Event]] = {}
- cache_refreshed_at: ClassVar[datetime | None] = None
-
- # Serializes cache refreshes within a single worker process (one event loop).
- # The optimized server handles hundreds of concurrent requests, so without this
- # an expired cache would let every in-flight request kick off its own refresh.
- cache_lock: ClassVar[Lock] = Lock()
-
- # The admin can change settings that affect eg. customer cancellation eligibility
- # at any time, so the cache needs to expire on its own.
- cache_ttl: ClassVar[timedelta] = timedelta(minutes=5)
-
query: ClassVar[bytes] = (Path(__file__).parent / "sql" / "get_events.sql").read_bytes()
@classmethod
- def _cache_is_fresh(cls) -> bool:
- return cls.cache_refreshed_at is not None and datetime.now(UTC) - cls.cache_refreshed_at < cls.cache_ttl
+ async def get(cls, slug: str) -> Event | None:
+ events = await cls._load_all()
+ return events.get(slug)
- @classmethod
- async def get(cls, db: AsyncConnection, slug: str) -> Event | None:
- if cls._cache_is_fresh() and slug in cls.cache:
- return cls.cache.get(slug)
+ @staticmethod
+ @alru_cache(maxsize=1, ttl=CACHE_TTL_SECONDS)
+ async def _load_all() -> dict[str | int, Event]:
+ """
+ Load all events into a slug/id-keyed dict, cached for CACHE_TTL_SECONDS.
- async with cls.cache_lock:
- # Another request may have refreshed the cache while we waited for the lock.
- # Re-check under the lock so only the first arrival actually hits the database;
- # the rest fall straight through to the fresh cache.
- if not (cls._cache_is_fresh() and slug in cls.cache):
- await cls._do_refresh_cache(db)
+ alru_cache gives us both TTL expiry and single-flight within a worker process:
+ when the entry is missing or expired, concurrent callers coalesce into one
+ execution and the rest await its result (a cancelled waiter does not abort it).
- return cls.cache.get(slug)
+ It takes no arguments on purpose: alru_cache keys on call arguments, and a
+ per-request pooled connection would be a different object every call, so the
+ cache would never hit. The loader owns its own pooled connection instead
+ (which also keeps the refresh independent of any single request's lifecycle).
- @classmethod
- async def _do_refresh_cache(cls, db: AsyncConnection):
- """
- Actually refresh the cache.
+ The DB work lives in _do_load() so tests can stub it while still exercising
+ the real caching/single-flight behaviour through this wrapper.
"""
- cache: dict[str | int, Event] = {}
- async with db.cursor() as cursor:
- await cursor.execute(cls.query)
+ return await Event._do_load()
+ @staticmethod
+ async def _do_load() -> dict[str | int, Event]:
+ from ..db import get_connection_pool
+
+ cache: dict[str | int, Event] = {}
+ async with get_connection_pool().connection() as conn, conn.cursor() as cursor:
+ await cursor.execute(Event.query)
async for row in cursor:
- event = cls(**dict(zip(cls.model_fields, row, strict=True))) # type: ignore
+ event = Event(**dict(zip(Event.model_fields, row, strict=True))) # type: ignore
cache[event.slug] = cache[event.id] = event
- # Swap in the fully-built cache atomically so concurrent readers never observe
- # a half-populated dict mid-refresh.
- cls.cache = cache
- cls.cache_refreshed_at = datetime.now(UTC)
-
- return cls.cache
+ return cache
def model_dump(self, *args, **kwargs) -> Any:
raise NotImplementedError("contains secrets, please don't")
diff --git a/kompassi/tickets_v2/tests.py b/kompassi/tickets_v2/tests.py
index 19dea8170..1720f9e16 100644
--- a/kompassi/tickets_v2/tests.py
+++ b/kompassi/tickets_v2/tests.py
@@ -839,12 +839,21 @@ def test_confirm_order_cancellation_expired_token(cancellation_event: Event):
assert _get_order(event, order_id).status == PaymentStatus.PAID
+# The optimized server's event cache is an async-lru (alru_cache) wrapped loader.
+# These tests stub the DB loader (_do_load) and drive the cache through Event.get(),
+# so they exercise alru_cache's real TTL/single-flight machinery. alru_cache binds to
+# the running event loop, so asyncio.run()'s throwaway loop triggers a (harmless)
+# AlruCacheLoopResetWarning we silence here.
+_ALRU_LOOP_RESET_WARNING = "ignore:alru_cache detected event loop change"
+
+
+@pytest.mark.filterwarnings(_ALRU_LOOP_RESET_WARNING)
def test_optimized_server_event_cache_single_flight():
"""
The optimized server serves hundreds of requests concurrently on a single event
- loop. When the event cache TTL expires, only the first request that notices should
- refresh the cache from the database; the rest must await that one refresh rather
- than each kicking off its own (a cache stampede).
+ loop. When the event cache is cold or its TTL has expired, only one request should
+ load it from the database; the rest must coalesce onto that single load rather than
+ each kicking off its own (a cache stampede).
"""
import asyncio
from unittest.mock import patch
@@ -852,41 +861,36 @@ def test_optimized_server_event_cache_single_flight():
from kompassi.tickets_v2.optimized_server.models.event import Event as OptimizedEvent
async def scenario():
- refresh_calls = 0
+ load_calls = 0
- async def fake_refresh(db):
- nonlocal refresh_calls
- refresh_calls += 1
+ async def fake_load():
+ nonlocal load_calls
+ load_calls += 1
# Yield control to let every other queued request pile up while this one
- # is "hitting the database"; a broken lock would let them refresh too.
+ # is "hitting the database"; without single-flight they would each load too.
await asyncio.sleep(0.05)
- OptimizedEvent.cache = {"tracon": object()}
- OptimizedEvent.cache_refreshed_at = datetime.now(UTC)
- return OptimizedEvent.cache
+ return {"tracon": object()}
- # Start from an empty, never-refreshed cache and a lock bound to this loop.
- OptimizedEvent.cache = {}
- OptimizedEvent.cache_refreshed_at = None
- OptimizedEvent.cache_lock = asyncio.Lock()
+ OptimizedEvent._load_all.cache_clear() # type: ignore[attr-defined] # alru_cache wrapper
+ with patch.object(OptimizedEvent, "_do_load", staticmethod(fake_load)):
+ results = await asyncio.gather(*(OptimizedEvent.get("tracon") for _ in range(200)))
- with patch.object(OptimizedEvent, "_do_refresh_cache", staticmethod(fake_refresh)):
- results = await asyncio.gather(*(OptimizedEvent.get(None, "tracon") for _ in range(200)))
+ return load_calls, results
- return refresh_calls, results
+ load_calls, results = asyncio.run(scenario())
- refresh_calls, results = asyncio.run(scenario())
-
- assert refresh_calls == 1
+ assert load_calls == 1
assert all(result is not None for result in results)
+@pytest.mark.filterwarnings(_ALRU_LOOP_RESET_WARNING)
def test_optimized_server_event_cache_refresh_survives_waiter_cancellation():
"""
- A request that gives up while waiting for an in-flight cache refresh (e.g. the
- client disconnected, cancelling the asyncio task) must not abort that refresh for
- the requests still waiting on it, nor cause a duplicate refresh. The earlier
- shared-Future approach broke here: cancelling any waiter cancelled the single
- shared refresh task, failing every other in-flight request.
+ A request that gives up while waiting for an in-flight cache load (e.g. the client
+ disconnected, cancelling the asyncio task) must not abort that load for the requests
+ still waiting on it, nor cause a duplicate load. An earlier hand-rolled shared-Future
+ approach broke here: cancelling any waiter cancelled the single shared task, failing
+ every other in-flight request. This guards that alru_cache does not regress that.
"""
import asyncio
from unittest.mock import patch
@@ -894,35 +898,30 @@ def test_optimized_server_event_cache_refresh_survives_waiter_cancellation():
from kompassi.tickets_v2.optimized_server.models.event import Event as OptimizedEvent
async def scenario():
- refresh_calls = 0
+ load_calls = 0
- async def fake_refresh(db):
- nonlocal refresh_calls
- refresh_calls += 1
+ async def fake_load():
+ nonlocal load_calls
+ load_calls += 1
await asyncio.sleep(0.05)
- OptimizedEvent.cache = {"tracon": object()}
- OptimizedEvent.cache_refreshed_at = datetime.now(UTC)
- return OptimizedEvent.cache
-
- OptimizedEvent.cache = {}
- OptimizedEvent.cache_refreshed_at = None
- OptimizedEvent.cache_lock = asyncio.Lock()
+ return {"tracon": object()}
- with patch.object(OptimizedEvent, "_do_refresh_cache", staticmethod(fake_refresh)):
- tasks = [asyncio.ensure_future(OptimizedEvent.get(None, "tracon")) for _ in range(50)]
- # Let the first task acquire the lock and start refreshing while the rest queue up.
+ OptimizedEvent._load_all.cache_clear() # type: ignore[attr-defined] # alru_cache wrapper
+ with patch.object(OptimizedEvent, "_do_load", staticmethod(fake_load)):
+ tasks = [asyncio.ensure_future(OptimizedEvent.get("tracon")) for _ in range(50)]
+ # Let the first task start the load while the rest coalesce behind it.
await asyncio.sleep(0.01)
- # The latter half of the waiters "disconnect" and give up mid-refresh.
+ # The latter half of the waiters "disconnect" and give up mid-load.
for task in tasks[25:]:
task.cancel()
results = await asyncio.gather(*tasks, return_exceptions=True)
- return refresh_calls, results
+ return load_calls, results
- refresh_calls, results = asyncio.run(scenario())
+ load_calls, results = asyncio.run(scenario())
- # Exactly one refresh, despite half the waiters bailing out.
- assert refresh_calls == 1
+ # Exactly one load, despite half the waiters bailing out.
+ assert load_calls == 1
# The waiters that did not cancel still got a valid result.
survivors = results[:25]
assert all(not isinstance(result, BaseException) and result is not None for result in survivors)
diff --git a/pyproject.toml b/pyproject.toml
index 6889b9efe..ad0af5b96 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,6 +63,7 @@ dependencies = [
"XlsxWriter",
"zxcvbn",
"django-enum>=2.2.3",
+ "async-lru>=2.3.0",
]
[tool.uv]
diff --git a/uv.lock b/uv.lock
index a5d5f328d..f7b2c1477 100644
--- a/uv.lock
+++ b/uv.lock
@@ -219,6 +219,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
]
+[[package]]
+name = "async-lru"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" },
+]
+
[[package]]
name = "attrs"
version = "26.1.0"
@@ -323,30 +332,30 @@ wheels = [
[[package]]
name = "boto3"
-version = "1.43.28"
+version = "1.43.29"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/4e/f2/a976b2a81d8dc7ff675f4b614367a185727061130184a28da0f53f446b97/boto3-1.43.28.tar.gz", hash = "sha256:8391fdcc4d8e1d4e0bf96575a7e5610964a4d401dafa4dccb0a5bade8dd3fbb0", size = 113202, upload-time = "2026-06-11T19:29:01.464Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/a6/9c02ff00d08ea87908934351d244e35bb6fb5cbc169e1a14fc5bd80d124b/boto3-1.43.29.tar.gz", hash = "sha256:354006c512cdb87ef8214a095f2ade961c8145734475cd7a7e6b39260ff5494a", size = 113198, upload-time = "2026-06-12T19:32:23.442Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c3/a5/47db150ea6380f11569b87d3ad064e3c929e5abe227a549d472fab6f5f3a/boto3-1.43.28-py3-none-any.whl", hash = "sha256:4fe6df2163aea02b561eca0d685e2f41a059d71f03721a3e79c3b522e79a3b56", size = 140536, upload-time = "2026-06-11T19:29:00.143Z" },
+ { url = "https://files.pythonhosted.org/packages/07/5c/f12a9978526c7068c873ccf9788161fc6af338c6a025f1354a46134a6e46/boto3-1.43.29-py3-none-any.whl", hash = "sha256:77c27ada27cdbf619a3bbc41fa9e991caef818d3a2988cf92ea722e107d90108", size = 140537, upload-time = "2026-06-12T19:32:21.682Z" },
]
[[package]]
name = "botocore"
-version = "1.43.28"
+version = "1.43.29"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/02/dc/1b01808003f88f8a328732c979f20cb0456791048b4440fc4abcae08c1a0/botocore-1.43.28.tar.gz", hash = "sha256:9bbad501a68e4ffdbeff76a382507f5d7827abc316f34a218ab76f5293e6c78d", size = 15503514, upload-time = "2026-06-11T19:28:50.989Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/54/df99c5ca5c9ef275e34b87e177782e3ca054fc35f1f462c40fe180936c81/botocore-1.43.29.tar.gz", hash = "sha256:dce39d33b707aa162aa3820975f99d7f8f746d46576169fb42ce4f2b3b56b261", size = 15512384, upload-time = "2026-06-12T19:32:13.754Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fa/8c/14916c353ce8a29d14cf6308c2bef842bbb25dde6defc620e26e28063331/botocore-1.43.28-py3-none-any.whl", hash = "sha256:8147adea89b4c9324e842cd8c01ea1a0e17c92cb6ebeaa8cb774f821cb5a7629", size = 15188401, upload-time = "2026-06-11T19:28:47.244Z" },
+ { url = "https://files.pythonhosted.org/packages/91/b1/aa410c22355f8f6c4ac2433db6a1c557dd959acf2953ccae4bfc37488119/botocore-1.43.29-py3-none-any.whl", hash = "sha256:5d62f2a03ed279a50207ca2824e009313df15f082b6bb591a095a4f04c7faef3", size = 15194135, upload-time = "2026-06-12T19:32:09.463Z" },
]
[[package]]
@@ -573,55 +582,52 @@ wheels = [
[[package]]
name = "cryptography"
-version = "48.0.1"
+version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
- { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
- { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
- { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
- { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
- { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
- { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
- { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
- { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
- { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
- { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
- { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
- { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
- { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
- { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" },
- { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
- { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
- { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
- { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" },
- { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
- { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
- { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
- { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" },
- { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
- { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
- { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
- { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" },
- { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" },
- { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
- { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
- { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
- { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
- { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
- { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
- { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
- { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
- { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
- { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
- { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
- { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
- { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
- { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
+ { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
+ { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
+ { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
+ { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
+ { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
+ { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
+ { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
+ { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
+ { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
+ { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
+ { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
+ { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
+ { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
+ { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
]
[[package]]
@@ -1365,6 +1371,7 @@ version = "2.0.0"
source = { virtual = "." }
dependencies = [
{ name = "aiohttp", extra = ["speedups"] },
+ { name = "async-lru" },
{ name = "babel" },
{ name = "bleach" },
{ name = "boto3" },
@@ -1427,6 +1434,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", extras = ["speedups"] },
+ { name = "async-lru", specifier = ">=2.3.0" },
{ name = "babel" },
{ name = "bleach" },
{ name = "boto3" },
From 3d5759b3a4dcf698bb9961f067fbb91453039ee3 Mon Sep 17 00:00:00 2001
From: Luka Pajukanta
Date: Sat, 13 Jun 2026 11:08:31 +0300
Subject: [PATCH 06/10] perf(tickets_v2): disable Next.js prefetch on
self-service cancellation link
Only a small fraction of visitors ever open the self-service cancellation page,
so Next.js prefetching it from every order view (public and profile) is wasteful.
Opt out with prefetch={false}.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../src/components/tickets/RequestCancellationSection.tsx | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/kompassi-v2-frontend/src/components/tickets/RequestCancellationSection.tsx b/kompassi-v2-frontend/src/components/tickets/RequestCancellationSection.tsx
index c82f51948..405a7b282 100644
--- a/kompassi-v2-frontend/src/components/tickets/RequestCancellationSection.tsx
+++ b/kompassi-v2-frontend/src/components/tickets/RequestCancellationSection.tsx
@@ -25,7 +25,13 @@ export default function RequestCancellationSection({
if (order.canRequestCancellation) {
return (
-
+ {/* prefetch disabled: only a small fraction of visitors ever open the
+ cancellation page, so prefetching it from every order view is wasteful. */}
+
{t.title}…
From 77f5f4e3db999a9036f4d44f13666ebfa6d6a626 Mon Sep 17 00:00:00 2001
From: Luka Pajukanta
Date: Sat, 13 Jun 2026 11:24:55 +0300
Subject: [PATCH 07/10] feat(tickets_v2): disable resend on cancellation page
after link sent
Once a cancellation link has been requested (?success=cancellationRequested),
re-requesting immediately is throttled by design. Disable the submit button in
that state, switch it to a muted secondary style, and relabel it to say the link
was sent so it is obvious why it is inactive.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../orders/[orderId]/cancel/page.tsx | 18 ++++++++++++++++--
kompassi-v2-frontend/src/translations/en.tsx | 1 +
kompassi-v2-frontend/src/translations/fi.tsx | 1 +
kompassi-v2-frontend/src/translations/sv.tsx | 1 +
4 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/orders/[orderId]/cancel/page.tsx b/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/orders/[orderId]/cancel/page.tsx
index cacda7e76..6478774c7 100644
--- a/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/orders/[orderId]/cancel/page.tsx
+++ b/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/orders/[orderId]/cancel/page.tsx
@@ -25,6 +25,11 @@ export default async function OrderCancellationPage(props: Props) {
const t = translations.Tickets.Order;
const cancelT = t.cancelPage;
+ // A link was just sent. Requesting another one immediately is throttled by
+ // design, so disable the button to avoid an inevitable failure.
+ const cancellationRequested =
+ searchParams.success === "cancellationRequested";
+
return (