From 31f64e3c7a1375fa4e6f861b69f7554e86d17a10 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:06:01 -0300 Subject: [PATCH 01/11] refactor(backend): remove ChooseDelegate related code --- backend/app/session/engine.py | 8 -------- backend/app/session/enums.py | 1 - backend/app/session/schemas.py | 12 ------------ 3 files changed, 21 deletions(-) diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py index 75eab0f..2b45499 100644 --- a/backend/app/session/engine.py +++ b/backend/app/session/engine.py @@ -39,7 +39,6 @@ class InvalidProceduralMove(Exception): Creating a committee should assign a chair as it's admin. Only Secretariats can create a session for a committee OPEN_SESSION / SETUP: setup state. Websockets are already made, thus possible events are -- ChooseDelegation: made from Delegates from a list of possible ones (so a list of possible delegations should be present either in server/frontend) - EditSetup: chair edits setup such as: - extends session: (extends an already made session, or unpause session?) - speaking_time @@ -48,7 +47,6 @@ class InvalidProceduralMove(Exception): - topics - may be skipped during initial testings -ChooseDelegation should not be possible anymore (at least not for now) ROLL_CALL: roll call/ quorum count state. Possible events are: - SetVotingChoiceEvent: by Delegate @@ -327,12 +325,6 @@ def handle_cast_vote( return state -# TODO: remove this -def handle_choose_delegation( - state: SessionLiveState, event: schemas.ChooseDelegateEvent, actor: SessionActor -) -> SessionLiveState: ... - - def handle_answer_roll_call( state: SessionLiveState, event: schemas.AnswerRollCallEvent, actor: SessionActor ) -> SessionLiveState: diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py index 7e0f8d4..2c83e4b 100644 --- a/backend/app/session/enums.py +++ b/backend/app/session/enums.py @@ -30,7 +30,6 @@ class DelegateEvents(StrEnum): JOIN_QUEUE = "JoinQueueEvent" LEAVE_QUEUE = "LeaveQueueEvent" CAST_VOTE = "CastVoteEvent" - CHOOSE_DELEGATION = "ChooseDelegateEvent" YIELD_SPEAKING = "YieldEvent" ANSWER_ROLLCALL = "AnswerRollCallEvent" diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py index 4c97e89..398d0f0 100644 --- a/backend/app/session/schemas.py +++ b/backend/app/session/schemas.py @@ -38,11 +38,6 @@ class DelegateVotingPayload(BaseModel): vote: enums.VotingChoice -# TODO: should be better implemented -class ChooseDelegatePayload(BaseModel): - choice: str - - # TODO: have a separate AbsentMyselfEvent for this thing here class AnswerRollCallPayload(BaseModel): choice: Literal[ @@ -66,12 +61,6 @@ class CastVoteEvent(BaseModel): payload: DelegateVotingPayload -# TODO: should be better implemented -class ChooseDelegateEvent(BaseModel): - type: Literal[enums.DelegateEvents.CHOOSE_DELEGATION] - payload: ChooseDelegatePayload - - class JoinQueueEvent(BaseModel): type: Literal[enums.DelegateEvents.JOIN_QUEUE] payload: dict = {} @@ -253,7 +242,6 @@ class DeleteAgendaItemEvent(BaseModel): SubmitMotionEvent | SubmitQuestionEvent | CastVoteEvent - | ChooseDelegateEvent | AnswerRollCallEvent | JoinQueueEvent | LeaveQueueEvent From 7be92acf57d661ae570e7ac283ac5c4897f6910c Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:11:00 -0300 Subject: [PATCH 02/11] feat(backend): implement get_*_priority helpers --- backend/app/session/engine.py | 83 +++++++++--------------- backend/app/tests/session/test_engine.py | 38 ++++++----- 2 files changed, 54 insertions(+), 67 deletions(-) diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py index 2b45499..77e61cc 100644 --- a/backend/app/session/engine.py +++ b/backend/app/session/engine.py @@ -33,55 +33,6 @@ class InvalidProceduralMove(Exception): pass -""" -This will document the flow of states the debates will have. As well as document an initial engine - -Creating a committee should assign a chair as it's admin. Only Secretariats can create a session for a committee - -OPEN_SESSION / SETUP: setup state. Websockets are already made, thus possible events are -- EditSetup: chair edits setup such as: - - extends session: (extends an already made session, or unpause session?) - - speaking_time - - can_set_motions (during specific debates) - - default_state (either GSL or Moderated, should be put later) - - topics - - may be skipped during initial testings - - -ROLL_CALL: roll call/ quorum count state. Possible events are: - - SetVotingChoiceEvent: by Delegate - - QuestionEvent - - Any Chair Event: since most are disruptive - -To user, INITIAL_DEBATE and OPEN_GSL should look mostly the same -INITIAL_DEBATE: after roll call, if no agenda is set and/or no speaking time for GSL is set, go to this state. It's an initial speakers list. Possible events are: - - Motions: subset - (POSTPONE, END, QUORUM, SETSPEAKINGTIME) - - Special action: "Propose Topics" - - QuestionEvent - - Any Chair Event - - CastInformalVoteEvent -Queue should not be open to speak. Delegates may only speak in motions - -OPEN_GSL: default state in general. Queue is open and all motions can be made. The topic is defaulted to the first one in the order of agenda_topics and index_topic. - - In particular, YieldEvent must be enabled and configured. - -Specific Debates: (Moderated, Unmoderated, Tour) Queue not enabled. Each delegation should raise their placard and popups a "selection" on map - - Motions may only be put if set_motions is enabled. - - In particular, Unmoderated should not have a queue/motions enabled at all, but this may be implemented later - -VOTING_EXECUTION: Voting on a procedural motion. - -CLOSED_GSL: after a successfull 'Close Speakers List'. Queue is disabled. - -VOTING_PREPARATION: ambiguous state after an "close debate" has either entered as a motion, or "move into voting procedures". Perhaps doesnt need to be added? - -VOTING_PROCEDURES: special case of voting on resolutions, etc. Substantive votes. - -FINISHED: when topics get empty automatically, or chair decides to close session. may be reverted. - -This will give some insight into what should or should not be possible during each event -""" - # Dispatch tables: alternative to if else chains MOTIONS_ALLOWED: dict[States, set[Motions]] = { States.INITIAL_DEBATE: { @@ -176,10 +127,39 @@ def validate_question_payload( def tally_votes(voting: VotingContext) -> bool: ... -def get_motion_priority(motion: Motions) -> int: ... +def get_motion_priority(motion: Motions) -> int | None: + """Given a motion, return it's priority. + Some motions are tied. Ex: Change Debate and Tour de Table""" + priority_map = { + Motions.POSTPONE_SESSION: 1, + Motions.REOPEN_SESSION: 2, + Motions.CHANGE_DEBATE_TYPE: 3, + Motions.TOUR_DE_TABLE: 3, + Motions.END_DEBATE: 4, + Motions.VOTE_AMENDMENT: 4, + Motions.CLOSE_SPEAKERS_LIST: 5, + Motions.REOPEN_SPEAKERS_LIST: 5, + Motions.SPLIT_PROPOSAL: 6, + Motions.INTRODUCE_RESOLUTION_PROPOSAL: 7, + Motions.INTRODUCE_AMENDMENT_PROPOSAL: 8, + Motions.VOTE_BY_ROLL_CALL: 9, + Motions.QUORUM: 10, + Motions.CHANGE_TOPIC: 11, + Motions.CUSTOM_MOTION: 12, + } + + return priority_map.get(motion) -def get_question_priority(question: Questions) -> int: ... +def get_question_priority(question: Questions) -> int | None: + """Given a question, return it's priority""" + priority_map = { + Questions.PERSONAL_PRIVILEGE: 1, + Questions.ORDER: 2, + Questions.QUESTION: 3, + } + + return priority_map.get(question) def get_default_speaker_seconds(state: SessionLiveState) -> int | None: @@ -788,7 +768,6 @@ def handle_insert_queue( DelegateEvents.JOIN_QUEUE: handle_join_queue, DelegateEvents.LEAVE_QUEUE: handle_leave_queue, DelegateEvents.CAST_VOTE: handle_cast_vote, - DelegateEvents.CHOOSE_DELEGATION: handle_choose_delegation, DelegateEvents.ANSWER_ROLLCALL: handle_answer_roll_call, ChairEvents.OPEN_SESSION: handle_open_session, ChairEvents.INCREASE_TIMER: handle_increase_timer, diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py index f2b2388..f196863 100644 --- a/backend/app/tests/session/test_engine.py +++ b/backend/app/tests/session/test_engine.py @@ -16,10 +16,10 @@ def open_gsl_state(session_state: md.SessionLiveState) -> md.SessionLiveState: def voting_state(session_state: md.SessionLiveState) -> md.SessionLiveState: session_state.current_state = enums.States.VOTING_EXECUTION session_state.voting = md.VotingContext( - target_type="INFORMAL", + target_type=enums.VotingType.INFORMAL, return_state=enums.States.OPEN_GSL, voting_registry={}, - majority="SIMPLE", + majority=enums.MajorityTypes.SIMPLE, veto_power=False, ) return session_state @@ -67,7 +67,7 @@ def leave_queue_event() -> sch.LeaveQueueEvent: def cast_vote_event() -> sch.CastVoteEvent: return sch.CastVoteEvent( type=enums.DelegateEvents.CAST_VOTE, - payload=sch.DelegateVotingPayload(type="FORMAL", vote="FAVOUR"), + payload=sch.DelegateVotingPayload(vote=enums.VotingChoice.FAVOUR), ) @@ -109,7 +109,7 @@ def open_informal_voting_event() -> sch.OpenInformalVotingEvent: type=enums.ChairEvents.OPEN_INFORMAL_VOTING, payload=sch.ChairOpenInformalVotingPayload( title="Straw poll", - majority="SIMPLE", + majority=enums.MajorityTypes.SIMPLE, veto_power=False, ), ) @@ -150,11 +150,11 @@ def procedural_voting_state( ) -> md.SessionLiveState: open_gsl_state.current_state = enums.States.VOTING_EXECUTION open_gsl_state.voting = md.VotingContext( - target_type="PROCEDURAL", + target_type=enums.VotingType.PROCEDURAL, motion_in_vote=close_speakers_list_motion, return_state=enums.States.OPEN_GSL, voting_registry={}, - majority="SIMPLE", + majority=enums.MajorityTypes.SIMPLE, veto_power=False, ) return open_gsl_state @@ -164,7 +164,7 @@ def procedural_voting_state( def resolve_motion_event() -> sch.ResolveMotionEvent: return sch.ResolveMotionEvent( type=enums.ChairEvents.RESOLVE_MOTION, - payload=sch.ChairResolveMotionPayload(motion_id=1, action="ACCEPT"), + payload=sch.ChairResolveMotionPayload(motion_id=1, action=True), ) @@ -232,7 +232,16 @@ def manual_phase_set_event() -> sch.SetPhaseEvent: ) -@pytest.mark.xfail(strict=True, reason="get_motion_priority is not implemented.") +def test_get_motion_priority() -> None: + motion = enums.Motions.END_DEBATE + assert eng.get_motion_priority(motion) == 4 + + +def test_get_question_priority() -> None: + question = enums.Questions.QUESTION + assert eng.get_question_priority(question) == 3 + + def test_delegate_can_submit_motion_in_open_gsl( engine: eng.SessionEngine, open_gsl_state: md.SessionLiveState, @@ -244,7 +253,7 @@ def test_delegate_can_submit_motion_in_open_gsl( assert len(state.submitted_motions) == 1 assert state.submitted_motions[0].id == 1 assert state.submitted_motions[0].type == enums.Motions.CHANGE_DEBATE_TYPE - assert state.submitted_motions[0].delegate_id == 1 + assert state.submitted_motions[0].delegate_id == delegate_actor.delegation.id # type: ignore[union-attr] def test_delegate_cannot_submit_motion_outside_allowed_phase( @@ -271,7 +280,6 @@ def test_chair_cannot_submit_delegate_motion( engine.dispatch(open_gsl_state, submit_debate_motion_event, chair_actor) -@pytest.mark.xfail(strict=True, reason="get_question_priority is not implemented.") def test_delegate_can_submit_question( engine: eng.SessionEngine, session_state: md.SessionLiveState, @@ -283,7 +291,7 @@ def test_delegate_can_submit_question( assert len(state.submitted_questions) == 1 assert state.submitted_questions[0].id == 1 assert state.submitted_questions[0].type == enums.Questions.PERSONAL_PRIVILEGE - assert state.submitted_questions[0].delegate_id == 1 + assert state.submitted_questions[0].delegate_id == delegate_actor.delegation.id # type: ignore[union-attr] def test_delegate_can_join_queue( @@ -361,7 +369,7 @@ def test_delegate_can_cast_vote( state = engine.dispatch(voting_state, cast_vote_event, delegate_actor) assert state.voting is not None - assert state.voting.voting_registry == {0: "FAVOUR"} + assert state.voting.voting_registry == {0: enums.VotingChoice.FAVOUR} def test_delegate_cannot_cast_vote_twice( @@ -491,7 +499,7 @@ def test_chair_can_open_informal_voting( assert state.current_state == enums.States.VOTING_EXECUTION assert state.voting is not None - assert state.voting.target_type == "INFORMAL" + assert state.voting.target_type == enums.VotingType.INFORMAL assert state.voting.title == "Straw poll" assert state.voting.return_state == enums.States.OPEN_GSL @@ -541,7 +549,7 @@ def test_chair_can_resolve_motion_into_procedural_voting( assert state.current_state == enums.States.VOTING_EXECUTION assert state.voting is not None - assert state.voting.target_type == "PROCEDURAL" + assert state.voting.target_type == enums.VotingType.PROCEDURAL assert state.voting.motion_in_vote == close_speakers_list_motion assert state.submitted_motions == [] @@ -555,7 +563,7 @@ def test_chair_can_deny_motion_without_opening_vote( open_gsl_state.submitted_motions.append(close_speakers_list_motion) event = sch.ResolveMotionEvent( type=enums.ChairEvents.RESOLVE_MOTION, - payload=sch.ChairResolveMotionPayload(motion_id=1, action="DENY"), + payload=sch.ChairResolveMotionPayload(motion_id=1, action=False), ) state = engine.dispatch(open_gsl_state, event, chair_actor) From d29c539d4188d792ef3dafccb0bac0ba7163443f Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:11:36 -0300 Subject: [PATCH 03/11] style(backend): fix formatting and linting issues --- backend/app/access/enums.py | 3 ++- backend/app/access/models.py | 3 ++- backend/app/access/schemas.py | 4 +++- backend/app/access/views.py | 7 +++---- backend/app/session/enums.py | 4 ++++ backend/app/session/models.py | 1 - backend/app/session/schemas.py | 5 ----- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/backend/app/access/enums.py b/backend/app/access/enums.py index 883d870..ef6dd71 100644 --- a/backend/app/access/enums.py +++ b/backend/app/access/enums.py @@ -1,5 +1,6 @@ from enum import StrEnum + class SessionRoles(StrEnum): CHAIR = "chair" - DELEGATION = "delegate" \ No newline at end of file + DELEGATION = "delegate" diff --git a/backend/app/access/models.py b/backend/app/access/models.py index a088422..8881a43 100644 --- a/backend/app/access/models.py +++ b/backend/app/access/models.py @@ -1,8 +1,9 @@ from dataclasses import dataclass -from typing import Literal from uuid import UUID + from . import enums + @dataclass(frozen=True) class CommitteeAssignment: """Object that holds info about an UUID to a commitee and Delegation / Chair""" diff --git a/backend/app/access/schemas.py b/backend/app/access/schemas.py index e586898..864dfb0 100644 --- a/backend/app/access/schemas.py +++ b/backend/app/access/schemas.py @@ -1,6 +1,8 @@ from pydantic import BaseModel + from . import enums + class SessionRepresentation(BaseModel): role: enums.SessionRoles - representation_id: int | None \ No newline at end of file + representation_id: int | None diff --git a/backend/app/access/views.py b/backend/app/access/views.py index 7983f3f..f707efd 100644 --- a/backend/app/access/views.py +++ b/backend/app/access/views.py @@ -7,19 +7,18 @@ from app.auth.service import AuthUser from app.core.database import get_db_session -from .service import AccessDenied, resolve_session_assignment from .schemas import SessionRepresentation +from .service import AccessDenied, resolve_session_assignment router = APIRouter() - @router.get("/sessions/{session_id}/me", response_model=SessionRepresentation) async def get_my_session_access( session_id: int, db_session: Annotated[AsyncSession, Depends(get_db_session)], current_user: Annotated[AuthUser, Depends(get_current_user)], -)->SessionRepresentation: +) -> SessionRepresentation: """Return the authenticated user's actor context for a session.""" try: assignment = await resolve_session_assignment( @@ -34,4 +33,4 @@ async def get_my_session_access( return { "role": assignment.role, "representation_id": assignment.representation_id, - } + } diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py index 2c83e4b..aaa0116 100644 --- a/backend/app/session/enums.py +++ b/backend/app/session/enums.py @@ -95,21 +95,25 @@ class RollCallChoice(StrEnum): PRESENT_AND_VOTING = "Present and Voting" ABSENT = "Absent" + class MajorityTypes(StrEnum): SIMPLE = "Maioria Simples" QUALIFIED = "Maioria Qualificada" ABSOLUTE = "Consenso" + class SessionRole(StrEnum): CHAIR = "CHAIR" DELEGATE = "DELEGATE" # further roles are put here + class VotingChoice(StrEnum): FAVOUR = "Favour" AGAINST = "Against" ABSTAIN = "Abstain" + class VotingType(StrEnum): INFORMAL = "Informal" PROCEDURAL = "Procedural" diff --git a/backend/app/session/models.py b/backend/app/session/models.py index 22f05b6..84140f2 100644 --- a/backend/app/session/models.py +++ b/backend/app/session/models.py @@ -2,7 +2,6 @@ # Even though it's internal, some things may be sent out to public (TODO:like SessionLiveState) from dataclasses import dataclass from datetime import datetime -from typing import Literal from pydantic import BaseModel diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py index 398d0f0..239a93c 100644 --- a/backend/app/session/schemas.py +++ b/backend/app/session/schemas.py @@ -128,11 +128,6 @@ class MarkAgendaItemPayload(BaseModel): class DeleteAgendaItemPayload(BaseModel): index: str # Agenda Item Id -#Removed: Unnecessary -# These two normally don't need to have an id -# class ChairCloseInformalVotingPayload(BaseModel): -# voting_id: int | None = None - class EmptyPayload(BaseModel): ... From 1d2ede7c1e433e17d5e48073bcf08a8df7818b0c Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:12:16 -0300 Subject: [PATCH 04/11] docs: add committee technical & product workflow --- docs/architecture.md | 10 +- docs/index.md | 6 +- docs/realtime/mun-flow.md | 202 ++++++++++++++++++++++++++++++++++++++ docs/realtime/overview.md | 3 + mkdocs.yml | 1 + 5 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 docs/realtime/mun-flow.md diff --git a/docs/architecture.md b/docs/architecture.md index 57bc893..8b2220b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,8 @@ Authenticated HTTP requests use that token as a Bearer token, while the session WebSocket sends it after connecting. Route guards improve navigation, but the backend is still responsible for authorization. For a fuller guide on these conventions, see -[Frontend development](frontend-development.md). +[Frontend state management](frontend/state-management.md) and +[Frontend authentication](frontend/authentication.md). ## The backend @@ -134,6 +135,7 @@ When in doubt, follow the direction of dependencies: views call services, and se - [Getting started](getting-started.md) explains how to run the application locally. -- [Authentication](authentication.md) will cover the frontend Supabase Auth - flow and backend identity checks. -- [Real-time sessions](realtime-sessions.md) will describe session events, payloads, and state snapshots in detail. +- [Authentication and authorization](backend/authentication-and-authorization.md) + covers Supabase Auth and backend identity checks. +- [MUN committee flow and technical approach](realtime/mun-flow.md) maps the + committee procedure to the state machine, events, and snapshots. diff --git a/docs/index.md b/docs/index.md index ca4be4b..af1e12a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,11 +15,6 @@ Our stack is currently composed of: - (Planned) Redis for in-memory kv store - (Planned) Terraform for infra setup and management -## What's next? - -Use the following items to guide yourself through these docs. - -TODO # WebMUN developer documentation WebMUN is a real-time web application for running Model United Nations @@ -43,6 +38,7 @@ server. - [Architecture](architecture.md) - Real-time sessions - [Overview](realtime/overview.md) + - [MUN committee flow and technical approach](realtime/mun-flow.md) - [Events and payloads](realtime/events-and-payloads.md) - [Session state](realtime/session-state.md) - Backend diff --git a/docs/realtime/mun-flow.md b/docs/realtime/mun-flow.md new file mode 100644 index 0000000..d894067 --- /dev/null +++ b/docs/realtime/mun-flow.md @@ -0,0 +1,202 @@ +# MUN committee flow and technical approach + +This page describes the top-level flow of a typical Model United Nations +(MUN) committee and how WebMUN represents it. It is a product and engineering +guide: conference rules of procedure vary, so the chair remains responsible +for applying the committee's adopted rules. + +The backend is the source of truth. A user interface may guide a delegate or +chair toward actions that make sense in the displayed phase, but only the +backend can accept an action and move the committee forward. + +## Committee flow at a glance + +```text +Setup Room + | + | chair opens the session + v +Roll Call + | + | chair closes roll call and establishes attendance/voting eligibility + v +Open GSL <-------------------------------+ + | | + | delegates join the speakers list | informal vote closes + | chair selects speakers | + | | + +-- procedural motion --> Voting Execution + | | | + | | passed | denied + | v | + | Open/Closed GSL, | + | caucus, roll call, | + | or voting procedures| + | | + +-- chair opens informal vote ----------+ + | + +-- accepted debate-type motion --> Moderated/Unmoderated Caucus + | + | next implemented transition + v + procedural motion / chair action + +Open or Closed GSL -- accepted end-debate motion --> Voting Procedures + | + | substantive-resolution flow + | is not implemented yet + v + Finished +``` + +`Finished` can also be reached when the chair closes the session from an +allowed phase. The diagram is deliberately a workflow view, not a complete +transition table: an informal vote can be opened by the chair from any phase +other than `Voting Execution`, and returns to the phase from which it opened. + +## Typical procedure, mapped to WebMUN + +### 1. Prepare the session + +Before formal business, durable conference data identifies the committee, +participants, and chair assignments. Starting a live session creates a +`SessionLiveState` in `Setup Room`. This live state contains the delegations, +agenda context, timer, queues, roll call, motions, and voting information used +while the committee is active. + +The chair sends `OpenSessionEvent` to move the session to `Roll Call`. + +### 2. Take roll call and establish quorum + +During `Roll Call`, the chair can mark a representation as `Present`, +`Present and Voting`, or `Absent`. A delegate may report their own presence +with `AnswerRollCallEvent`; the chair remains able to correct the record. + +When the chair sends `CloseRollCallEvent`, any unrecorded representation is +marked absent. WebMUN then derives `voting_choice` from the recorded present +representations and moves to `Open GSL`. The identity used here and throughout +the flow is `representation_id`, never a client-asserted country or delegate +identity. + +### 3. Conduct general debate + +`Open GSL` is the normal working phase. Delegates may join or leave the +General Speakers List (GSL); the chair can insert a representation or select +the next speaker. Speaker selection sets the timer; timer controls are chair +actions. Delegates can submit motions and questions for chair consideration. + +The chair may accept or deny a submitted motion. Accepting it opens a +procedural vote in `Voting Execution`; denying it removes it without changing +the phase. A failed procedural vote returns to the earlier phase. + +An accepted motion can currently produce these transitions: + +- change debate type to a moderated or unmoderated caucus; +- close or reopen the GSL; +- reopen roll call to check quorum; or +- end debate and enter `Voting Procedures`. + +Some defined motion types are not yet given a concrete effect by the engine. +They must not be presented as a completed procedure until their transition and +data model are implemented. + +### 4. Run caucuses + +A moderated caucus has an overall debate duration and an individual speaking +time. A chair chooses speakers; the GSL queue is not used for the automatic +order. An unmoderated caucus has an overall duration but no individual speaker +timer. + +The current engine does not automatically advance when a caucus duration +expires. Returning to the prior phase and the intended handling of a tour de +table need explicit implementation. Until then, a chair uses only supported +events and the server validates every request against the current phase. + +### 5. Vote + +WebMUN currently supports two distinct voting mechanisms: + +- **Procedural voting:** accepting a submitted motion creates a vote. Closing + it tallies the vote and applies the supported motion transition, or restores + the previous phase. +- **Informal voting:** the chair may open a named, non-procedural vote. It + uses `Voting Execution` temporarily and returns to its origin phase when + closed; it does not itself apply a procedural transition. + +`Voting Procedures` is the intended destination after an accepted +end-debate motion. Substantive voting on resolutions, amendments, and related +final outcomes is not implemented yet. This is a known boundary, rather than +a promise that the state name alone provides a complete resolution workflow. + +### 6. Close the session + +The chair can send `CloseSessionEvent` from the phases accepted by the engine, +which clears active debate and timer state and moves the session to `Finished`. + +## Technical approach + +### One authoritative state machine + +`SessionLiveState` is the active committee state machine. A transition reads +the current state, verifies the actor and payload, and updates this state. This +makes a single server—not several browser tabs—the authority for speaker +order, attendance, votes, and phase transitions. + +The state includes both the phase and the context required to continue it: + +- `delegations`, keyed by `representation_id`; +- roll-call and voting eligibility; +- GSL, current speaker, and timer; +- caucus/debate context; +- submitted motions and questions; and +- the current voting context and the phase to return to. + +### Commands in, snapshots out + +Clients connect to the session WebSocket, authenticate with a Supabase JWT, +and receive a complete initial snapshot. They send an event envelope such as +`{ "type": "JoinQueueEvent", "payload": {} }`. The WebSocket handler builds a +server-side actor from the JWT and committee assignment, dispatches the event +to the session engine, then broadcasts the next complete snapshot. + +```text +authenticated client request + -> WebSocket handler builds SessionActor + -> engine validates role, representation IDs, and current phase + -> in-memory SessionLiveState changes + -> updated snapshot is broadcast to all connected clients +``` + +The frontend replaces its shared session state with each received snapshot. It +does not optimistically edit the shared state, because another accepted event +may have changed the phase or queue first. This is also why a UI permission +check is a convenience only; authorization is enforced by the backend. + +### Persistence boundary today + +Activating a session stores its initial snapshot in PostgreSQL, and a missing +in-memory room can be reconstructed from that stored snapshot. The current +per-event handler updates `ConnectionManager.room_states` and broadcasts it, +but does **not** persist the changed snapshot after every accepted event. +Consequently, a process restart can recover the last stored snapshot rather +than the latest live action. Persisting accepted state atomically before (or +with) broadcast is required before durable live-session recovery can be +claimed. + +### Extending the flow safely + +When adding a new committee rule, define its phase preconditions, actor role, +required `representation_id` references, state transition, and recovery or +persistence impact before adding a UI control. Add the event schema and engine +handler together, then document the flow change here and in the event +reference. Because live snapshots are persisted, changes to +`SessionLiveState` need a compatibility or migration path. Event persistence +should be part of that design, rather than being left to the frontend. + +## Related references + +- [Real-time overview](overview.md) explains the WebSocket protocol. +- [Events and payloads](events-and-payloads.md) is the event-level reference. +- [Session state](session-state.md) lists the snapshot fields. +- [Architecture](../architecture.md) explains the frontend, backend, and + persistence boundaries. diff --git a/docs/realtime/overview.md b/docs/realtime/overview.md index c0bfefe..cb59378 100644 --- a/docs/realtime/overview.md +++ b/docs/realtime/overview.md @@ -40,6 +40,9 @@ own role or identity as authority. ## Where to look next +- [MUN committee flow and technical approach](mun-flow.md) maps a typical + committee procedure to the current state machine and records its implemented + boundaries. - [Events and payloads](events-and-payloads.md) is the searchable event reference for frontend and backend development. - [Session state](session-state.md) describes the snapshot returned by the diff --git a/mkdocs.yml b/mkdocs.yml index 5124b36..eb256a6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,5 +17,6 @@ nav: - Architecture: architecture.md - Real-time: - Overview: realtime/overview.md + - MUN committee flow: realtime/mun-flow.md - Events and payloads: realtime/events-and-payloads.md - Session state: realtime/session-state.md From c5c18c4d1095d8cb8aa66cbd58fe5587583daeb1 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:45:27 -0300 Subject: [PATCH 05/11] feat(backend): add tally votes Closes #33 --- backend/app/session/engine.py | 47 ++++++++++++++++++++++-- backend/app/session/models.py | 4 ++ backend/app/tests/session/test_engine.py | 13 +++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py index 77e61cc..544ec12 100644 --- a/backend/app/session/engine.py +++ b/backend/app/session/engine.py @@ -5,6 +5,7 @@ import app.session.enums as enums import app.session.schemas as schemas +from math import ceil from .enums import ( ChairEvents, @@ -121,10 +122,49 @@ def validate_motion_payload( def validate_question_payload( payload: schemas.DelegateQuestionPayload, state: SessionLiveState -) -> None: ... +) -> None: + ... + + +def count_present_delegations(state: SessionLiveState) -> int: + """Count total present delegations. + A delegation is considered present (even if AFK) + if it's Roll Call Choice is Present / Present and Voting""" + if state.voting_choice is None: + return 0 + + return len([True for _, vote in state.voting_choice.items() + if vote == enums.RollCallChoice.PRESENT + or vote == enums.RollCallChoice.PRESENT_AND_VOTING]) -def tally_votes(voting: VotingContext) -> bool: ... +def tally_votes(voting: VotingContext, total_presents: int) -> bool: + qualified_motions =( + Motions.POSTPONE_SESSION, Motions.CHANGE_DEBATE_TYPE, + Motions.TOUR_DE_TABLE, Motions.CLOSE_SPEAKERS_LIST, + Motions.SPLIT_PROPOSAL + ) + """Helper for computing votes. + Unless motion is explicitly requiring qualified majority, + will use simple majority (also counts for informal votes)""" + + simple = ceil(total_presents / 2) + qualified = ceil(0.66 * total_presents) + in_favor_count = len([True + for _, vote in voting.voting_registry.items() + if vote == enums.VotingChoice.FAVOUR]) + motion = voting.motion_in_vote + + if motion is None: + return in_favor_count >= simple + + # Use qualified majority for "important" motions + if motion.type in qualified_motions and in_favor_count >= qualified: + return True + elif motion.type not in qualified_motions and in_favor_count >= simple: + return True + + return False def get_motion_priority(motion: Motions) -> int | None: @@ -132,6 +172,7 @@ def get_motion_priority(motion: Motions) -> int | None: Some motions are tied. Ex: Change Debate and Tour de Table""" priority_map = { Motions.POSTPONE_SESSION: 1, + Motions.REOPEN_SESSION: 2, Motions.CHANGE_DEBATE_TYPE: 3, Motions.TOUR_DE_TABLE: 3, @@ -478,7 +519,7 @@ def handle_close_procedural_voting( if motion is None: raise InvalidProceduralMove("Can't close voting if motion is None") - passed = tally_votes(state.voting) + passed = tally_votes(state.voting, count_present_delegations(state)) # TODO: pass everything here into a helper "apply_passed_motion" and "apply_change_debate" if passed: diff --git a/backend/app/session/models.py b/backend/app/session/models.py index 84140f2..1375c1a 100644 --- a/backend/app/session/models.py +++ b/backend/app/session/models.py @@ -139,3 +139,7 @@ class SessionLiveState(BaseModel): voting_choice: dict[int, enums.RollCallChoice] | None = None # DelegationId as key roll_call: RollCallContext # Not None, even if registry is empty + + # Additional config + has_veto_power: bool = False + diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py index f196863..cd78ded 100644 --- a/backend/app/tests/session/test_engine.py +++ b/backend/app/tests/session/test_engine.py @@ -637,6 +637,19 @@ def test_delegate_cannot_close_procedural_vote( delegate_actor, ) +@pytest.mark.xfail +def test_tally_votes_correctly_marks_success_simple( + +) -> None: + assert True == False + + +@pytest.mark.xfail +def test_tally_votes_correctly_marks_fail_majority( + +) -> None: + assert True == False + def test_chair_can_choose_speaker( engine: eng.SessionEngine, From 4942c20394258008e8dca17d59b35d61fe3f7ccb Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:48:43 -0300 Subject: [PATCH 06/11] style(backend): ruff lint & format --- backend/app/session/engine.py | 43 ++++++++++++++---------- backend/app/session/models.py | 3 +- backend/app/tests/session/test_engine.py | 13 +++---- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py index 544ec12..aa350d0 100644 --- a/backend/app/session/engine.py +++ b/backend/app/session/engine.py @@ -1,11 +1,11 @@ # where engine lives from collections.abc import Callable from datetime import UTC, datetime, timedelta +from math import ceil from typing import Any, TypeAlias import app.session.enums as enums import app.session.schemas as schemas -from math import ceil from .enums import ( ChairEvents, @@ -122,8 +122,7 @@ def validate_motion_payload( def validate_question_payload( payload: schemas.DelegateQuestionPayload, state: SessionLiveState -) -> None: - ... +) -> None: ... def count_present_delegations(state: SessionLiveState) -> int: @@ -133,31 +132,42 @@ def count_present_delegations(state: SessionLiveState) -> int: if state.voting_choice is None: return 0 - return len([True for _, vote in state.voting_choice.items() - if vote == enums.RollCallChoice.PRESENT - or vote == enums.RollCallChoice.PRESENT_AND_VOTING]) + return len( + [ + True + for _, vote in state.voting_choice.items() + if vote == enums.RollCallChoice.PRESENT + or vote == enums.RollCallChoice.PRESENT_AND_VOTING + ] + ) def tally_votes(voting: VotingContext, total_presents: int) -> bool: - qualified_motions =( - Motions.POSTPONE_SESSION, Motions.CHANGE_DEBATE_TYPE, - Motions.TOUR_DE_TABLE, Motions.CLOSE_SPEAKERS_LIST, - Motions.SPLIT_PROPOSAL + qualified_motions = ( + Motions.POSTPONE_SESSION, + Motions.CHANGE_DEBATE_TYPE, + Motions.TOUR_DE_TABLE, + Motions.CLOSE_SPEAKERS_LIST, + Motions.SPLIT_PROPOSAL, ) """Helper for computing votes. Unless motion is explicitly requiring qualified majority, will use simple majority (also counts for informal votes)""" - + simple = ceil(total_presents / 2) qualified = ceil(0.66 * total_presents) - in_favor_count = len([True - for _, vote in voting.voting_registry.items() - if vote == enums.VotingChoice.FAVOUR]) + in_favor_count = len( + [ + True + for _, vote in voting.voting_registry.items() + if vote == enums.VotingChoice.FAVOUR + ] + ) motion = voting.motion_in_vote - + if motion is None: return in_favor_count >= simple - + # Use qualified majority for "important" motions if motion.type in qualified_motions and in_favor_count >= qualified: return True @@ -172,7 +182,6 @@ def get_motion_priority(motion: Motions) -> int | None: Some motions are tied. Ex: Change Debate and Tour de Table""" priority_map = { Motions.POSTPONE_SESSION: 1, - Motions.REOPEN_SESSION: 2, Motions.CHANGE_DEBATE_TYPE: 3, Motions.TOUR_DE_TABLE: 3, diff --git a/backend/app/session/models.py b/backend/app/session/models.py index 1375c1a..73d0e89 100644 --- a/backend/app/session/models.py +++ b/backend/app/session/models.py @@ -141,5 +141,4 @@ class SessionLiveState(BaseModel): roll_call: RollCallContext # Not None, even if registry is empty # Additional config - has_veto_power: bool = False - + has_veto_power: bool = False diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py index cd78ded..5940910 100644 --- a/backend/app/tests/session/test_engine.py +++ b/backend/app/tests/session/test_engine.py @@ -637,18 +637,15 @@ def test_delegate_cannot_close_procedural_vote( delegate_actor, ) -@pytest.mark.xfail -def test_tally_votes_correctly_marks_success_simple( -) -> None: - assert True == False +@pytest.mark.xfail +def test_tally_votes_correctly_marks_success_simple() -> None: + assert True == False # noqa @pytest.mark.xfail -def test_tally_votes_correctly_marks_fail_majority( - -) -> None: - assert True == False +def test_tally_votes_correctly_marks_fail_majority() -> None: + assert True == False # noqa def test_chair_can_choose_speaker( From 0bcabbe76b3d4e45820fc8d7abf78634af6cd92a Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:10:43 -0300 Subject: [PATCH 07/11] chore: update frontend schema --- frontend/src/schemas/types.gen.ts | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/frontend/src/schemas/types.gen.ts b/frontend/src/schemas/types.gen.ts index c7849ba..6723ddf 100644 --- a/frontend/src/schemas/types.gen.ts +++ b/frontend/src/schemas/types.gen.ts @@ -50,7 +50,7 @@ export type BodyDummyCommitteesDummyGet = { /** * Types */ - types: SubmitMotionEvent | SubmitQuestionEvent | CastVoteEvent | ChooseDelegateEvent | AnswerRollCallEvent | JoinQueueEvent | LeaveQueueEvent | OpenSessionEvent | CloseSessionEvent | IncreaseTimerEvent | ToggleTimerEvent | OpenInformalVotingEvent | CloseProceduralVotingEvent | CloseInformalVotingEvent | ResolveMotionEvent | SpeakerEvent | SetAgendaEvent | SetAgendaItemEvent | MarkAgendaItemEvent | DeleteAgendaItemEvent | SetPhaseEvent | MarkRollCallEvent | CloseRollCallEvent | ChairInsertQueueEvent | MarkRollCallBulkEvent; + types: SubmitMotionEvent | SubmitQuestionEvent | CastVoteEvent | AnswerRollCallEvent | JoinQueueEvent | LeaveQueueEvent | OpenSessionEvent | CloseSessionEvent | IncreaseTimerEvent | ToggleTimerEvent | OpenInformalVotingEvent | CloseProceduralVotingEvent | CloseInformalVotingEvent | ResolveMotionEvent | SpeakerEvent | SetAgendaEvent | SetAgendaItemEvent | MarkAgendaItemEvent | DeleteAgendaItemEvent | SetPhaseEvent | MarkRollCallEvent | CloseRollCallEvent | ChairInsertQueueEvent | MarkRollCallBulkEvent; schemas: SessionLiveState; }; @@ -195,27 +195,6 @@ export type ChairToggleTimerPayload = { toggle?: boolean; }; -/** - * ChooseDelegateEvent - */ -export type ChooseDelegateEvent = { - /** - * Type - */ - type: 'ChooseDelegateEvent'; - payload: ChooseDelegatePayload; -}; - -/** - * ChooseDelegatePayload - */ -export type ChooseDelegatePayload = { - /** - * Choice - */ - choice: string; -}; - /** * CloseInformalVotingEvent */ @@ -311,7 +290,6 @@ export const DelegateEvents = { JOIN_QUEUE_EVENT: 'JoinQueueEvent', LEAVE_QUEUE_EVENT: 'LeaveQueueEvent', CAST_VOTE_EVENT: 'CastVoteEvent', - CHOOSE_DELEGATE_EVENT: 'ChooseDelegateEvent', YIELD_EVENT: 'YieldEvent', ANSWER_ROLL_CALL_EVENT: 'AnswerRollCallEvent' } as const; @@ -818,6 +796,10 @@ export type SessionLiveState = { [key: string]: RollCallChoice; } | null; roll_call: RollCallContext; + /** + * Has Veto Power + */ + has_veto_power?: boolean; }; /** From d89de879ba6e215ff31e8bd7bace7af00e6dd11a Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:20:59 -0300 Subject: [PATCH 08/11] test(backend): exercise procedural vote tally Co-authored-by: Codex --- backend/app/session/engine.py | 5 ++++- backend/app/tests/session/test_engine.py | 26 ++++++++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py index aa350d0..6c149e4 100644 --- a/backend/app/session/engine.py +++ b/backend/app/session/engine.py @@ -153,6 +153,8 @@ def tally_votes(voting: VotingContext, total_presents: int) -> bool: """Helper for computing votes. Unless motion is explicitly requiring qualified majority, will use simple majority (also counts for informal votes)""" + if total_presents == 0: + return False simple = ceil(total_presents / 2) qualified = ceil(0.66 * total_presents) @@ -528,7 +530,8 @@ def handle_close_procedural_voting( if motion is None: raise InvalidProceduralMove("Can't close voting if motion is None") - passed = tally_votes(state.voting, count_present_delegations(state)) + present = count_present_delegations(state) + passed = tally_votes(state.voting, present) # TODO: pass everything here into a helper "apply_passed_motion" and "apply_change_debate" if passed: diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py index 5940910..5f5154b 100644 --- a/backend/app/tests/session/test_engine.py +++ b/backend/app/tests/session/test_engine.py @@ -591,9 +591,18 @@ def test_chair_can_close_passed_procedural_vote( procedural_voting_state: md.SessionLiveState, close_procedural_voting_event: sch.CloseProceduralVotingEvent, chair_actor: md.SessionActor, - monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(eng, "tally_votes", lambda voting: True) + procedural_voting_state.voting_choice = { + 0: enums.RollCallChoice.PRESENT, + 1: enums.RollCallChoice.PRESENT, + 2: enums.RollCallChoice.PRESENT, + } + assert procedural_voting_state.voting is not None + procedural_voting_state.voting.voting_registry = { + 0: enums.VotingChoice.FAVOUR, + 1: enums.VotingChoice.FAVOUR, + 2: enums.VotingChoice.AGAINST, + } state = engine.dispatch( procedural_voting_state, @@ -610,9 +619,18 @@ def test_chair_can_close_failed_procedural_vote( procedural_voting_state: md.SessionLiveState, close_procedural_voting_event: sch.CloseProceduralVotingEvent, chair_actor: md.SessionActor, - monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(eng, "tally_votes", lambda voting: False) + procedural_voting_state.voting_choice = { + 0: enums.RollCallChoice.PRESENT, + 1: enums.RollCallChoice.PRESENT, + 2: enums.RollCallChoice.PRESENT, + } + assert procedural_voting_state.voting is not None + procedural_voting_state.voting.voting_registry = { + 0: enums.VotingChoice.FAVOUR, + 1: enums.VotingChoice.AGAINST, + 2: enums.VotingChoice.AGAINST, + } state = engine.dispatch( procedural_voting_state, From 8c791f5797a42f7b0b4f582b9106ee2560c5a324 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:36:23 -0300 Subject: [PATCH 09/11] refactor(backend): add user_id to SessionActor --- backend/app/session/models.py | 3 +++ backend/app/session/service.py | 5 +++++ backend/app/tests/conftest.py | 5 +++-- backend/app/tests/session/test_service.py | 7 ++++++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/backend/app/session/models.py b/backend/app/session/models.py index 73d0e89..7b9a4f7 100644 --- a/backend/app/session/models.py +++ b/backend/app/session/models.py @@ -7,6 +7,8 @@ import app.session.enums as enums +from uuid import UUID + @dataclass(frozen=True) class StoredSession: @@ -28,6 +30,7 @@ class DelegationContext(BaseModel): class SessionActor(BaseModel): + user_id: UUID role: enums.SessionRole display_name: str = "Placeholder" delegation: DelegationContext | None = None diff --git a/backend/app/session/service.py b/backend/app/session/service.py index 25c14a9..414aa77 100644 --- a/backend/app/session/service.py +++ b/backend/app/session/service.py @@ -5,6 +5,7 @@ import logging from dataclasses import replace from datetime import datetime +from uuid import UUID from pydantic import TypeAdapter from sqlalchemy.ext.asyncio import AsyncSession @@ -41,6 +42,7 @@ class SessionUpdateError(Exception): def build_actor( + user_id: UUID, manager: ConnectionManager, session_id: int, role: enums.SessionRole, @@ -49,6 +51,7 @@ def build_actor( if role == enums.SessionRole.CHAIR: return SessionActor( + user_id=user_id, role=enums.SessionRole.CHAIR, display_name="Chair", ) @@ -65,6 +68,7 @@ def build_actor( raise ActorResolutionError("delegation not found") return SessionActor( + user_id=user_id, role=enums.SessionRole.DELEGATE, delegation=DelegationContext( id=delegation.id, @@ -189,6 +193,7 @@ async def prepare_session_connect( manager.active_connections.setdefault(committee_session_id, {}) actor = build_actor( + user_id=assignment.user_id, manager=manager, session_id=committee_session_id, role=enums.SessionRole(assignment.role.upper()), diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 6cac13e..04fe14f 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -1,6 +1,7 @@ # Environment configuration file for testing from datetime import datetime +from uuid import UUID import pytest @@ -44,12 +45,12 @@ def session_state(delegation_dict, roll_call): @pytest.fixture def chair_actor(): - return SessionActor(role=SessionRole.CHAIR, display_name="Chair") + return SessionActor(user_id=UUID('11111111-1111-1111-1111-111111111111'),role=SessionRole.CHAIR, display_name="Chair") @pytest.fixture def delegate_actor(delegation_dict): - return SessionActor(role=SessionRole.DELEGATE, delegation=delegation_dict.get(0)) + return SessionActor(user_id=UUID('22222222-2222-2222-2222-222222222222'),role=SessionRole.DELEGATE, delegation=delegation_dict.get(0)) @pytest.fixture diff --git a/backend/app/tests/session/test_service.py b/backend/app/tests/session/test_service.py index 5996839..a4a2785 100644 --- a/backend/app/tests/session/test_service.py +++ b/backend/app/tests/session/test_service.py @@ -5,6 +5,7 @@ import pytest +from app.access.enums import SessionRoles from app.access.models import CommitteeAssignment from app.session import enums from app.session.enums import SessionRole @@ -24,7 +25,7 @@ def brazil_assignment(): return CommitteeAssignment( user_id=UUID("44444444-4444-4444-4444-444444444444"), committee_id=0, - role="delegate", + role=SessionRoles.DELEGATION, representation_id=0, ) @@ -36,6 +37,7 @@ def test_can_build_actor( connection_manager.room_states[0] = session_state actor = build_actor( + user_id=UUID('11111111-1111-1111-1111-111111111111'), manager=connection_manager, session_id=0, role=SessionRole.DELEGATE, @@ -55,6 +57,7 @@ def test_cannot_build_actor_with_nonexistent_state( ) -> None: with pytest.raises(ActorResolutionError, match="session not found"): build_actor( + user_id=UUID('11111111-1111-1111-1111-111111111111'), manager=connection_manager, session_id=0, role=SessionRole.DELEGATE, @@ -69,6 +72,7 @@ def test_cannot_build_actor_with_no_delegation_id( with pytest.raises(ActorResolutionError, match="needs delegate id"): connection_manager.room_states[0] = session_state build_actor( + user_id=UUID('11111111-1111-1111-1111-111111111111'), manager=connection_manager, session_id=0, role=SessionRole.DELEGATE, @@ -83,6 +87,7 @@ def test_cannot_build_actor_with_nonexistent_delegation( connection_manager.room_states[0] = session_state build_actor( + user_id=UUID('11111111-1111-1111-1111-111111111111'), manager=connection_manager, session_id=0, role=SessionRole.DELEGATE, From 9ac9f0fc28f60d063bab2871116643d88e7f946d Mon Sep 17 00:00:00 2001 From: Extroias <91105220+Extroias@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:41:52 -0300 Subject: [PATCH 10/11] fix: linting --- backend/app/session/models.py | 3 +-- backend/app/tests/conftest.py | 12 ++++++++++-- backend/app/tests/session/test_engine.py | 4 ++-- backend/app/tests/session/test_service.py | 8 ++++---- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/backend/app/session/models.py b/backend/app/session/models.py index 7b9a4f7..49b1f78 100644 --- a/backend/app/session/models.py +++ b/backend/app/session/models.py @@ -2,13 +2,12 @@ # Even though it's internal, some things may be sent out to public (TODO:like SessionLiveState) from dataclasses import dataclass from datetime import datetime +from uuid import UUID from pydantic import BaseModel import app.session.enums as enums -from uuid import UUID - @dataclass(frozen=True) class StoredSession: diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 04fe14f..13cc326 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -45,12 +45,20 @@ def session_state(delegation_dict, roll_call): @pytest.fixture def chair_actor(): - return SessionActor(user_id=UUID('11111111-1111-1111-1111-111111111111'),role=SessionRole.CHAIR, display_name="Chair") + return SessionActor( + user_id=UUID("11111111-1111-1111-1111-111111111111"), + role=SessionRole.CHAIR, + display_name="Chair", + ) @pytest.fixture def delegate_actor(delegation_dict): - return SessionActor(user_id=UUID('22222222-2222-2222-2222-222222222222'),role=SessionRole.DELEGATE, delegation=delegation_dict.get(0)) + return SessionActor( + user_id=UUID("22222222-2222-2222-2222-222222222222"), + role=SessionRole.DELEGATE, + delegation=delegation_dict.get(0), + ) @pytest.fixture diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py index 5f5154b..142a4c6 100644 --- a/backend/app/tests/session/test_engine.py +++ b/backend/app/tests/session/test_engine.py @@ -658,12 +658,12 @@ def test_delegate_cannot_close_procedural_vote( @pytest.mark.xfail def test_tally_votes_correctly_marks_success_simple() -> None: - assert True == False # noqa + assert True == False # noqa @pytest.mark.xfail def test_tally_votes_correctly_marks_fail_majority() -> None: - assert True == False # noqa + assert True == False # noqa def test_chair_can_choose_speaker( diff --git a/backend/app/tests/session/test_service.py b/backend/app/tests/session/test_service.py index a4a2785..a1a9659 100644 --- a/backend/app/tests/session/test_service.py +++ b/backend/app/tests/session/test_service.py @@ -37,7 +37,7 @@ def test_can_build_actor( connection_manager.room_states[0] = session_state actor = build_actor( - user_id=UUID('11111111-1111-1111-1111-111111111111'), + user_id=UUID("11111111-1111-1111-1111-111111111111"), manager=connection_manager, session_id=0, role=SessionRole.DELEGATE, @@ -57,7 +57,7 @@ def test_cannot_build_actor_with_nonexistent_state( ) -> None: with pytest.raises(ActorResolutionError, match="session not found"): build_actor( - user_id=UUID('11111111-1111-1111-1111-111111111111'), + user_id=UUID("11111111-1111-1111-1111-111111111111"), manager=connection_manager, session_id=0, role=SessionRole.DELEGATE, @@ -72,7 +72,7 @@ def test_cannot_build_actor_with_no_delegation_id( with pytest.raises(ActorResolutionError, match="needs delegate id"): connection_manager.room_states[0] = session_state build_actor( - user_id=UUID('11111111-1111-1111-1111-111111111111'), + user_id=UUID("11111111-1111-1111-1111-111111111111"), manager=connection_manager, session_id=0, role=SessionRole.DELEGATE, @@ -87,7 +87,7 @@ def test_cannot_build_actor_with_nonexistent_delegation( connection_manager.room_states[0] = session_state build_actor( - user_id=UUID('11111111-1111-1111-1111-111111111111'), + user_id=UUID("11111111-1111-1111-1111-111111111111"), manager=connection_manager, session_id=0, role=SessionRole.DELEGATE, From 27adb4c8a6b8a8e40866178b782a0070aae18295 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:57:34 -0300 Subject: [PATCH 11/11] test(backend): tally votes testing & reduce VotingContext --- backend/app/access/views.py | 9 ++-- backend/app/session/engine.py | 13 +++--- backend/app/session/models.py | 4 -- backend/app/tests/session/test_engine.py | 59 ++++++++++++++++++------ backend/pyproject.toml | 3 ++ 5 files changed, 58 insertions(+), 30 deletions(-) diff --git a/backend/app/access/views.py b/backend/app/access/views.py index f707efd..91975ac 100644 --- a/backend/app/access/views.py +++ b/backend/app/access/views.py @@ -13,7 +13,7 @@ router = APIRouter() -@router.get("/sessions/{session_id}/me", response_model=SessionRepresentation) +@router.get("/sessions/{session_id}/me") async def get_my_session_access( session_id: int, db_session: Annotated[AsyncSession, Depends(get_db_session)], @@ -30,7 +30,6 @@ async def get_my_session_access( detail=str(exc), ) from exc - return { - "role": assignment.role, - "representation_id": assignment.representation_id, - } + return SessionRepresentation( + role=assignment.role, representation_id=assignment.representation_id + ) diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py index 6c149e4..e1705cc 100644 --- a/backend/app/session/engine.py +++ b/backend/app/session/engine.py @@ -171,9 +171,12 @@ def tally_votes(voting: VotingContext, total_presents: int) -> bool: return in_favor_count >= simple # Use qualified majority for "important" motions - if motion.type in qualified_motions and in_favor_count >= qualified: - return True - elif motion.type not in qualified_motions and in_favor_count >= simple: + if ( + motion.type in qualified_motions + and in_favor_count >= qualified + or motion.type not in qualified_motions + and in_favor_count >= simple + ): return True return False @@ -476,8 +479,6 @@ def handle_open_informal_voting( title=event.payload.title, return_state=state.current_state, voting_registry={}, - majority=event.payload.majority, - veto_power=event.payload.veto_power, ) state.current_state = States.VOTING_EXECUTION @@ -662,8 +663,6 @@ def handle_resolve_motion( motion_in_vote=motion, return_state=state.current_state, voting_registry={}, - majority=enums.MajorityTypes.QUALIFIED, # TODO: change depending on type of motion - veto_power=True, ) state.current_state = States.VOTING_EXECUTION diff --git a/backend/app/session/models.py b/backend/app/session/models.py index 49b1f78..e539e16 100644 --- a/backend/app/session/models.py +++ b/backend/app/session/models.py @@ -64,10 +64,6 @@ class VotingContext(BaseModel): return_state: enums.States voting_registry: dict[int, enums.VotingChoice] = {} - # additional fields - majority: enums.MajorityTypes - veto_power: bool - class DebateContext(BaseModel): debate_type: enums.DebateTypes diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py index 142a4c6..295b7e6 100644 --- a/backend/app/tests/session/test_engine.py +++ b/backend/app/tests/session/test_engine.py @@ -19,8 +19,6 @@ def voting_state(session_state: md.SessionLiveState) -> md.SessionLiveState: target_type=enums.VotingType.INFORMAL, return_state=enums.States.OPEN_GSL, voting_registry={}, - majority=enums.MajorityTypes.SIMPLE, - veto_power=False, ) return session_state @@ -143,6 +141,16 @@ def close_speakers_list_motion( ) +@pytest.fixture +def reopen_speakers_list_motion(delegate_actor: md.SessionActor) -> md.MotionContext: + return md.MotionContext( + id=1, + priority=1, + type=enums.Motions.REOPEN_SPEAKERS_LIST, + delegate_id=delegate_actor.delegation.id, # type: ignore[union-attr] + ) + + @pytest.fixture def procedural_voting_state( open_gsl_state: md.SessionLiveState, @@ -154,8 +162,6 @@ def procedural_voting_state( motion_in_vote=close_speakers_list_motion, return_state=enums.States.OPEN_GSL, voting_registry={}, - majority=enums.MajorityTypes.SIMPLE, - veto_power=False, ) return open_gsl_state @@ -656,14 +662,43 @@ def test_delegate_cannot_close_procedural_vote( ) -@pytest.mark.xfail -def test_tally_votes_correctly_marks_success_simple() -> None: - assert True == False # noqa +def test_tally_votes_correctly_marks_success_simple( + reopen_speakers_list_motion: md.MotionContext, +) -> None: + voting_context = md.VotingContext( + target_type=enums.VotingType.INFORMAL, + return_state=enums.States.OPEN_GSL, + motion_in_vote=reopen_speakers_list_motion, + voting_registry={ + 0: enums.VotingChoice.FAVOUR, + 1: enums.VotingChoice.FAVOUR, + 2: enums.VotingChoice.FAVOUR, + 3: enums.VotingChoice.AGAINST, + 4: enums.VotingChoice.AGAINST, + }, + ) + + res = eng.tally_votes(voting_context, 5) + assert res -@pytest.mark.xfail -def test_tally_votes_correctly_marks_fail_majority() -> None: - assert True == False # noqa +def test_tally_votes_correctly_marks_fail_majority( + close_speakers_list_motion: md.MotionContext, +) -> None: + voting_context = md.VotingContext( + target_type=enums.VotingType.INFORMAL, + return_state=enums.States.OPEN_GSL, + motion_in_vote=close_speakers_list_motion, + voting_registry={ + 0: enums.VotingChoice.FAVOUR, + 1: enums.VotingChoice.FAVOUR, + 2: enums.VotingChoice.FAVOUR, + 3: enums.VotingChoice.AGAINST, + 4: enums.VotingChoice.AGAINST, + }, + ) + res = eng.tally_votes(voting_context, 5) + assert not res def test_chair_can_choose_speaker( @@ -759,10 +794,6 @@ def test_chair_cannot_mark_roll_call_bulk_nonexistent_delegations( engine.dispatch(session_state, event, chair_actor) -# @pytest.mark.xfail( -# strict=True, -# reason="handle_insert_queue currently treats delegation id as list index.", -# ) def test_chair_insert_queue_uses_delegation_id( engine: eng.SessionEngine, open_gsl_state: md.SessionLiveState, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f2af6fd..7edcd17 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -30,3 +30,6 @@ line-ending = "auto" [tool.coverage.run] omit = ["app/tests/*"] + +[tool.pytest] +xfail_strict = true