From e2935f953633beda98e8de368ec10c6149bda3ab Mon Sep 17 00:00:00 2001 From: Tuomas Virtanen Date: Sun, 8 Mar 2026 19:39:30 +0200 Subject: [PATCH 1/2] backend, admin: Live voting API and management pieces --- admin/src/api/sdk.gen.ts | 260 ++++++++++- admin/src/api/types.gen.ts | 283 +++++++++-- admin/src/locales/en.json | 31 +- admin/src/locales/fi.json | 31 +- admin/src/router.ts | 11 + admin/src/services/auth.ts | 1 + .../views/kompomaatti/CompoEditView.test.ts | 52 +-- admin/src/views/kompomaatti/CompoEditView.vue | 33 +- admin/src/views/kompomaatti/ComposView.vue | 31 +- .../src/views/kompomaatti/LiveVotingView.vue | 440 ++++++++++++++++++ .../api/v1/admin/serializers/kompomaatti.py | 2 - .../api/v1/admin/viewsets/kompomaatti.py | 1 - .../api/v1/serializers/kompomaatti.py | 10 +- .../Instanssi/api/v1/viewsets/kompomaatti.py | 3 +- .../serializers/admin/kompomaatti/__init__.py | 8 + .../kompomaatti/compo_entry_serializer.py | 1 + .../admin/kompomaatti/compo_serializer.py | 2 - .../kompomaatti/live_voting_serializer.py | 51 ++ .../public/kompomaatti/compo_serializer.py | 2 - .../public_compo_entry_serializer.py | 4 +- .../user_compo_entry_serializer.py | 4 +- .../v2/viewsets/admin/kompomaatti/compos.py | 2 - .../viewsets/admin/kompomaatti/live_voting.py | 172 +++++++ .../Instanssi/api/v2/viewsets/admin/urls.py | 2 + .../public/kompomaatti/compo_entries.py | 13 +- .../public/kompomaatti/live_voting.py | 81 ++++ .../Instanssi/api/v2/viewsets/public/urls.py | 8 + .../user/kompomaatti/user_compo_entries.py | 2 +- .../user/kompomaatti/user_vote_groups.py | 4 + .../templatetags/programme_tags.py | 23 +- .../management/commands/fixtures/compos.py | 20 - .../migrations/0025_live_voting.py | 58 +++ backend/Instanssi/kompomaatti/misc/events.py | 19 +- backend/Instanssi/kompomaatti/models.py | 34 +- backend/openapi.yaml | 410 ++++++++++++++-- backend/tests/api/v1/admin/test_compos.py | 4 - backend/tests/api/v1/test_authenticated.py | 2 +- backend/tests/api/v1/test_public_responses.py | 4 - .../v2/admin/kompomaatti/compos/test_staff.py | 8 - .../admin/kompomaatti/live_voting/__init__.py | 0 .../kompomaatti/live_voting/test_staff.py | 206 ++++++++ .../live_voting/test_unauthenticated.py | 34 ++ .../live_voting/test_unauthorized.py | 34 ++ .../public/kompomaatti/test_compo_entries.py | 10 +- .../v2/public/kompomaatti/test_live_voting.py | 83 ++++ .../user_compo_entries/test_authorized.py | 1 + backend/tests/arkisto/conftest.py | 3 - .../tests/arkisto/test_entry_index_view.py | 1 - backend/tests/conftest.py | 60 ++- .../kompomaatti/test_live_voting_models.py | 45 ++ backend/tests/notifications/test_tasks.py | 2 - 51 files changed, 2304 insertions(+), 302 deletions(-) create mode 100644 admin/src/views/kompomaatti/LiveVotingView.vue create mode 100644 backend/Instanssi/api/v2/serializers/admin/kompomaatti/live_voting_serializer.py create mode 100644 backend/Instanssi/api/v2/viewsets/admin/kompomaatti/live_voting.py create mode 100644 backend/Instanssi/api/v2/viewsets/public/kompomaatti/live_voting.py create mode 100644 backend/Instanssi/kompomaatti/migrations/0025_live_voting.py create mode 100644 backend/tests/api/v2/admin/kompomaatti/live_voting/__init__.py create mode 100644 backend/tests/api/v2/admin/kompomaatti/live_voting/test_staff.py create mode 100644 backend/tests/api/v2/admin/kompomaatti/live_voting/test_unauthenticated.py create mode 100644 backend/tests/api/v2/admin/kompomaatti/live_voting/test_unauthorized.py create mode 100644 backend/tests/api/v2/public/kompomaatti/test_live_voting.py create mode 100644 backend/tests/kompomaatti/test_live_voting_models.py diff --git a/admin/src/api/sdk.gen.ts b/admin/src/api/sdk.gen.ts index 8d5575d36..06a40071a 100644 --- a/admin/src/api/sdk.gen.ts +++ b/admin/src/api/sdk.gen.ts @@ -115,6 +115,20 @@ import type { AdminEventKompomaattiEntriesValidateArchiveRetrieveData, AdminEventKompomaattiEntriesValidateArchiveRetrieveErrors, AdminEventKompomaattiEntriesValidateArchiveRetrieveResponses, + AdminEventKompomaattiLiveVotingHideAllCreateData, + AdminEventKompomaattiLiveVotingHideAllCreateResponses, + AdminEventKompomaattiLiveVotingHideEntryCreateData, + AdminEventKompomaattiLiveVotingHideEntryCreateResponses, + AdminEventKompomaattiLiveVotingPartialUpdateData, + AdminEventKompomaattiLiveVotingPartialUpdateResponses, + AdminEventKompomaattiLiveVotingResetCreateData, + AdminEventKompomaattiLiveVotingResetCreateResponses, + AdminEventKompomaattiLiveVotingRetrieveData, + AdminEventKompomaattiLiveVotingRetrieveResponses, + AdminEventKompomaattiLiveVotingRevealAllCreateData, + AdminEventKompomaattiLiveVotingRevealAllCreateResponses, + AdminEventKompomaattiLiveVotingRevealEntryCreateData, + AdminEventKompomaattiLiveVotingRevealEntryCreateResponses, AdminEventKompomaattiTicketVoteCodesListData, AdminEventKompomaattiTicketVoteCodesListResponses, AdminEventKompomaattiTicketVoteCodesRetrieveData, @@ -344,6 +358,9 @@ import type { PublicEventKompomaattiEntriesListResponses, PublicEventKompomaattiEntriesRetrieveData, PublicEventKompomaattiEntriesRetrieveResponses, + PublicEventKompomaattiLiveVotingRetrieveData, + PublicEventKompomaattiLiveVotingRetrieveErrors, + PublicEventKompomaattiLiveVotingRetrieveResponses, PublicEventProgramEventsListData, PublicEventProgramEventsListResponses, PublicEventProgramEventsRetrieveData, @@ -1996,6 +2013,220 @@ export const adminEventKompomaattiEntriesValidateArchiveRetrieve = < }); }; +/** + * Staff viewset for managing live voting state during a compo presentation. + */ +export const adminEventKompomaattiLiveVotingRetrieve = ( + options: Options +) => { + return (options.client ?? client).get< + AdminEventKompomaattiLiveVotingRetrieveResponses, + unknown, + ThrowOnError + >({ + responseType: "json", + security: [ + { + name: "Authorization", + type: "apiKey", + }, + { + in: "cookie", + name: "sessionid", + type: "apiKey", + }, + ], + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/", + ...options, + }); +}; + +/** + * Staff viewset for managing live voting state during a compo presentation. + */ +export const adminEventKompomaattiLiveVotingPartialUpdate = ( + options: Options +) => { + return (options.client ?? client).patch< + AdminEventKompomaattiLiveVotingPartialUpdateResponses, + unknown, + ThrowOnError + >({ + responseType: "json", + security: [ + { + name: "Authorization", + type: "apiKey", + }, + { + in: "cookie", + name: "sessionid", + type: "apiKey", + }, + ], + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); +}; + +/** + * Staff viewset for managing live voting state during a compo presentation. + */ +export const adminEventKompomaattiLiveVotingHideAllCreate = ( + options: Options +) => { + return (options.client ?? client).post< + AdminEventKompomaattiLiveVotingHideAllCreateResponses, + unknown, + ThrowOnError + >({ + responseType: "json", + security: [ + { + name: "Authorization", + type: "apiKey", + }, + { + in: "cookie", + name: "sessionid", + type: "apiKey", + }, + ], + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/hide_all/", + ...options, + }); +}; + +/** + * Staff viewset for managing live voting state during a compo presentation. + */ +export const adminEventKompomaattiLiveVotingHideEntryCreate = < + ThrowOnError extends boolean = false, +>( + options: Options +) => { + return (options.client ?? client).post< + AdminEventKompomaattiLiveVotingHideEntryCreateResponses, + unknown, + ThrowOnError + >({ + responseType: "json", + security: [ + { + name: "Authorization", + type: "apiKey", + }, + { + in: "cookie", + name: "sessionid", + type: "apiKey", + }, + ], + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/hide_entry/", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); +}; + +/** + * Staff viewset for managing live voting state during a compo presentation. + */ +export const adminEventKompomaattiLiveVotingResetCreate = ( + options: Options +) => { + return (options.client ?? client).post< + AdminEventKompomaattiLiveVotingResetCreateResponses, + unknown, + ThrowOnError + >({ + responseType: "json", + security: [ + { + name: "Authorization", + type: "apiKey", + }, + { + in: "cookie", + name: "sessionid", + type: "apiKey", + }, + ], + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/reset/", + ...options, + }); +}; + +/** + * Staff viewset for managing live voting state during a compo presentation. + */ +export const adminEventKompomaattiLiveVotingRevealAllCreate = < + ThrowOnError extends boolean = false, +>( + options: Options +) => { + return (options.client ?? client).post< + AdminEventKompomaattiLiveVotingRevealAllCreateResponses, + unknown, + ThrowOnError + >({ + responseType: "json", + security: [ + { + name: "Authorization", + type: "apiKey", + }, + { + in: "cookie", + name: "sessionid", + type: "apiKey", + }, + ], + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/reveal_all/", + ...options, + }); +}; + +/** + * Staff viewset for managing live voting state during a compo presentation. + */ +export const adminEventKompomaattiLiveVotingRevealEntryCreate = < + ThrowOnError extends boolean = false, +>( + options: Options +) => { + return (options.client ?? client).post< + AdminEventKompomaattiLiveVotingRevealEntryCreateResponses, + unknown, + ThrowOnError + >({ + responseType: "json", + security: [ + { + name: "Authorization", + type: "apiKey", + }, + { + in: "cookie", + name: "sessionid", + type: "apiKey", + }, + ], + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/reveal_entry/", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); +}; + /** * Staff viewset for viewing ticket vote codes (read-only). */ @@ -5318,8 +5549,8 @@ export const publicEventKompomaattiComposRetrieve = ( + options: Options +) => { + return (options.client ?? client).get< + PublicEventKompomaattiLiveVotingRetrieveResponses, + PublicEventKompomaattiLiveVotingRetrieveErrors, + ThrowOnError + >({ + responseType: "json", + url: "/api/v2/public/event/{event_pk}/kompomaatti/live_voting/{compo_pk}/", + ...options, + }); +}; + /** * Public read-only endpoint for program events. Only active events are shown. */ diff --git a/admin/src/api/types.gen.ts b/admin/src/api/types.gen.ts index cdefb9414..80058abe3 100644 --- a/admin/src/api/types.gen.ts +++ b/admin/src/api/types.gen.ts @@ -233,10 +233,6 @@ export type Compo = { * Compo start time */ compo_start: string; - /** - * Voting start time - */ - voting_start: string; /** * Voting end time */ @@ -280,10 +276,6 @@ export type Compo = { * Hide from front page */ hide_from_frontpage?: boolean; - /** - * Votable - */ - is_votable?: boolean; /** * Thumbnail settings */ @@ -320,6 +312,14 @@ export type CompoEntry = { readonly imagefile_thumbnail_url: string | null; readonly imagefile_medium_url: string | null; youtube_url?: string | null; + /** + * Revealed in live voting + */ + live_voting_revealed?: boolean; + /** + * Live voting reveal order + */ + live_voting_order?: number; disqualified?: boolean; /** * Disqualification reason @@ -368,6 +368,14 @@ export type CompoEntryRequest = { */ imagefile_original?: Blob | File | null; youtube_url?: string | null; + /** + * Revealed in live voting + */ + live_voting_revealed?: boolean; + /** + * Live voting reveal order + */ + live_voting_order?: number; disqualified?: boolean; /** * Disqualification reason @@ -401,10 +409,6 @@ export type CompoRequest = { * Compo start time */ compo_start: string; - /** - * Voting start time - */ - voting_start: string; /** * Voting end time */ @@ -443,10 +447,6 @@ export type CompoRequest = { * Hide from front page */ hide_from_frontpage?: boolean; - /** - * Votable - */ - is_votable?: boolean; /** * Thumbnail settings */ @@ -1114,6 +1114,26 @@ export type InfodeskTransactionItem = { */ export type LanguageEnum = "en" | "fi"; +export type LiveVotingEntry = { + readonly id: number; + /** + * Revealed in live voting + */ + readonly live_voting_revealed: boolean; +}; + +export type LiveVotingEntryActionRequest = { + entry_id: number; +}; + +export type LiveVotingState = { + readonly compo: number; + voting_open?: boolean; + current_entry?: number | null; + readonly updated_at: string; + readonly entries: Array; +}; + /** * Serializer for audit log entries. */ @@ -1549,6 +1569,14 @@ export type PatchedCompoEntryRequest = { */ imagefile_original?: Blob | File | null; youtube_url?: string | null; + /** + * Revealed in live voting + */ + live_voting_revealed?: boolean; + /** + * Live voting reveal order + */ + live_voting_order?: number; disqualified?: boolean; /** * Disqualification reason @@ -1582,10 +1610,6 @@ export type PatchedCompoRequest = { * Compo start time */ compo_start?: string; - /** - * Voting start time - */ - voting_start?: string; /** * Voting end time */ @@ -1624,10 +1648,6 @@ export type PatchedCompoRequest = { * Hide from front page */ hide_from_frontpage?: boolean; - /** - * Votable - */ - is_votable?: boolean; /** * Thumbnail settings */ @@ -1652,6 +1672,11 @@ export type PatchedEventRequest = { mainurl?: string; }; +export type PatchedLiveVotingUpdateRequest = { + voting_open?: boolean; + current_entry?: number | null; +}; + /** * Staff serializer for archive video categories. */ @@ -2174,10 +2199,6 @@ export type PublicCompo = { * Compo start time */ compo_start: string; - /** - * Voting start time - */ - voting_start: string; /** * Voting end time */ @@ -2186,10 +2207,6 @@ export type PublicCompo = { * Entry presentation */ entry_view_type?: EntryViewTypeEnum; - /** - * Votable - */ - is_votable?: boolean; }; /** @@ -2238,6 +2255,14 @@ export type PublicEvent = { mainurl?: string; }; +export type PublicLiveVotingState = { + compo: number; + voting_open: boolean; + current_entry: number | null; + updated_at: string; + revealed_entries: Array; +}; + /** * Public read-only serializer for archive videos. */ @@ -3331,10 +3356,6 @@ export type CompoWritable = { * Compo start time */ compo_start: string; - /** - * Voting start time - */ - voting_start: string; /** * Voting end time */ @@ -3373,10 +3394,6 @@ export type CompoWritable = { * Hide from front page */ hide_from_frontpage?: boolean; - /** - * Votable - */ - is_votable?: boolean; /** * Thumbnail settings */ @@ -3407,6 +3424,14 @@ export type CompoEntryWritable = { */ imagefile_original?: string | null; youtube_url?: string | null; + /** + * Revealed in live voting + */ + live_voting_revealed?: boolean; + /** + * Live voting reveal order + */ + live_voting_order?: number; disqualified?: boolean; /** * Disqualification reason @@ -3458,6 +3483,11 @@ export type GroupWritable = { name: string; }; +export type LiveVotingStateWritable = { + voting_open?: boolean; + current_entry?: number | null; +}; + /** * Serializer for audit log entries. */ @@ -3659,10 +3689,6 @@ export type PublicCompoWritable = { * Compo start time */ compo_start: string; - /** - * Voting start time - */ - voting_start: string; /** * Voting end time */ @@ -3671,10 +3697,6 @@ export type PublicCompoWritable = { * Entry presentation */ entry_view_type?: EntryViewTypeEnum; - /** - * Votable - */ - is_votable?: boolean; }; /** @@ -4971,7 +4993,6 @@ export type AdminEventKompomaattiComposListData = { active?: boolean; hide_from_archive?: boolean; hide_from_frontpage?: boolean; - is_votable?: boolean; /** * Number of results to return per page. */ @@ -5307,6 +5328,146 @@ export type AdminEventKompomaattiEntriesValidateArchiveRetrieveResponses = { export type AdminEventKompomaattiEntriesValidateArchiveRetrieveResponse = AdminEventKompomaattiEntriesValidateArchiveRetrieveResponses[keyof AdminEventKompomaattiEntriesValidateArchiveRetrieveResponses]; +export type AdminEventKompomaattiLiveVotingRetrieveData = { + body?: never; + path: { + event_pk: number; + /** + * A unique integer value identifying this live voting state. + */ + id: number; + }; + query?: never; + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/"; +}; + +export type AdminEventKompomaattiLiveVotingRetrieveResponses = { + 200: LiveVotingState; +}; + +export type AdminEventKompomaattiLiveVotingRetrieveResponse = + AdminEventKompomaattiLiveVotingRetrieveResponses[keyof AdminEventKompomaattiLiveVotingRetrieveResponses]; + +export type AdminEventKompomaattiLiveVotingPartialUpdateData = { + body?: PatchedLiveVotingUpdateRequest; + path: { + event_pk: number; + /** + * A unique integer value identifying this live voting state. + */ + id: number; + }; + query?: never; + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/"; +}; + +export type AdminEventKompomaattiLiveVotingPartialUpdateResponses = { + 200: LiveVotingState; +}; + +export type AdminEventKompomaattiLiveVotingPartialUpdateResponse = + AdminEventKompomaattiLiveVotingPartialUpdateResponses[keyof AdminEventKompomaattiLiveVotingPartialUpdateResponses]; + +export type AdminEventKompomaattiLiveVotingHideAllCreateData = { + body?: never; + path: { + event_pk: number; + /** + * A unique integer value identifying this live voting state. + */ + id: number; + }; + query?: never; + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/hide_all/"; +}; + +export type AdminEventKompomaattiLiveVotingHideAllCreateResponses = { + 200: LiveVotingState; +}; + +export type AdminEventKompomaattiLiveVotingHideAllCreateResponse = + AdminEventKompomaattiLiveVotingHideAllCreateResponses[keyof AdminEventKompomaattiLiveVotingHideAllCreateResponses]; + +export type AdminEventKompomaattiLiveVotingHideEntryCreateData = { + body: LiveVotingEntryActionRequest; + path: { + event_pk: number; + /** + * A unique integer value identifying this live voting state. + */ + id: number; + }; + query?: never; + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/hide_entry/"; +}; + +export type AdminEventKompomaattiLiveVotingHideEntryCreateResponses = { + 200: LiveVotingState; +}; + +export type AdminEventKompomaattiLiveVotingHideEntryCreateResponse = + AdminEventKompomaattiLiveVotingHideEntryCreateResponses[keyof AdminEventKompomaattiLiveVotingHideEntryCreateResponses]; + +export type AdminEventKompomaattiLiveVotingResetCreateData = { + body?: never; + path: { + event_pk: number; + /** + * A unique integer value identifying this live voting state. + */ + id: number; + }; + query?: never; + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/reset/"; +}; + +export type AdminEventKompomaattiLiveVotingResetCreateResponses = { + 200: LiveVotingState; +}; + +export type AdminEventKompomaattiLiveVotingResetCreateResponse = + AdminEventKompomaattiLiveVotingResetCreateResponses[keyof AdminEventKompomaattiLiveVotingResetCreateResponses]; + +export type AdminEventKompomaattiLiveVotingRevealAllCreateData = { + body?: never; + path: { + event_pk: number; + /** + * A unique integer value identifying this live voting state. + */ + id: number; + }; + query?: never; + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/reveal_all/"; +}; + +export type AdminEventKompomaattiLiveVotingRevealAllCreateResponses = { + 200: LiveVotingState; +}; + +export type AdminEventKompomaattiLiveVotingRevealAllCreateResponse = + AdminEventKompomaattiLiveVotingRevealAllCreateResponses[keyof AdminEventKompomaattiLiveVotingRevealAllCreateResponses]; + +export type AdminEventKompomaattiLiveVotingRevealEntryCreateData = { + body: LiveVotingEntryActionRequest; + path: { + event_pk: number; + /** + * A unique integer value identifying this live voting state. + */ + id: number; + }; + query?: never; + url: "/api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/reveal_entry/"; +}; + +export type AdminEventKompomaattiLiveVotingRevealEntryCreateResponses = { + 200: LiveVotingState; +}; + +export type AdminEventKompomaattiLiveVotingRevealEntryCreateResponse = + AdminEventKompomaattiLiveVotingRevealEntryCreateResponses[keyof AdminEventKompomaattiLiveVotingRevealEntryCreateResponses]; + export type AdminEventKompomaattiTicketVoteCodesListData = { body?: never; path: { @@ -7836,6 +7997,30 @@ export type PublicEventKompomaattiEntriesRetrieveResponses = { export type PublicEventKompomaattiEntriesRetrieveResponse = PublicEventKompomaattiEntriesRetrieveResponses[keyof PublicEventKompomaattiEntriesRetrieveResponses]; +export type PublicEventKompomaattiLiveVotingRetrieveData = { + body?: never; + path: { + compo_pk: number; + event_pk: number; + }; + query?: never; + url: "/api/v2/public/event/{event_pk}/kompomaatti/live_voting/{compo_pk}/"; +}; + +export type PublicEventKompomaattiLiveVotingRetrieveErrors = { + /** + * No response body + */ + 404: unknown; +}; + +export type PublicEventKompomaattiLiveVotingRetrieveResponses = { + 200: PublicLiveVotingState; +}; + +export type PublicEventKompomaattiLiveVotingRetrieveResponse = + PublicEventKompomaattiLiveVotingRetrieveResponses[keyof PublicEventKompomaattiLiveVotingRetrieveResponses]; + export type PublicEventProgramEventsListData = { body?: never; path: { diff --git a/admin/src/locales/en.json b/admin/src/locales/en.json index 835c037bf..3e6b65149 100644 --- a/admin/src/locales/en.json +++ b/admin/src/locales/en.json @@ -112,6 +112,7 @@ "entries": "Entries", "competitions": "Competitions", "competitionParticipations": "Participations", + "liveVoting": "Live Voting", "voteCodes": "Ticket Vote Codes", "voteCodeRequests": "Vote Requests", "store": "Store", @@ -236,13 +237,13 @@ "loadingCompos": "Loading compos ...", "headers": { "addingEnd": "Entry deadline", - "votingStart": "Voting start", "votingEnd": "Voting end" }, "filterByStatus": "Status", "allStatuses": "All", "activeOnly": "Active", - "inactiveOnly": "Inactive" + "inactiveOnly": "Inactive", + "liveVoting": "Live voting" }, "CompoEditView": { "newTitle": "New Compo", @@ -262,7 +263,6 @@ "addingEnd": "Entry deadline", "editingEnd": "Edit deadline", "compoStart": "Compo start", - "votingStart": "Voting start", "votingEnd": "Voting end", "entrySizelimit": "Max entry size (bytes)", "sourceSizelimit": "Max source size (bytes)", @@ -277,10 +277,6 @@ "showVotingResultsOff": "Voting results are hidden", "showVotingResultsHintOn": "Scores, rankings, and entry files are publicly visible", "showVotingResultsHintOff": "Scores and rankings are hidden; entry files hidden until voting starts", - "isVotableOn": "Voting is enabled", - "isVotableOff": "Voting is disabled", - "isVotableHintOn": "Users can vote on entries during the voting period", - "isVotableHintOff": "Voting disabled entirely; use for externally judged compos (e.g. Robocoding)", "entryViewType": "Entry view type", "thumbnailPref": "Thumbnail preference", "hideFromArchiveOn": "Hidden from archive", @@ -542,6 +538,27 @@ "disqualifiedReason": "Reason" } }, + "LiveVotingView": { + "title": "Live Voting", + "loadFailure": "Failed to load live voting state.", + "actionFailure": "Action failed; try again.", + "openVoting": "Open Voting", + "closeVoting": "Close Voting", + "revealAll": "Reveal All", + "revealAllConfirm": "Reveal all entries?", + "hideAll": "Hide All", + "hideAllConfirm": "Hide all entries?", + "reset": "Reset", + "openVotingConfirm": "Open voting for this compo?", + "closeVotingConfirm": "Close voting for this compo?", + "resetConfirm": "Reset all entries and close voting?", + "reveal": "Reveal", + "hide": "Hide", + "revealed": "Revealed", + "onScreen": "On Screen", + "votingEnd": "Voting ends:", + "votingTimeEnded": "Ended" + }, "VoteCodesView": { "title": "Ticket Vote Codes", "loadFailure": "Failed to load vote codes; try again later.", diff --git a/admin/src/locales/fi.json b/admin/src/locales/fi.json index c488a54a7..f07647986 100644 --- a/admin/src/locales/fi.json +++ b/admin/src/locales/fi.json @@ -112,6 +112,7 @@ "entries": "Tuotokset", "competitions": "Kilpailut", "competitionParticipations": "Osallistumiset", + "liveVoting": "Live-äänestys", "voteCodes": "Lippu-äänikoodit", "voteCodeRequests": "Äänikoodipyynnöt", "store": "Kauppa", @@ -236,13 +237,13 @@ "loadingCompos": "Ladataan kompoja ...", "headers": { "addingEnd": "Lähetystakaraja", - "votingStart": "Äänestyksen alku", "votingEnd": "Äänestyksen loppu" }, "filterByStatus": "Tila", "allStatuses": "Kaikki", "activeOnly": "Aktiiviset", - "inactiveOnly": "Ei-aktiiviset" + "inactiveOnly": "Ei-aktiiviset", + "liveVoting": "Live-äänestys" }, "CompoEditView": { "newTitle": "Uusi kompo", @@ -262,7 +263,6 @@ "addingEnd": "Lähetystakaraja", "editingEnd": "Muokkaustakaraja", "compoStart": "Kompon alkamisaika", - "votingStart": "Äänestyksen alkamisaika", "votingEnd": "Äänestyksen päättymisaika", "entrySizelimit": "Tuotoksen enimmäiskoko (tavua)", "sourceSizelimit": "Lähdekoodin enimmäiskoko (tavua)", @@ -277,10 +277,6 @@ "showVotingResultsOff": "Äänestystulokset ovat piilossa", "showVotingResultsHintOn": "Pisteet, sijoitukset ja tuotostiedostot ovat julkisesti näkyvissä", "showVotingResultsHintOff": "Pisteet ja sijoitukset ovat piilossa; tuotostiedostot piilotettu ennen äänestyksen alkua", - "isVotableOn": "Äänestys on käytössä", - "isVotableOff": "Äänestys on pois käytöstä", - "isVotableHintOn": "Käyttäjät voivat äänestää tuotoksia äänestysaikana", - "isVotableHintOff": "Äänestys on kokonaan pois käytöstä; käytä ulkoisesti arvioitavissa kompoissa (esim. Robocoding)", "entryViewType": "Tuotoksen näkymätyyppi", "thumbnailPref": "Pienoiskuva-asetus", "hideFromArchiveOn": "Piilotettu arkistosta", @@ -542,6 +538,27 @@ "disqualifiedReason": "Syy" } }, + "LiveVotingView": { + "title": "Live-äänestys", + "loadFailure": "Live-äänestyksen tilan lataus epäonnistui.", + "actionFailure": "Toiminto epäonnistui; yritä uudelleen.", + "openVoting": "Avaa äänestys", + "closeVoting": "Sulje äänestys", + "revealAll": "Paljasta kaikki", + "revealAllConfirm": "Paljasta kaikki tuotokset?", + "hideAll": "Piilota kaikki", + "hideAllConfirm": "Piilota kaikki tuotokset?", + "reset": "Nollaa", + "openVotingConfirm": "Avaa äänestys tälle kompolle?", + "closeVotingConfirm": "Sulje äänestys tälle kompolle?", + "resetConfirm": "Nollaa kaikki tuotokset ja sulje äänestys?", + "reveal": "Paljasta", + "hide": "Piilota", + "revealed": "Paljastettu", + "onScreen": "Ruudulla", + "votingEnd": "Äänestys päättyy:", + "votingTimeEnded": "Päättynyt" + }, "VoteCodesView": { "title": "Lippu-äänikoodit", "loadFailure": "Äänikoodien lataus epäonnistui; yritä myöhemmin uudelleen.", diff --git a/admin/src/router.ts b/admin/src/router.ts index 4a01eff1d..b2800b174 100644 --- a/admin/src/router.ts +++ b/admin/src/router.ts @@ -379,6 +379,17 @@ const router = createRouter({ props: true, component: () => import("@/views/kompomaatti/VoteCodesView.vue"), }, + // Live Voting + { + path: "/:eventId(\\d+)/live-voting/:compoId(\\d+)", + name: "live-voting", + meta: { + requireAuth: true, + requireViewPermission: PermissionTarget.LIVE_VOTING_STATE, + }, + props: true, + component: () => import("@/views/kompomaatti/LiveVotingView.vue"), + }, // Vote Code Requests { path: "/:eventId(\\d+)/vote-code-requests", diff --git a/admin/src/services/auth.ts b/admin/src/services/auth.ts index 0daa8f733..781ad23a1 100644 --- a/admin/src/services/auth.ts +++ b/admin/src/services/auth.ts @@ -45,6 +45,7 @@ export enum PermissionTarget { EVENT = "event", TICKET_VOTE_CODE = "ticketvotecode", VOTE = "vote", + LIVE_VOTING_STATE = "livevotingstate", VOTE_CODE_REQUEST = "votecoderequest", VOTE_GROUP = "votegroup", RECEIPT = "receipt", diff --git a/admin/src/views/kompomaatti/CompoEditView.test.ts b/admin/src/views/kompomaatti/CompoEditView.test.ts index 6b2c9cd9d..d6dbf290f 100644 --- a/admin/src/views/kompomaatti/CompoEditView.test.ts +++ b/admin/src/views/kompomaatti/CompoEditView.test.ts @@ -81,9 +81,9 @@ describe("CompoEditView", () => { // Tiptap editor for description expect(wrapper.find('[data-testid="tiptap"]').exists()).toBe(true); - // Datetime fields (5 total) + // Datetime fields (4 total) const datetimeInputs = wrapper.findAll('input[type="datetime-local"]'); - expect(datetimeInputs.length).toBe(5); + expect(datetimeInputs.length).toBe(4); // Size inputs (2) const numberInputs = wrapper.findAll('input[type="number"]'); @@ -97,8 +97,8 @@ describe("CompoEditView", () => { const selects = wrapper.findAllComponents({ name: "VSelect" }); expect(selects.length).toBe(4); - // Toggle switches (5) - expect(wrapper.findAll(".toggle-switch").length).toBe(5); + // Toggle switches (4) + expect(wrapper.findAll(".toggle-switch").length).toBe(4); }); it("submits correct snake_case data to API", async () => { @@ -113,8 +113,7 @@ describe("CompoEditView", () => { await datetimeInputs[0]!.setValue("2024-03-15T12:00"); await datetimeInputs[1]!.setValue("2024-03-15T18:00"); await datetimeInputs[2]!.setValue("2024-03-16T10:00"); - await datetimeInputs[3]!.setValue("2024-03-16T12:00"); - await datetimeInputs[4]!.setValue("2024-03-16T20:00"); + await datetimeInputs[3]!.setValue("2024-03-16T20:00"); await flushPromises(); await submitForm(wrapper); @@ -130,14 +129,12 @@ describe("CompoEditView", () => { expect("adding_end" in callBody!).toBe(true); expect("editing_end" in callBody!).toBe(true); expect("compo_start" in callBody!).toBe(true); - expect("voting_start" in callBody!).toBe(true); expect("voting_end" in callBody!).toBe(true); expect("entry_sizelimit" in callBody!).toBe(true); expect("source_sizelimit" in callBody!).toBe(true); expect("source_formats" in callBody!).toBe(true); expect("image_formats" in callBody!).toBe(true); expect("show_voting_results" in callBody!).toBe(true); - expect("is_votable" in callBody!).toBe(true); expect("entry_view_type" in callBody!).toBe(true); expect("thumbnail_pref" in callBody!).toBe(true); expect("hide_from_archive" in callBody!).toBe(true); @@ -155,8 +152,7 @@ describe("CompoEditView", () => { await datetimeInputs[0]!.setValue("2024-03-15T12:00"); await datetimeInputs[1]!.setValue("2024-03-15T18:00"); await datetimeInputs[2]!.setValue("2024-03-16T10:00"); - await datetimeInputs[3]!.setValue("2024-03-16T12:00"); - await datetimeInputs[4]!.setValue("2024-03-16T20:00"); + await datetimeInputs[3]!.setValue("2024-03-16T20:00"); // Set size value (first number input) const numberInputs = wrapper.findAll('input[type="number"]'); @@ -182,8 +178,7 @@ describe("CompoEditView", () => { await datetimeInputs[0]!.setValue("2024-03-15T12:00"); await datetimeInputs[1]!.setValue("2024-03-15T18:00"); await datetimeInputs[2]!.setValue("2024-03-16T10:00"); - await datetimeInputs[3]!.setValue("2024-03-16T12:00"); - await datetimeInputs[4]!.setValue("2024-03-16T20:00"); + await datetimeInputs[3]!.setValue("2024-03-16T20:00"); // Add formats via combobox (simulated) const comboboxes = wrapper.findAllComponents({ name: "VCombobox" }); @@ -208,7 +203,6 @@ describe("CompoEditView", () => { adding_end: "2024-03-15T12:00:00Z", editing_end: "2024-03-15T18:00:00Z", compo_start: "2024-03-16T10:00:00Z", - voting_start: "2024-03-16T12:00:00Z", voting_end: "2024-03-16T20:00:00Z", entry_sizelimit: 52428800, // 50 MB source_sizelimit: 10485760, // 10 MB @@ -217,7 +211,6 @@ describe("CompoEditView", () => { image_formats: "png|jpg", active: true, show_voting_results: false, - is_votable: true, entry_view_type: 0, thumbnail_pref: 1, hide_from_archive: false, @@ -245,7 +238,6 @@ describe("CompoEditView", () => { adding_end: "2024-03-15T12:00:00Z", editing_end: "2024-03-15T18:00:00Z", compo_start: "2024-03-16T10:00:00Z", - voting_start: "2024-03-16T12:00:00Z", voting_end: "2024-03-16T20:00:00Z", entry_sizelimit: 1073741824, // 1 GB source_sizelimit: null, @@ -254,7 +246,6 @@ describe("CompoEditView", () => { image_formats: "", active: true, show_voting_results: false, - is_votable: true, entry_view_type: 0, thumbnail_pref: 0, hide_from_archive: false, @@ -279,7 +270,6 @@ describe("CompoEditView", () => { adding_end: "2024-03-15T12:00:00Z", editing_end: "2024-03-15T18:00:00Z", compo_start: "2024-03-16T10:00:00Z", - voting_start: "2024-03-16T12:00:00Z", voting_end: "2024-03-16T20:00:00Z", entry_sizelimit: null, source_sizelimit: null, @@ -288,7 +278,6 @@ describe("CompoEditView", () => { image_formats: "", active: true, show_voting_results: false, - is_votable: true, entry_view_type: 0, thumbnail_pref: 0, hide_from_archive: false, @@ -337,8 +326,7 @@ describe("CompoEditView", () => { await datetimeInputs[0]!.setValue("2024-03-15T12:00"); await datetimeInputs[1]!.setValue("2024-03-15T18:00"); await datetimeInputs[2]!.setValue("2024-03-16T10:00"); - await datetimeInputs[3]!.setValue("2024-03-16T12:00"); - await datetimeInputs[4]!.setValue("2024-03-16T20:00"); + await datetimeInputs[3]!.setValue("2024-03-16T20:00"); await flushPromises(); await submitForm(wrapper); @@ -363,8 +351,7 @@ describe("CompoEditView", () => { await datetimeInputs[0]!.setValue("2024-03-15T12:00"); await datetimeInputs[1]!.setValue("2024-03-15T18:00"); await datetimeInputs[2]!.setValue("2024-03-16T10:00"); - await datetimeInputs[3]!.setValue("2024-03-16T12:00"); - await datetimeInputs[4]!.setValue("2024-03-16T20:00"); + await datetimeInputs[3]!.setValue("2024-03-16T20:00"); await flushPromises(); await submitForm(wrapper); @@ -387,9 +374,9 @@ describe("CompoEditView", () => { const wrapper = mountComponent({ eventId: "1" }); await flushPromises(); - // 5 toggle switches + // 4 toggle switches const toggles = wrapper.findAll(".toggle-switch"); - expect(toggles.length).toBe(5); + expect(toggles.length).toBe(4); }); it("has format comboboxes with common format suggestions", async () => { @@ -426,8 +413,7 @@ describe("CompoEditView", () => { await datetimeInputs[0]!.setValue("2024-03-15T12:00"); await datetimeInputs[1]!.setValue("2024-03-15T18:00"); await datetimeInputs[2]!.setValue("2024-03-16T10:00"); - await datetimeInputs[3]!.setValue("2024-03-16T12:00"); - await datetimeInputs[4]!.setValue("2024-03-16T20:00"); + await datetimeInputs[3]!.setValue("2024-03-16T20:00"); await flushPromises(); await submitForm(wrapper); @@ -451,8 +437,7 @@ describe("CompoEditView", () => { await datetimeInputs[0]!.setValue("2024-03-15T12:00"); await datetimeInputs[1]!.setValue("2024-03-15T18:00"); await datetimeInputs[2]!.setValue("2024-03-16T10:00"); - await datetimeInputs[3]!.setValue("2024-03-16T12:00"); - await datetimeInputs[4]!.setValue("2024-03-16T20:00"); + await datetimeInputs[3]!.setValue("2024-03-16T20:00"); await flushPromises(); await submitForm(wrapper); @@ -474,8 +459,7 @@ describe("CompoEditView", () => { await datetimeInputs[0]!.setValue("2024-03-15T12:00"); await datetimeInputs[1]!.setValue("2024-03-15T18:00"); await datetimeInputs[2]!.setValue("2024-03-16T10:00"); - await datetimeInputs[3]!.setValue("2024-03-16T12:00"); - await datetimeInputs[4]!.setValue("2024-03-16T20:00"); + await datetimeInputs[3]!.setValue("2024-03-16T20:00"); await submitForm(wrapper); @@ -489,7 +473,7 @@ describe("CompoEditView", () => { const textInputs = wrapper.findAll('input[type="text"]'); await textInputs[0]!.setValue("Demo Compo"); - // Only fill 3 of 5 datetime fields + // Only fill 3 of 4 datetime fields const datetimeInputs = wrapper.findAll('input[type="datetime-local"]'); await datetimeInputs[0]!.setValue("2024-03-15T12:00"); await datetimeInputs[1]!.setValue("2024-03-15T18:00"); @@ -511,8 +495,7 @@ describe("CompoEditView", () => { await datetimeInputs[0]!.setValue("2024-03-15T12:00"); await datetimeInputs[1]!.setValue("2024-03-15T18:00"); await datetimeInputs[2]!.setValue("2024-03-16T10:00"); - await datetimeInputs[3]!.setValue("2024-03-16T12:00"); - await datetimeInputs[4]!.setValue("2024-03-16T20:00"); + await datetimeInputs[3]!.setValue("2024-03-16T20:00"); // Don't fill size limits @@ -533,8 +516,7 @@ describe("CompoEditView", () => { await datetimeInputs[0]!.setValue("2024-03-15T12:00"); await datetimeInputs[1]!.setValue("2024-03-15T18:00"); await datetimeInputs[2]!.setValue("2024-03-16T10:00"); - await datetimeInputs[3]!.setValue("2024-03-16T12:00"); - await datetimeInputs[4]!.setValue("2024-03-16T20:00"); + await datetimeInputs[3]!.setValue("2024-03-16T20:00"); // Don't fill formats diff --git a/admin/src/views/kompomaatti/CompoEditView.vue b/admin/src/views/kompomaatti/CompoEditView.vue index a2767eb77..463604a6d 100644 --- a/admin/src/views/kompomaatti/CompoEditView.vue +++ b/admin/src/views/kompomaatti/CompoEditView.vue @@ -45,7 +45,7 @@ - + - - - - + - ("description"); const addingEnd = useField("addingEnd"); const editingEnd = useField("editingEnd"); const compoStart = useField("compoStart"); -const votingStart = useField("votingStart"); const votingEnd = useField("votingEnd"); const entrySizelimit = useField("entrySizelimit"); const sourceSizelimit = useField("sourceSizelimit"); @@ -429,7 +405,6 @@ const sourceFormats = useField("sourceFormats"); const imageFormats = useField("imageFormats"); const active = useField("active"); const showVotingResults = useField("showVotingResults"); -const isVotable = useField("isVotable"); const entryViewType = useField("entryViewType"); const thumbnailPref = useField("thumbnailPref"); const hideFromArchive = useField("hideFromArchive"); @@ -470,7 +445,6 @@ function buildBody(values: GenericObject) { adding_end: toISODatetime(values.addingEnd)!, editing_end: toISODatetime(values.editingEnd)!, compo_start: toISODatetime(values.compoStart)!, - voting_start: toISODatetime(values.votingStart)!, voting_end: toISODatetime(values.votingEnd)!, entry_sizelimit: values.entrySizelimit, source_sizelimit: values.sourceSizelimit, @@ -479,7 +453,6 @@ function buildBody(values: GenericObject) { image_formats: values.imageFormats || "", active: values.active, show_voting_results: values.showVotingResults, - is_votable: values.isVotable, entry_view_type: values.entryViewType, thumbnail_pref: values.thumbnailPref, hide_from_archive: values.hideFromArchive, @@ -534,7 +507,6 @@ onMounted(async () => { addingEnd: toLocalDatetime(item.adding_end), editingEnd: toLocalDatetime(item.editing_end), compoStart: toLocalDatetime(item.compo_start), - votingStart: toLocalDatetime(item.voting_start), votingEnd: toLocalDatetime(item.voting_end), entrySizelimit: item.entry_sizelimit ?? null, sourceSizelimit: item.source_sizelimit ?? null, @@ -543,7 +515,6 @@ onMounted(async () => { imageFormats: item.image_formats ?? "", active: item.active ?? true, showVotingResults: item.show_voting_results ?? false, - isVotable: item.is_votable ?? true, entryViewType: item.entry_view_type ?? 0, thumbnailPref: item.thumbnail_pref ?? 0, hideFromArchive: item.hide_from_archive ?? false, diff --git a/admin/src/views/kompomaatti/ComposView.vue b/admin/src/views/kompomaatti/ComposView.vue index adbeb3c0b..42039575b 100644 --- a/admin/src/views/kompomaatti/ComposView.vue +++ b/admin/src/views/kompomaatti/ComposView.vue @@ -61,9 +61,6 @@ - @@ -71,6 +68,19 @@