From 12e7eb8413a25f5380253f7f9ef346d20da1a11e Mon Sep 17 00:00:00 2001 From: Santtu Pajukanta Date: Sun, 17 May 2026 22:01:12 +0300 Subject: [PATCH] feat(involvement): Freeze shirt sizes after ordering and add involvement preferences --- kompassi-v2-frontend/package-lock.json | 25 +++- kompassi-v2-frontend/src/__generated__/gql.ts | 12 ++ .../src/__generated__/graphql.ts | 34 +++++ .../involvement-preferences/actions.ts | 43 ++++++ .../involvement-preferences/page.tsx | 128 ++++++++++++++++++ .../involvement/InvolvementAdminTabs.tsx | 7 +- kompassi-v2-frontend/src/translations/en.tsx | 9 ++ kompassi-v2-frontend/src/translations/fi.tsx | 9 ++ kompassi-v2-frontend/src/translations/sv.tsx | 11 ++ .../badges_admin_onboarding_view.pug | 1 - kompassi/graphql_api/schema.py | 2 + kompassi/involvement/emperkelators/base.py | 44 +++++- .../involvement/emperkelators/desucon2026.py | 19 ++- .../involvement/emperkelators/tracon2025.py | 7 +- .../update_involvement_preferences.py | 43 ++++++ ...9_involvementeventmeta_shirts_frozen_at.py | 21 +++ kompassi/involvement/models/meta.py | 18 +++ 17 files changed, 419 insertions(+), 14 deletions(-) create mode 100644 kompassi-v2-frontend/src/app/[locale]/[eventSlug]/involvement-preferences/actions.ts create mode 100644 kompassi-v2-frontend/src/app/[locale]/[eventSlug]/involvement-preferences/page.tsx create mode 100644 kompassi/involvement/graphql/mutations/update_involvement_preferences.py create mode 100644 kompassi/involvement/migrations/0009_involvementeventmeta_shirts_frozen_at.py diff --git a/kompassi-v2-frontend/package-lock.json b/kompassi-v2-frontend/package-lock.json index 65610a006..0356d7387 100644 --- a/kompassi-v2-frontend/package-lock.json +++ b/kompassi-v2-frontend/package-lock.json @@ -2802,7 +2802,7 @@ "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2841,6 +2841,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2861,6 +2862,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2881,6 +2883,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2901,6 +2904,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2921,6 +2925,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2941,6 +2946,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2961,6 +2967,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2981,6 +2988,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3001,6 +3009,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3021,6 +3030,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3041,6 +3051,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3061,6 +3072,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3081,6 +3093,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3098,7 +3111,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -7191,7 +7204,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7246,7 +7259,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -8277,7 +8290,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/node-domexception": { @@ -10840,7 +10853,7 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/kompassi-v2-frontend/src/__generated__/gql.ts b/kompassi-v2-frontend/src/__generated__/gql.ts index a8b20a7a4..0919b9a90 100644 --- a/kompassi-v2-frontend/src/__generated__/gql.ts +++ b/kompassi-v2-frontend/src/__generated__/gql.ts @@ -26,6 +26,8 @@ type Documents = { "\n mutation PutInvolvementDimensionValue($input: PutDimensionValueInput!) {\n putDimensionValue(input: $input) {\n value {\n slug\n }\n }\n }\n": typeof types.PutInvolvementDimensionValueDocument, "\n mutation DeleteInvolvementDimensionValue($input: DeleteDimensionValueInput!) {\n deleteDimensionValue(input: $input) {\n slug\n }\n }\n": typeof types.DeleteInvolvementDimensionValueDocument, "\n query InvolvementDimensionsList($eventSlug: String!, $locale: String!) {\n event(slug: $eventSlug) {\n name\n slug\n\n involvement {\n dimensions(publicOnly: false) {\n ...DimensionEditor\n }\n }\n }\n }\n": typeof types.InvolvementDimensionsListDocument, + "\n mutation UpdateInvolvementPreferences(\n $input: UpdateInvolvementPreferencesInput!\n ) {\n updateInvolvementPreferences(input: $input) {\n preferences {\n shirtsFrozenAt\n }\n }\n }\n": typeof types.UpdateInvolvementPreferencesDocument, + "\n query InvolvementPreferences($eventSlug: String!) {\n event(slug: $eventSlug) {\n name\n slug\n\n involvement {\n shirtsFrozenAt\n }\n }\n }\n": typeof types.InvolvementPreferencesDocument, "\n query InvolvementAdminReportsPage($eventSlug: String!, $locale: String) {\n event(slug: $eventSlug) {\n name\n slug\n timezone\n\n involvement {\n reports(lang: $locale) {\n ...Report\n }\n }\n }\n }\n": typeof types.InvolvementAdminReportsPageDocument, "\n mutation ResendOrderConfirmation($input: ResendOrderConfirmationInput!) {\n resendOrderConfirmation(input: $input) {\n order {\n id\n }\n }\n }\n": typeof types.ResendOrderConfirmationDocument, "\n mutation UpdateOrder($input: UpdateOrderInput!) {\n updateOrder(input: $input) {\n order {\n id\n }\n }\n }\n": typeof types.UpdateOrderDocument, @@ -223,6 +225,8 @@ const documents: Documents = { "\n mutation PutInvolvementDimensionValue($input: PutDimensionValueInput!) {\n putDimensionValue(input: $input) {\n value {\n slug\n }\n }\n }\n": types.PutInvolvementDimensionValueDocument, "\n mutation DeleteInvolvementDimensionValue($input: DeleteDimensionValueInput!) {\n deleteDimensionValue(input: $input) {\n slug\n }\n }\n": types.DeleteInvolvementDimensionValueDocument, "\n query InvolvementDimensionsList($eventSlug: String!, $locale: String!) {\n event(slug: $eventSlug) {\n name\n slug\n\n involvement {\n dimensions(publicOnly: false) {\n ...DimensionEditor\n }\n }\n }\n }\n": types.InvolvementDimensionsListDocument, + "\n mutation UpdateInvolvementPreferences(\n $input: UpdateInvolvementPreferencesInput!\n ) {\n updateInvolvementPreferences(input: $input) {\n preferences {\n shirtsFrozenAt\n }\n }\n }\n": types.UpdateInvolvementPreferencesDocument, + "\n query InvolvementPreferences($eventSlug: String!) {\n event(slug: $eventSlug) {\n name\n slug\n\n involvement {\n shirtsFrozenAt\n }\n }\n }\n": types.InvolvementPreferencesDocument, "\n query InvolvementAdminReportsPage($eventSlug: String!, $locale: String) {\n event(slug: $eventSlug) {\n name\n slug\n timezone\n\n involvement {\n reports(lang: $locale) {\n ...Report\n }\n }\n }\n }\n": types.InvolvementAdminReportsPageDocument, "\n mutation ResendOrderConfirmation($input: ResendOrderConfirmationInput!) {\n resendOrderConfirmation(input: $input) {\n order {\n id\n }\n }\n }\n": types.ResendOrderConfirmationDocument, "\n mutation UpdateOrder($input: UpdateOrderInput!) {\n updateOrder(input: $input) {\n order {\n id\n }\n }\n }\n": types.UpdateOrderDocument, @@ -470,6 +474,14 @@ export function graphql(source: "\n mutation DeleteInvolvementDimensionValue($i * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n query InvolvementDimensionsList($eventSlug: String!, $locale: String!) {\n event(slug: $eventSlug) {\n name\n slug\n\n involvement {\n dimensions(publicOnly: false) {\n ...DimensionEditor\n }\n }\n }\n }\n"): (typeof documents)["\n query InvolvementDimensionsList($eventSlug: String!, $locale: String!) {\n event(slug: $eventSlug) {\n name\n slug\n\n involvement {\n dimensions(publicOnly: false) {\n ...DimensionEditor\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation UpdateInvolvementPreferences(\n $input: UpdateInvolvementPreferencesInput!\n ) {\n updateInvolvementPreferences(input: $input) {\n preferences {\n shirtsFrozenAt\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateInvolvementPreferences(\n $input: UpdateInvolvementPreferencesInput!\n ) {\n updateInvolvementPreferences(input: $input) {\n preferences {\n shirtsFrozenAt\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query InvolvementPreferences($eventSlug: String!) {\n event(slug: $eventSlug) {\n name\n slug\n\n involvement {\n shirtsFrozenAt\n }\n }\n }\n"): (typeof documents)["\n query InvolvementPreferences($eventSlug: String!) {\n event(slug: $eventSlug) {\n name\n slug\n\n involvement {\n shirtsFrozenAt\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/kompassi-v2-frontend/src/__generated__/graphql.ts b/kompassi-v2-frontend/src/__generated__/graphql.ts index 38f4a053d..b0c961356 100644 --- a/kompassi-v2-frontend/src/__generated__/graphql.ts +++ b/kompassi-v2-frontend/src/__generated__/graphql.ts @@ -1041,6 +1041,8 @@ export type InvolvementEventMetaType = { people: Array; person?: Maybe; reports: Array; + /** When shirts were ordered, the shirt sizes in COMBINED_PERKS involvements are frozen. After this timestamp, only changing to ShirtSize.NONE is allowed. */ + shirtsFrozenAt?: Maybe; }; @@ -1615,6 +1617,7 @@ export type Mutation = { updateForm?: Maybe; updateFormFields?: Maybe; updateInvolvementDimensions?: Maybe; + updateInvolvementPreferences?: Maybe; updateOrder?: Maybe; updateProduct?: Maybe; updateProgram?: Maybe; @@ -1879,6 +1882,11 @@ export type MutationUpdateInvolvementDimensionsArgs = { }; +export type MutationUpdateInvolvementPreferencesArgs = { + input: UpdateInvolvementPreferencesInput; +}; + + export type MutationUpdateOrderArgs = { input: UpdateOrderInput; }; @@ -2782,6 +2790,16 @@ export type UpdateInvolvementDimensionsInput = { involvementId: Scalars['String']['input']; }; +export type UpdateInvolvementPreferences = { + __typename?: 'UpdateInvolvementPreferences'; + preferences?: Maybe; +}; + +export type UpdateInvolvementPreferencesInput = { + eventSlug: Scalars['String']['input']; + shirtsFrozenAt?: InputMaybe; +}; + export type UpdateOrder = { __typename?: 'UpdateOrder'; order?: Maybe; @@ -2984,6 +3002,20 @@ export type InvolvementDimensionsListQueryVariables = Exact<{ export type InvolvementDimensionsListQuery = { __typename?: 'Query', event?: { __typename?: 'FullEventType', name: string, slug: string, involvement?: { __typename?: 'InvolvementEventMetaType', dimensions: Array<{ __typename?: 'FullDimensionType', slug: string, canRemove: boolean, canAddValues: boolean, title?: string | null, isPublic: boolean, isKeyDimension: boolean, isMultiValue: boolean, isListFilter: boolean, isShownInDetail: boolean, isNegativeSelection: boolean, isTechnical: boolean, valueOrdering: DimensionsDimensionValueOrderingChoices, titleFi: string, titleEn: string, titleSv: string, values: Array<{ __typename?: 'DimensionValueType', slug: string, color: string, isTechnical: boolean, isSubjectLocked: boolean, canRemove: boolean, title?: string | null, titleFi: string, titleEn: string, titleSv: string }> }> } | null } | null }; +export type UpdateInvolvementPreferencesMutationVariables = Exact<{ + input: UpdateInvolvementPreferencesInput; +}>; + + +export type UpdateInvolvementPreferencesMutation = { __typename?: 'Mutation', updateInvolvementPreferences?: { __typename?: 'UpdateInvolvementPreferences', preferences?: { __typename?: 'InvolvementEventMetaType', shirtsFrozenAt?: string | null } | null } | null }; + +export type InvolvementPreferencesQueryVariables = Exact<{ + eventSlug: Scalars['String']['input']; +}>; + + +export type InvolvementPreferencesQuery = { __typename?: 'Query', event?: { __typename?: 'FullEventType', name: string, slug: string, involvement?: { __typename?: 'InvolvementEventMetaType', shirtsFrozenAt?: string | null } | null } | null }; + export type InvolvementAdminReportsPageQueryVariables = Exact<{ eventSlug: Scalars['String']['input']; locale?: InputMaybe; @@ -4105,6 +4137,8 @@ export const DeleteInvolvementDimensionDocument = {"kind":"Document","definition export const PutInvolvementDimensionValueDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"PutInvolvementDimensionValue"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PutDimensionValueInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"putDimensionValue"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"value"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteInvolvementDimensionValueDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteInvolvementDimensionValue"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteDimensionValueInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteDimensionValue"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]}}]} as unknown as DocumentNode; export const InvolvementDimensionsListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"InvolvementDimensionsList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"involvement"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dimensions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"publicOnly"},"value":{"kind":"BooleanValue","value":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"DimensionEditor"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DimensionEditorValue"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"DimensionValueType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"isTechnical"}},{"kind":"Field","name":{"kind":"Name","value":"isSubjectLocked"}},{"kind":"Field","name":{"kind":"Name","value":"canRemove"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"titleFi"}},{"kind":"Field","name":{"kind":"Name","value":"titleEn"}},{"kind":"Field","name":{"kind":"Name","value":"titleSv"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DimensionEditor"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullDimensionType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"canRemove"}},{"kind":"Field","name":{"kind":"Name","value":"canAddValues"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"isKeyDimension"}},{"kind":"Field","name":{"kind":"Name","value":"isMultiValue"}},{"kind":"Field","name":{"kind":"Name","value":"isListFilter"}},{"kind":"Field","name":{"kind":"Name","value":"isShownInDetail"}},{"kind":"Field","name":{"kind":"Name","value":"isNegativeSelection"}},{"kind":"Field","name":{"kind":"Name","value":"isTechnical"}},{"kind":"Field","name":{"kind":"Name","value":"valueOrdering"}},{"kind":"Field","name":{"kind":"Name","value":"titleFi"}},{"kind":"Field","name":{"kind":"Name","value":"titleEn"}},{"kind":"Field","name":{"kind":"Name","value":"titleSv"}},{"kind":"Field","name":{"kind":"Name","value":"values"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"DimensionEditorValue"}}]}}]}}]} as unknown as DocumentNode; +export const UpdateInvolvementPreferencesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateInvolvementPreferences"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateInvolvementPreferencesInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateInvolvementPreferences"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"preferences"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"shirtsFrozenAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const InvolvementPreferencesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"InvolvementPreferences"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"involvement"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"shirtsFrozenAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const InvolvementAdminReportsPageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"InvolvementAdminReportsPage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"involvement"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"reports"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Report"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Report"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ReportType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"footer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"columns"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rows"}},{"kind":"Field","name":{"kind":"Name","value":"totalRow"}}]}}]} as unknown as DocumentNode; export const ResendOrderConfirmationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResendOrderConfirmation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ResendOrderConfirmationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resendOrderConfirmation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateOrderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateOrder"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateOrderInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOrder"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/involvement-preferences/actions.ts b/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/involvement-preferences/actions.ts new file mode 100644 index 000000000..2175f32c3 --- /dev/null +++ b/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/involvement-preferences/actions.ts @@ -0,0 +1,43 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { graphql } from "@/__generated__"; +import { getClient } from "@/apolloClient"; + +const mutation = graphql(` + mutation UpdateInvolvementPreferences( + $input: UpdateInvolvementPreferencesInput! + ) { + updateInvolvementPreferences(input: $input) { + preferences { + shirtsFrozenAt + } + } + } +`); + +export async function updateInvolvementPreferences( + locale: string, + eventSlug: string, + formData: FormData, +) { + const shirtsFrozenAtRaw = formData.get("shirtsFrozenAt"); + const shirtsFrozenAt = + shirtsFrozenAtRaw && + typeof shirtsFrozenAtRaw === "string" && + shirtsFrozenAtRaw !== "" + ? shirtsFrozenAtRaw + : null; + + await getClient().mutate({ + mutation, + variables: { + input: { + eventSlug, + shirtsFrozenAt, + }, + }, + }); + + revalidatePath(`/${locale}/${eventSlug}/involvement-preferences`); +} diff --git a/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/involvement-preferences/page.tsx b/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/involvement-preferences/page.tsx new file mode 100644 index 000000000..bd5d0c607 --- /dev/null +++ b/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/involvement-preferences/page.tsx @@ -0,0 +1,128 @@ +import { notFound } from "next/navigation"; + +import Card from "react-bootstrap/Card"; +import CardBody from "react-bootstrap/CardBody"; +import { updateInvolvementPreferences } from "./actions"; +import { graphql } from "@/__generated__"; +import { getClient } from "@/apolloClient"; +import { auth } from "@/auth"; +import SignInRequired from "@/components/errors/SignInRequired"; +import { Field } from "@/components/forms/models"; +import { SchemaForm } from "@/components/forms/SchemaForm"; +import SubmitButton from "@/components/forms/SubmitButton"; +import InvolvementAdminView from "@/components/involvement/InvolvementAdminView"; +import getPageTitle from "@/helpers/getPageTitle"; +import { getTranslations } from "@/translations"; + +const query = graphql(` + query InvolvementPreferences($eventSlug: String!) { + event(slug: $eventSlug) { + name + slug + + involvement { + shirtsFrozenAt + } + } + } +`); + +interface Props { + params: Promise<{ + locale: string; + eventSlug: string; + }>; + searchParams: Promise>; +} + +export const revalidate = 0; + +export async function generateMetadata(props: Props) { + const params = await props.params; + const { locale, eventSlug } = params; + const translations = getTranslations(locale); + + const session = await auth(); + if (!session) { + return translations.SignInRequired.metadata; + } + + const { data } = await getClient().query({ + query, + variables: { eventSlug }, + }); + + return { + title: getPageTitle({ + translations, + event: data.event, + viewTitle: translations.Involvement.preferencesAdmin.title, + }), + }; +} + +export default async function InvolvementPreferencesPage(props: Props) { + const params = await props.params; + const searchParams = await props.searchParams; + const { locale, eventSlug } = params; + const translations = getTranslations(locale); + const t = translations.Involvement.preferencesAdmin; + + const session = await auth(); + if (!session) { + return ; + } + + const { data } = await getClient().query({ + query, + variables: { eventSlug }, + }); + + const event = data.event; + const involvement = data.event?.involvement; + + if (!event || !involvement) { + notFound(); + } + + const fields: Field[] = [ + { + slug: "shirtsFrozenAt", + type: "DateTimeField", + title: t.attributes.shirtsFrozenAt.title, + helpText: t.attributes.shirtsFrozenAt.helpText, + required: false, + }, + ]; + + const values = { + shirtsFrozenAt: involvement.shirtsFrozenAt ?? "", + }; + + return ( + + + +
+ + + {translations.Common.standardActions.save} + + +
+
+
+ ); +} diff --git a/kompassi-v2-frontend/src/components/involvement/InvolvementAdminTabs.tsx b/kompassi-v2-frontend/src/components/involvement/InvolvementAdminTabs.tsx index c546bb579..2d4e89481 100644 --- a/kompassi-v2-frontend/src/components/involvement/InvolvementAdminTabs.tsx +++ b/kompassi-v2-frontend/src/components/involvement/InvolvementAdminTabs.tsx @@ -3,7 +3,7 @@ import { Translations } from "@/translations/en"; export interface InvolvementAdminTabsProps { eventSlug: string; - active: "people" | "dimensions" | "registries" | "reports"; + active: "people" | "dimensions" | "registries" | "reports" | "preferences"; translations: Translations; searchParams?: Record; } @@ -53,6 +53,11 @@ export default function InvolvementAdminTabs({ title: reporT.listTitle, href: `/${eventSlug}/involvement-reports`, }, + { + slug: "preferences", + title: t.preferencesAdmin.title, + href: `/${eventSlug}/involvement-preferences`, + }, ]; return ; diff --git a/kompassi-v2-frontend/src/translations/en.tsx b/kompassi-v2-frontend/src/translations/en.tsx index dbcfd5e94..e2e92267d 100644 --- a/kompassi-v2-frontend/src/translations/en.tsx +++ b/kompassi-v2-frontend/src/translations/en.tsx @@ -2758,6 +2758,15 @@ const translations = { ), }, }, + preferencesAdmin: { + title: "Preferences", + attributes: { + shirtsFrozenAt: { + title: "Shirts frozen at", + helpText: "When shirts are ordered, the shirt sizes are frozen.", + }, + }, + }, messages: {}, filters: { searchPlaceholder: "Search by name or email", diff --git a/kompassi-v2-frontend/src/translations/fi.tsx b/kompassi-v2-frontend/src/translations/fi.tsx index 553eb77f4..ab5ba9fdb 100644 --- a/kompassi-v2-frontend/src/translations/fi.tsx +++ b/kompassi-v2-frontend/src/translations/fi.tsx @@ -2710,6 +2710,15 @@ const translations: Translations = { ), }, }, + preferencesAdmin: { + title: "Asetukset", + attributes: { + shirtsFrozenAt: { + title: "Paitojen jäädytysaika", + helpText: "Kun paidat on tilattu, paitakoot jäädytetään.", + }, + }, + }, messages: {}, filters: { searchPlaceholder: "Hae nimellä tai sähköpostilla", diff --git a/kompassi-v2-frontend/src/translations/sv.tsx b/kompassi-v2-frontend/src/translations/sv.tsx index cdf3c569d..ae80921e5 100644 --- a/kompassi-v2-frontend/src/translations/sv.tsx +++ b/kompassi-v2-frontend/src/translations/sv.tsx @@ -2636,6 +2636,17 @@ const translations: Translations = { ), }, }, + preferencesAdmin: { + title: "Inställningar", + attributes: { + shirtsFrozenAt: { + title: "Skjortor frysning vid", + helpText: UNTRANSLATED( + en.Involvement.preferencesAdmin.attributes.shirtsFrozenAt.helpText, + ), + }, + }, + }, messages: {}, filters: { searchPlaceholder: "Sök på namn eller e-post", diff --git a/kompassi/badges/templates/badges_admin_onboarding_view.pug b/kompassi/badges/templates/badges_admin_onboarding_view.pug index d681c35e7..309cf1c6f 100644 --- a/kompassi/badges/templates/badges_admin_onboarding_view.pug +++ b/kompassi/badges/templates/badges_admin_onboarding_view.pug @@ -9,7 +9,6 @@ block content .panel-body .text-muted p Merkitse henkilö saapuneeksi klikkaamalla taulukon riviä. Haku toimii välittömästi ilman rivinvaihdon tmv. painallusta ja hakee ainoastaan nimikentästä. - p Viemällä hiiren osoittimen henkilöstöluokan nimen päälle näet, mitä kyseisen henkilöstöluokan henkilölle annetaan, mikäli tämä tieto on syötetty. .form-group label(for='onboarding_search') {% trans "Search" %} input#onboarding_search.form-control(type='search') diff --git a/kompassi/graphql_api/schema.py b/kompassi/graphql_api/schema.py index 7516b9aa8..327583426 100644 --- a/kompassi/graphql_api/schema.py +++ b/kompassi/graphql_api/schema.py @@ -30,6 +30,7 @@ from kompassi.involvement.graphql.mutations.delete_invitation import DeleteInvitation from kompassi.involvement.graphql.mutations.resend_invitation import ResendInvitation from kompassi.involvement.graphql.mutations.update_involvement_dimensions import UpdateInvolvementDimensions +from kompassi.involvement.graphql.mutations.update_involvement_preferences import UpdateInvolvementPreferences from kompassi.involvement.graphql.registry_limited import LimitedRegistryType from kompassi.involvement.models.registry import Registry from kompassi.program_v2.graphql.mutations.accept_program_offer import AcceptProgramOffer @@ -161,6 +162,7 @@ class Mutation(graphene.ObjectType): resend_invitation = ResendInvitation.Field() update_involvement_dimensions = UpdateInvolvementDimensions.Field() + update_involvement_preferences = UpdateInvolvementPreferences.Field() # Program v2 mark_program_as_favorite = MarkProgramAsFavorite.Field() diff --git a/kompassi/involvement/emperkelators/base.py b/kompassi/involvement/emperkelators/base.py index b328dd78b..1c61f1a08 100644 --- a/kompassi/involvement/emperkelators/base.py +++ b/kompassi/involvement/emperkelators/base.py @@ -15,6 +15,7 @@ from ..models.enums import INVOLVEMENT_TYPES_CONSIDERED_FOR_COMBINED_PERKS from ..models.involvement import Involvement +from ..models.meta import InvolvementEventMeta class BaseEmperkelator: @@ -27,9 +28,23 @@ class BaseEmperkelator: def scope(self): return self.universe.scope - @property + @cached_property def event(self): - return self.universe.scope.event + event = self.universe.scope.event + + if event is None: + raise ValueError("Instantiated an emperkelator on a universe with no event (this should not happen)") + + return event + + @cached_property + def meta(self) -> InvolvementEventMeta: + meta = self.event.involvement_event_meta + + if meta is None: + raise ValueError(f"Event {self.event.slug} has no involvement event meta (this should not happen)") + + return meta @cached_property def cache(self): @@ -152,6 +167,31 @@ def get_annotation_values(self) -> CachedAnnotations: """ return {} + def get_frozen_shirt_size_values(self, computed_shirt_size_values: list[str]) -> list[str]: + """ + Freeze shirt-size dimension values after shirt order. + + - New combined perks created after freeze get no shirt. + - Existing combined perks keep their old value, unless explicitly cleared. + """ + if not self.meta.are_shirts_frozen(): + return computed_shirt_size_values + + if self.existing_combined_perks is None: + return [] + + existing_dimensions = self.existing_combined_perks.cached_dimensions + existing_shirt_sizes = existing_dimensions.get("shirt-size", []) + + if not existing_shirt_sizes: + return computed_shirt_size_values + + # Empty or explicit NONE clears shirt size even after freeze. + if not computed_shirt_size_values or "none" in computed_shirt_size_values: + return [] + + return existing_shirt_sizes + def get_title(self) -> str: return next( ( diff --git a/kompassi/involvement/emperkelators/desucon2026.py b/kompassi/involvement/emperkelators/desucon2026.py index 6fb9a56cc..61c44dcc2 100644 --- a/kompassi/involvement/emperkelators/desucon2026.py +++ b/kompassi/involvement/emperkelators/desucon2026.py @@ -367,12 +367,29 @@ def imbibe(self, perks: Perks): class DesuconEmperkelator(BaseEmperkelator): @cached_property def perks(self) -> Perks: - return reduce( + perks = reduce( Perks.imbibe, (Perks.for_involvement(inv) for inv in self.involvements), Perks(), ) + # Apply shirt freeze directly to computed perks so display text and dimensions stay in sync. + perks.shirt_size = self._get_frozen_shirt_size(perks.shirt_size) + return perks + + def _get_frozen_shirt_size(self, computed_shirt_size: ShirtSize) -> ShirtSize: + """Get the existing shirt size if shirts are frozen, otherwise return computed value.""" + computed_shirt_size_values = [] if computed_shirt_size == ShirtSize.NONE else [computed_shirt_size.value] + frozen_shirt_size_values = self.get_frozen_shirt_size_values(computed_shirt_size_values) + + if not frozen_shirt_size_values: + return ShirtSize.NONE + + try: + return ShirtSize(frozen_shirt_size_values[0]) + except (ValueError, KeyError): + return computed_shirt_size + @classmethod def get_dimension_dtos(cls, event: Event) -> list[DimensionDTO]: return [ diff --git a/kompassi/involvement/emperkelators/tracon2025.py b/kompassi/involvement/emperkelators/tracon2025.py index b048fcf42..960564986 100644 --- a/kompassi/involvement/emperkelators/tracon2025.py +++ b/kompassi/involvement/emperkelators/tracon2025.py @@ -324,7 +324,8 @@ def ticket_type_dimension_values(self) -> list[str]: @property def shirt_size_dimension_values(self) -> list[str]: if inv := self.active_legacy_signup_involvement: - return inv.cached_dimensions.get("shirt-size", []) + existing_shirt_size = inv.cached_dimensions.get("shirt-size", []) + return self.get_frozen_shirt_size_values(existing_shirt_size) # TODO in future events, make sure this is a dimension in source data for response in Response.objects.filter( @@ -359,9 +360,9 @@ def shirt_size_dimension_values(self) -> list[str]: ) continue - return [shirt_size.value] + return self.get_frozen_shirt_size_values([shirt_size.value]) - return [] + return self.get_frozen_shirt_size_values([]) @classmethod def get_dimension_dtos(cls, event: Event) -> list[DimensionDTO]: diff --git a/kompassi/involvement/graphql/mutations/update_involvement_preferences.py b/kompassi/involvement/graphql/mutations/update_involvement_preferences.py new file mode 100644 index 000000000..15f523f4c --- /dev/null +++ b/kompassi/involvement/graphql/mutations/update_involvement_preferences.py @@ -0,0 +1,43 @@ +from datetime import datetime + +import graphene +from django.utils.timezone import is_naive, make_aware + +from kompassi.access.cbac import graphql_check_instance +from kompassi.involvement.graphql.meta import InvolvementEventMetaType +from kompassi.involvement.models.meta import InvolvementEventMeta + + +class UpdateInvolvementPreferencesInput(graphene.InputObjectType): + event_slug = graphene.String(required=True) + shirts_frozen_at = graphene.DateTime() + + +class UpdateInvolvementPreferences(graphene.Mutation): + class Arguments: + input = UpdateInvolvementPreferencesInput(required=True) + + preferences = graphene.Field(InvolvementEventMetaType) + + @staticmethod + def mutate( + _root, + info, + input: UpdateInvolvementPreferencesInput, + ): + meta = InvolvementEventMeta.objects.get(event__slug=input.event_slug) + + graphql_check_instance( + meta, + info, + app="involvement", + operation="update", + ) + + shirts_frozen_at: datetime | None = input.shirts_frozen_at # type: ignore + if shirts_frozen_at is not None and is_naive(shirts_frozen_at): + shirts_frozen_at = make_aware(shirts_frozen_at) + meta.shirts_frozen_at = shirts_frozen_at + meta.save(update_fields=["shirts_frozen_at"]) + + return UpdateInvolvementPreferences(preferences=meta) diff --git a/kompassi/involvement/migrations/0009_involvementeventmeta_shirts_frozen_at.py b/kompassi/involvement/migrations/0009_involvementeventmeta_shirts_frozen_at.py new file mode 100644 index 000000000..77c14f391 --- /dev/null +++ b/kompassi/involvement/migrations/0009_involvementeventmeta_shirts_frozen_at.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.5 on 2026-05-17 18:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("involvement", "0008_involvementtobadgemapping_job_title_mode_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="involvementeventmeta", + name="shirts_frozen_at", + field=models.DateTimeField( + blank=True, + help_text="When shirts were ordered, the shirt sizes in COMBINED_PERKS involvements are frozen. After this timestamp, only changing to ShirtSize.NONE is allowed.", + null=True, + ), + ), + ] diff --git a/kompassi/involvement/models/meta.py b/kompassi/involvement/models/meta.py index f237182bd..988c44277 100644 --- a/kompassi/involvement/models/meta.py +++ b/kompassi/involvement/models/meta.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from django.db import models +from django.utils.timezone import now from kompassi.core.models.event import Event from kompassi.core.utils.log_utils import log_get_or_create @@ -48,9 +49,22 @@ class InvolvementEventMeta(models.Model): on_delete=models.SET_NULL, ) + shirts_frozen_at = models.DateTimeField( + null=True, + blank=True, + help_text="When shirts were ordered, the shirt sizes in COMBINED_PERKS involvements are frozen. After this timestamp, only changing to ShirtSize.NONE is allowed.", + ) + def __str__(self): return self.event.slug if self.event else None + def are_shirts_frozen(self) -> bool: + """Check if shirt sizes are frozen due to shirts having been ordered.""" + if self.shirts_frozen_at is None: + return False + + return now() >= self.shirts_frozen_at + @property def invitations(self): return Invitation.objects.filter( @@ -60,6 +74,10 @@ def invitations(self): "program", ) + @cached_property + def scope(self): + return self.event.scope + @cached_property def dimension_cache(self): return self.event.involvement_universe.preload_dimensions()