diff --git a/AGENTS.md b/AGENTS.md index 8aee52b..4a4e81d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,3 +15,4 @@ - Treat `representation_id` as the delegation identity everywhere. - For cross-boundary changes, verify the backend tests and the frontend build/typecheck. Report pre-existing failures separately. - Inspect security advisories before changing dependencies. Do not run `npm audit fix --force` without approval and a review of the resulting version changes. +- For any commit done by an agent, include the agent as coauthor (for example, Codex should append `-m "Co-authored-by: Codex "`) diff --git a/backend/README.md b/backend/README.md index b43e307..35483dc 100644 --- a/backend/README.md +++ b/backend/README.md @@ -47,3 +47,19 @@ uv run pytest We deeply recommend contributers to first lint and test their code before sending the commit. This way we make our codebase better. +## Realtime resolution voting events + +Resolution-related motions use delegation-supplied `resolution_id`, +`amendment_id`, and `split_resolution_id`, along with `resolution_title`, +`target_resolution_id`, `is_friendly`, and `split_title` as applicable. The server +derives the submitting representation and accepts a draft before it enters state. +Once debate ends, the chair starts the first `DRAFT` item in `draft_resolutions` +with `StartResolutionVoteEvent {}`. Pending unfriendly amendments are voted +procedurally before substantive voting begins. + +For substantive votes, delegates use `CastVoteEvent { vote }`; chairs can record +a vote with `RecordSubstantiveVoteEvent { representation_id, vote }`. Chairs close +ordinary substantive votes with `CloseSubstantiveVotingEvent`. Roll-call votes use +`AdvanceSubstantiveVoteRoundEvent` for the initial, yes-with-rights, and +no-with-rights rounds; right-of-reply speakers are served through the existing +`NextSpeakerEvent` or `GrantFloorEvent` controls for 30 seconds each. diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py index e1705cc..984a569 100644 --- a/backend/app/session/engine.py +++ b/backend/app/session/engine.py @@ -2,7 +2,7 @@ from collections.abc import Callable from datetime import UTC, datetime, timedelta from math import ceil -from typing import Any, TypeAlias +from typing import Any, TypeAlias, cast import app.session.enums as enums import app.session.schemas as schemas @@ -18,10 +18,12 @@ ) from .models import ( AgendaItem, + AmendmentContext, DebateContext, DelegationContext, MotionContext, QuestionContext, + ResolutionContext, RollCallContext, SessionActor, SessionLiveState, @@ -44,7 +46,6 @@ class InvalidProceduralMove(Exception): Motions.POSTPONE_SESSION, Motions.TOUR_DE_TABLE, Motions.END_DEBATE, - Motions.VOTE_AMENDMENT, Motions.VOTE_BY_ROLL_CALL, Motions.CLOSE_SPEAKERS_LIST, Motions.SPLIT_PROPOSAL, @@ -57,7 +58,6 @@ class InvalidProceduralMove(Exception): States.CLOSED_GSL: { Motions.REOPEN_SPEAKERS_LIST, Motions.END_DEBATE, - Motions.VOTE_AMENDMENT, Motions.VOTE_BY_ROLL_CALL, Motions.INTRODUCE_RESOLUTION_PROPOSAL, Motions.INTRODUCE_AMENDMENT_PROPOSAL, @@ -98,51 +98,85 @@ def generate_next_question_id(state: SessionLiveState) -> int: return state._question_id_counter -# TODO: map more things to be needed here def validate_motion_payload( - payload: schemas.DelegateMotionPayload, state: SessionLiveState + payload: schemas.MotionPayload, + state: SessionLiveState, + representation_id: int, ) -> None: - """Should validate motion payload and correct it before submitting""" - # can correct things - if payload.target_topic is None: - payload.target_topic = ( - state.agenda_topics[state.active_topic_index][0] - if state.active_topic_index is not None - and 0 <= state.active_topic_index < len(state.agenda_topics) - else None - ) + """Validate a motion before reserving it in the submitted-motion queue.""" + if payload.type not in MOTIONS_ALLOWED.get(state.current_state, set()): + raise InvalidProceduralMove("Cannot submit this motion at this phase") + if ( + state.current_state in {States.MODERATED_CAUCUS, States.UNMODERATED_CAUCUS} + and not state.can_set_motion + ): + raise InvalidProceduralMove("Submitting motions during caucuses is disabled") - # can also raise error if there are missing fields if ( - payload.type in {States.MODERATED_CAUCUS} + payload.type == Motions.CHANGE_DEBATE_TYPE + and payload.debate_type == DebateTypes.MODERATED_DEBATE and payload.per_speaker_seconds is None ): raise InvalidProceduralMove("Cannot submit motion without speaking time") - -def validate_question_payload( - payload: schemas.DelegateQuestionPayload, state: SessionLiveState -) -> None: ... + required_fields: dict[Motions, tuple[str, ...]] = { + Motions.INTRODUCE_RESOLUTION_PROPOSAL: ("resolution_title", "resolution_id"), + Motions.INTRODUCE_AMENDMENT_PROPOSAL: ( + "target_resolution_id", + "amendment_id", + "is_friendly", + ), + Motions.SPLIT_PROPOSAL: ( + "target_resolution_id", + "split_title", + "split_resolution_id", + ), + Motions.VOTE_BY_ROLL_CALL: ("target_resolution_id",), + } + for field in required_fields.get(payload.type, ()): + if getattr(payload, field) is None: + raise InvalidProceduralMove(f"{field} is required for this motion") + + if payload.type == Motions.VOTE_AMENDMENT: + raise InvalidProceduralMove("Vote amendment is handled automatically") + + if representation_id not in state.delegations: + raise InvalidProceduralMove("Representation not found") + + match payload.type: + case Motions.INTRODUCE_RESOLUTION_PROPOSAL: + _ensure_resolution_id_available(state, cast(str, payload.resolution_id)) + case Motions.INTRODUCE_AMENDMENT_PROPOSAL: + _get_draft_resolution(state, payload.target_resolution_id) + _ensure_amendment_id_available(state, cast(str, payload.amendment_id)) + case Motions.SPLIT_PROPOSAL: + _get_draft_resolution(state, payload.target_resolution_id) + _ensure_resolution_id_available( + state, cast(str, payload.split_resolution_id) + ) + case Motions.VOTE_BY_ROLL_CALL: + _get_draft_resolution(state, payload.target_resolution_id) 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: + if not state.roll_call.registry: return 0 return len( [ True - for _, vote in state.voting_choice.items() + for _, vote in state.roll_call.registry.items() if vote == enums.RollCallChoice.PRESENT or vote == enums.RollCallChoice.PRESENT_AND_VOTING ] ) -def tally_votes(voting: VotingContext, total_presents: int) -> bool: +def needs_simple_majority_type(motion: enums.Motions) -> bool: + """Simple map for majority type. Used when creating VotingContext and tallying votes""" qualified_motions = ( Motions.POSTPONE_SESSION, Motions.CHANGE_DEBATE_TYPE, @@ -150,7 +184,14 @@ def tally_votes(voting: VotingContext, total_presents: int) -> bool: Motions.CLOSE_SPEAKERS_LIST, Motions.SPLIT_PROPOSAL, ) - """Helper for computing votes. + + if motion not in qualified_motions: + return True + return False + + +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: @@ -162,7 +203,7 @@ def tally_votes(voting: VotingContext, total_presents: int) -> bool: [ True for _, vote in voting.voting_registry.items() - if vote == enums.VotingChoice.FAVOUR + if vote in {enums.VotingChoice.FAVOUR, enums.VotingChoice.YES_WITH_RIGHTS} ] ) motion = voting.motion_in_vote @@ -170,12 +211,10 @@ def tally_votes(voting: VotingContext, total_presents: int) -> bool: 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 - or motion.type not in qualified_motions + needs_simple_majority_type(motion.type) and in_favor_count >= simple + or in_favor_count >= qualified ): return True @@ -191,7 +230,6 @@ def get_motion_priority(motion: Motions) -> int | None: 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, @@ -251,37 +289,32 @@ def require_chair(actor: SessionActor) -> None: # -------------- HANDLERS -------------- -def handle_submit_motion( +def handle_delegate_submit_motion( state: SessionLiveState, event: schemas.SubmitMotionEvent, actor: SessionActor ) -> SessionLiveState: """Handles/Maps all possible states through a motion""" - # Extract payload (as DelegateMotionSchema) - payload = event.payload - current_state = state.current_state - - # check if motion can be made for this state - if payload.type not in MOTIONS_ALLOWED.get(current_state, set()): - raise InvalidProceduralMove("Cannot submit this motion at this phase") - - if ( - current_state in {States.MODERATED_CAUCUS, States.UNMODERATED_CAUCUS} - and not state.can_set_motion - ): - raise InvalidProceduralMove("Submitting motions during caucuses is disabled") - - # if actor.delegation is None and actor.role != SessionRole.CHAIR: - # raise InvalidProceduralMove("Delegation context is missing") + require_delegate(actor) - validate_motion_payload(payload, state) + payload = event.payload + validate_motion_payload(payload, state, actor.delegation.id) # type: ignore context = MotionContext( id=generate_next_motion_id(state), - priority=get_motion_priority(payload.type), + priority=get_motion_priority(payload.type) or 1, type=payload.type, + timestamp=datetime.now(UTC), delegate_id=actor.delegation.id if actor.delegation is not None else None, + debate_type=payload.debate_type, total_duration_minutes=payload.total_duration_minutes, per_speaker_seconds=payload.per_speaker_seconds, target_topic=payload.target_topic, + resolution_title=payload.resolution_title, + resolution_id=payload.resolution_id, + target_resolution_id=payload.target_resolution_id, + amendment_id=payload.amendment_id, + is_friendly=payload.is_friendly, + split_title=payload.split_title, + split_resolution_id=payload.split_resolution_id, details=payload.details, ) @@ -292,15 +325,13 @@ def handle_submit_motion( def handle_submit_question( state: SessionLiveState, event: schemas.SubmitQuestionEvent, actor: SessionActor ) -> SessionLiveState: - # TODO: change this so Chair can also catalog motions for delegations require_delegate(actor) payload = event.payload - validate_question_payload(payload, state) context = QuestionContext( id=generate_next_question_id(state), - priority=get_question_priority(payload.type), + priority=get_question_priority(payload.type) or 1, type=payload.type, delegate_id=actor.delegation.id, # type:ignore since require_delegate assumes actor delegate is not none details=payload.details, @@ -349,15 +380,38 @@ def handle_cast_vote( if voting_context is None: raise InvalidProceduralMove("Cannot vote during this stage") - # initial voting workflow, may be reviewed later - # TODO: perhaps allow casting another vote if first one fails - if delegate.id in voting_context.voting_registry: + _record_vote(state, voting_context, delegate.id, event.payload.vote) + + return state + + +def _record_vote( + state: SessionLiveState, + voting: VotingContext, + representation_id: int, + vote: enums.VotingChoice, +) -> None: + """Validate and record one vote. Substantive eligibility is frozen on start.""" + if representation_id in voting.voting_registry: raise InvalidProceduralMove("Already cast vote") - # register vote on voting context - voting_context.voting_registry[delegate.id] = event.payload.vote + attendance = state.roll_call.registry.get(representation_id) + if voting.target_type == enums.VotingType.SUBSTANTIVE and attendance not in { + enums.RollCallChoice.PRESENT, + enums.RollCallChoice.PRESENT_AND_VOTING, + }: + raise InvalidProceduralMove("Representation is not eligible to vote") + + if not voting.is_choice_allowed( + choice=vote, + is_roll_call=bool( + voting.resolution_in_vote and voting.resolution_in_vote.roll_call_vote + ), + is_present_and_voting=attendance == enums.RollCallChoice.PRESENT_AND_VOTING, + ): + raise InvalidProceduralMove("Vote choice is not allowed") - return state + voting.voting_registry[representation_id] = vote def handle_answer_roll_call( @@ -382,8 +436,7 @@ def handle_open_session( raise InvalidProceduralMove("Session can only be opened from setup") state.current_state = States.ROLL_CALL - state.roll_call = RollCallContext(registry={}, current_delegation=None) - state.voting_choice = {} + state.roll_call = RollCallContext(registry={}) state.gsl_queue = [] state.current_speaker = None state.debate = None @@ -510,6 +563,193 @@ def handle_close_informal_voting( return state +def apply_passed_motion( + state: SessionLiveState, motion: MotionContext, return_state: States +) -> None: + """Apply a passed procedural motion to the live session state in place.""" + next_state = return_state # as fallback + state.current_speaker = None + state.timer_is_running = False + state.timer_expiration = None + + # 1st block: change of debate motions + if motion.type == Motions.CHANGE_DEBATE_TYPE and motion.debate_type is not None: + state.caucus_list = [] + state.current_speaker = None + duration_seconds = ( + (motion.total_duration_minutes * 60) + if motion.total_duration_minutes is not None + else 600 + ) # defaults to 10 minutes as fallback + + match motion.debate_type: + case DebateTypes.MODERATED_DEBATE: + next_state = States.MODERATED_CAUCUS + state.debate = DebateContext( + debate_type=DebateTypes.MODERATED_DEBATE, + return_state=return_state, + total_duration_seconds=duration_seconds, + per_speaker_seconds=motion.per_speaker_seconds, + expires_at=datetime.now(UTC) + timedelta(seconds=duration_seconds), + ) + reset_timer( + state, + motion.per_speaker_seconds + if motion.per_speaker_seconds is not None + else 60, + ) + + case DebateTypes.UNMODERATED_DEBATE: + next_state = States.UNMODERATED_CAUCUS + state.debate = DebateContext( + debate_type=DebateTypes.UNMODERATED_DEBATE, + return_state=return_state, + total_duration_seconds=duration_seconds, + per_speaker_seconds=None, + expires_at=datetime.now(UTC) + timedelta(seconds=duration_seconds), + ) + reset_timer(state) # should not display per_speaker timer + + case DebateTypes.SPEAKERS_LIST: + next_state = States.OPEN_GSL + state.debate = None + reset_timer(state, state.gsl_default_time_seconds) + + case _: + raise InvalidProceduralMove("Undefined debate type") + + match motion.type: + case Motions.POSTPONE_SESSION: + pass + case Motions.REOPEN_SESSION: + pass + case Motions.TOUR_DE_TABLE: + next_state = States.TOUR_DE_TABLE + state.caucus_list = [ + del_id + for del_id, choice in state.roll_call.registry.items() + if choice + in ( + enums.RollCallChoice.PRESENT, + enums.RollCallChoice.PRESENT_AND_VOTING, + ) + ] + + case Motions.END_DEBATE: + # clean gsl list + state.gsl_queue = [] + state.debate = None + reset_timer(state) + next_state = States.VOTING_PREPARATION # or VOTING_PREPARATION + + case Motions.CLOSE_SPEAKERS_LIST: + next_state = States.CLOSED_GSL + + case Motions.REOPEN_SPEAKERS_LIST: + next_state = States.OPEN_GSL + + case Motions.CHANGE_TOPIC: + # note: seems more like an informal consultation + pass + case Motions.QUORUM: + state.roll_call = RollCallContext(registry={}, return_state=return_state) + next_state = States.ROLL_CALL + case _: + raise InvalidProceduralMove("Undefined motion type") + + # additional case: if we went from GSL to something, save gsl structures + state.current_state = next_state + + +def apply_passed_voting_preparation_motion( + state: SessionLiveState, motion: MotionContext +) -> None: + """Apply a passed motion that changes a draft resolution, not debate state.""" + match motion.type: + case Motions.VOTE_BY_ROLL_CALL: + _get_draft_resolution( + state, motion.target_resolution_id + ).roll_call_vote = True + case Motions.SPLIT_PROPOSAL: + parent = _get_draft_resolution(state, motion.target_resolution_id) + state.draft_resolutions.append( + ResolutionContext( + id=cast(str, motion.split_resolution_id), + title=cast(str, motion.split_title), + delegate_id=parent.delegate_id, + parent_resolution_id=parent.id, + roll_call_vote=parent.roll_call_vote, + ) + ) + case _: + raise InvalidProceduralMove("Not a voting preparation motion") + + +def _ensure_resolution_id_available( + state: SessionLiveState, resolution_id: str +) -> None: + if any(resolution.id == resolution_id for resolution in state.draft_resolutions): + raise InvalidProceduralMove("Resolution ID already exists") + if any( + resolution_id in {motion.resolution_id, motion.split_resolution_id} + for motion in state.submitted_motions + ): + raise InvalidProceduralMove("Resolution ID already reserved") + + +def _ensure_amendment_id_available(state: SessionLiveState, amendment_id: str) -> None: + if any( + amendment.id == amendment_id + for resolution in state.draft_resolutions + for amendment in resolution.amendments + ): + raise InvalidProceduralMove("Amendment ID already exists") + if any(amendment_id == motion.amendment_id for motion in state.submitted_motions): + raise InvalidProceduralMove("Amendment ID already reserved") + + +def _get_draft_resolution( + state: SessionLiveState, resolution_id: str | None +) -> ResolutionContext: + resolution = next( + (r for r in state.draft_resolutions if r.id == resolution_id), None + ) + if resolution is None: + raise InvalidProceduralMove("Draft resolution not found") + return resolution + + +def _open_next_amendment_or_substantive(state: SessionLiveState) -> None: + """Open pending amendment votes in submission order, then the resolution vote.""" + if state.voting is None or state.voting.resolution_in_vote is None: + raise InvalidProceduralMove("No resolution vote in progress") + resolution = state.voting.resolution_in_vote + pending = next( + (amendment for amendment in resolution.amendments if not amendment.is_friendly), + None, + ) + if pending is not None: + state.voting = VotingContext( + target_type=enums.VotingType.PROCEDURAL, + return_state=States.VOTING_PROCEDURES, + voting_registry={}, + majority=enums.MajorityTypes.SIMPLE, + resolution_in_vote=resolution, + amendment_in_vote=pending, + ) + return + + state.voting = VotingContext( + target_type=enums.VotingType.SUBSTANTIVE, + return_state=States.VOTING_PREPARATION, + voting_registry={}, + majority=enums.MajorityTypes.SIMPLE, + resolution_in_vote=resolution, + substantive_round=enums.SubstantiveVoteRound.INITIAL, + ) + state.current_state = States.VOTING_PROCEDURES + + def handle_close_procedural_voting( state: SessionLiveState, event: schemas.CloseProceduralVotingEvent, @@ -521,117 +761,40 @@ def handle_close_procedural_voting( raise InvalidProceduralMove("No voting present") if ( - state.current_state != States.VOTING_EXECUTION - or state.voting.target_type != enums.VotingType.PROCEDURAL + state.voting.target_type != enums.VotingType.PROCEDURAL + or state.current_state + not in { + States.VOTING_EXECUTION, + States.VOTING_PROCEDURES, + } ): raise InvalidProceduralMove("Can't close voting") + amendment = state.voting.amendment_in_vote + resolution = state.voting.resolution_in_vote motion = state.voting.motion_in_vote - - if motion is None: - raise InvalidProceduralMove("Can't close voting if motion is None") - 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: - next_state = state.current_state # as fallback - state.current_speaker = None - state.timer_is_running = False - state.timer_expiration = None - - # 1st block: change of debate motions - if motion.type == Motions.CHANGE_DEBATE_TYPE and motion.debate_type is not None: - state.caucus_list = [] - state.current_speaker = None - duration_seconds = ( - (motion.total_duration_minutes * 60) - if motion.total_duration_minutes is not None - else 600 - ) # defaults to 10 minutes as fallback - - match motion.debate_type: - case DebateTypes.MODERATED_DEBATE: - next_state = States.MODERATED_CAUCUS - state.debate = DebateContext( - debate_type=DebateTypes.MODERATED_DEBATE, - return_state=state.current_state, - total_duration_seconds=duration_seconds, - per_speaker_seconds=motion.per_speaker_seconds, - expires_at=datetime.now(UTC) - + timedelta(seconds=duration_seconds), - ) - reset_timer( - state, - motion.per_speaker_seconds - if motion.per_speaker_seconds is not None - else 60, - ) - - case DebateTypes.UNMODERATED_DEBATE: - next_state = States.UNMODERATED_CAUCUS - state.debate = DebateContext( - debate_type=DebateTypes.UNMODERATED_DEBATE, - return_state=state.current_state, - total_duration_seconds=duration_seconds, - per_speaker_seconds=None, - expires_at=datetime.now(UTC) - + timedelta(seconds=duration_seconds), - ) - reset_timer(state) # should not display per_speaker timer - - case DebateTypes.SPEAKERS_LIST: - next_state = States.OPEN_GSL - state.debate = None - reset_timer(state, state.gsl_default_time_seconds) - - case _: - raise InvalidProceduralMove("Undefined debate type") - - match motion.type: - case Motions.POSTPONE_SESSION: - # TODO: create a type of force_to_database function here? or query if it's a postpone session on service.py - pass - case Motions.REOPEN_SESSION: - # TODO: same as above - pass - case Motions.TOUR_DE_TABLE: - # note: seems like belongs to debate type - pass - case Motions.END_DEBATE: - # clean gsl list - state.gsl_queue = [] - state.debate = None - reset_timer(state) - next_state = States.VOTING_PROCEDURES # or VOTING_PREPARATION - - case Motions.VOTE_AMENDMENT: - # note: seems more like an informal consultation - pass - case Motions.VOTE_BY_ROLL_CALL: - # will define the VotingContext for resolutions - pass - case Motions.CLOSE_SPEAKERS_LIST: - next_state = States.CLOSED_GSL - - case Motions.REOPEN_SPEAKERS_LIST: - next_state = States.OPEN_GSL + if amendment is not None and resolution is not None: + resolution.amendments.remove(amendment) + state.voting = VotingContext( + target_type=enums.VotingType.SUBSTANTIVE, + return_state=States.VOTING_PREPARATION, + resolution_in_vote=resolution, + ) + _open_next_amendment_or_substantive(state) + return state - case Motions.SPLIT_PROPOSAL: - # note: seems more like an informal consultation - pass - case Motions.CHANGE_TOPIC: - # note: seems more like an informal consultation - pass - case Motions.QUORUM: - state.roll_call = RollCallContext(registry={}) - next_state = States.ROLL_CALL - case _: - raise InvalidProceduralMove("Undefined motion type") + if motion is None: + raise InvalidProceduralMove("Can't close voting if motion is None") - # additional case: if we went from GSL to something, save gsl structures - state.current_state = next_state + if passed: + if motion.type in {Motions.SPLIT_PROPOSAL, Motions.VOTE_BY_ROLL_CALL}: + apply_passed_voting_preparation_motion(state, motion) + state.current_state = state.voting.return_state + else: + apply_passed_motion(state, motion, return_state=state.voting.return_state) else: # motion failed, so return to last state state.current_state = state.voting.return_state @@ -641,15 +804,32 @@ def handle_close_procedural_voting( return state -# handles setting state into VOTING_EXECUTION or rejecting the motion +def handle_finish_caucus( + state: SessionLiveState, event: schemas.FinishCaucusEvent, actor: SessionActor +) -> SessionLiveState: + require_chair(actor) + + if state.debate is None or state.current_state not in { + States.MODERATED_CAUCUS, + States.UNMODERATED_CAUCUS, + }: + raise InvalidProceduralMove("No active caucus") + + return_state = state.debate.return_state + state.current_speaker = None + state.caucus_list = [] + state.debate = None + reset_timer(state) + state.current_state = return_state + return state + + def handle_resolve_motion( state: SessionLiveState, event: schemas.ResolveMotionEvent, actor: SessionActor ) -> SessionLiveState: - # TODO: check how to resolve INTRODUCE_RESOLUTION_PROPOSAL and INTRODUCE_AMENDMENT_PROPOSAL motions separately from procedural motions require_chair(actor) payload = event.payload - # next() function with generator expression motion = next( (m for m in state.submitted_motions if m.id == payload.motion_id), None ) @@ -657,18 +837,96 @@ def handle_resolve_motion( if motion is None: raise InvalidProceduralMove("Motion not found") - if payload.action: - state.voting = VotingContext( - target_type=enums.VotingType.PROCEDURAL, - motion_in_vote=motion, - return_state=state.current_state, - voting_registry={}, + state.submitted_motions.remove(motion) + if not payload.action: + return state + + if motion.type == Motions.INTRODUCE_RESOLUTION_PROPOSAL: + state.draft_resolutions.append( + ResolutionContext( + id=cast(str, motion.resolution_id), + title=cast(str, motion.resolution_title), + delegate_id=cast(int, motion.delegate_id), + ) + ) + return state + + if motion.type == Motions.INTRODUCE_AMENDMENT_PROPOSAL: + target = _get_draft_resolution(state, motion.target_resolution_id) + is_friendly = cast(bool, motion.is_friendly) + target.amendments.append( + AmendmentContext( + id=cast(str, motion.amendment_id), + target_resolution_id=target.id, + is_friendly=is_friendly, + representation_id=cast(int, motion.delegate_id), + ) ) + return state - state.current_state = States.VOTING_EXECUTION + majority_type = ( + enums.MajorityTypes.SIMPLE + if needs_simple_majority_type(motion.type) + else enums.MajorityTypes.QUALIFIED + ) - state.submitted_motions.remove(motion) + state.voting = VotingContext( + target_type=enums.VotingType.PROCEDURAL, + motion_in_vote=motion, + return_state=state.current_state, + voting_registry={}, + majority=majority_type, + ) + state.current_state = States.VOTING_EXECUTION + + return state + + +def handle_chair_submit_motion( + state: SessionLiveState, event: schemas.LogMotionEvent, actor: SessionActor +) -> SessionLiveState: + require_chair(actor) + + payload = event.payload + validate_motion_payload(payload, state, payload.representation_id) + + # create motion context + context = MotionContext( + id=generate_next_motion_id(state), + priority=get_motion_priority(payload.type) or 1, + type=payload.type, + timestamp=datetime.now(UTC), + delegate_id=payload.representation_id, + debate_type=payload.debate_type, + total_duration_minutes=payload.total_duration_minutes, + per_speaker_seconds=payload.per_speaker_seconds, + target_topic=payload.target_topic, + resolution_title=payload.resolution_title, + resolution_id=payload.resolution_id, + target_resolution_id=payload.target_resolution_id, + amendment_id=payload.amendment_id, + is_friendly=payload.is_friendly, + split_title=payload.split_title, + split_resolution_id=payload.split_resolution_id, + details=payload.details, + ) + + majority_type = ( + enums.MajorityTypes.SIMPLE + if needs_simple_majority_type(payload.type) + else enums.MajorityTypes.QUALIFIED + ) + # set state to be in voting execution + state.voting = VotingContext( + target_type=enums.VotingType.PROCEDURAL, + motion_in_vote=context, + return_state=state.current_state, + voting_registry={}, + majority=majority_type, + ) + + state.current_state = States.VOTING_EXECUTION return state @@ -718,28 +976,242 @@ def handle_manual_phase_set( ) -> SessionLiveState: ... -def handle_choose_speaker( - state: SessionLiveState, event: schemas.SpeakerEvent, actor: SessionActor +def handle_next_speaker( + state: SessionLiveState, event: schemas.NextSpeakerEvent, actor: SessionActor ) -> SessionLiveState: require_chair(actor) - seconds = event.payload.seconds or get_default_speaker_seconds(state) if ( - event.payload.speaker_id is None - and state.current_state in (States.OPEN_GSL, States.CLOSED_GSL) - and state.gsl_queue + state.current_state == States.VOTING_PROCEDURES + and state.voting is not None + and state.voting.target_type == enums.VotingType.SUBSTANTIVE + and state.voting.substantive_round + in { + enums.SubstantiveVoteRound.YES_WITH_RIGHTS, + enums.SubstantiveVoteRound.NO_WITH_RIGHTS, + } ): + if not state.voting.rights_queue: + state.current_speaker = None + reset_timer(state) + return state + state.current_speaker = state.voting.rights_queue.pop(0) + reset_timer(state, 30) + return state + + if state.current_state in {States.OPEN_GSL, States.CLOSED_GSL}: + if not state.gsl_queue: + state.current_speaker = None + reset_timer(state) + return state state.current_speaker = state.gsl_queue.pop(0) - else: - state.current_speaker = event.payload.speaker_id + reset_timer(state, state.gsl_default_time_seconds) + return state + + if state.current_state == States.TOUR_DE_TABLE: + if not state.caucus_list: + state.current_speaker = None + reset_timer(state) + return state + state.current_speaker = state.caucus_list.pop(0) + reset_timer(state, state.gsl_default_time_seconds) + return state + + if state.current_state == States.MODERATED_CAUCUS: + raise InvalidProceduralMove("Chair must grant floor during moderated caucus") + + raise InvalidProceduralMove("Cannot advance speaker right now") + + +def handle_add_gsl_speaker( + state: SessionLiveState, event: schemas.AddGslSpeakerEvent, actor: SessionActor +) -> SessionLiveState: + require_chair(actor) + + if state.current_state not in {States.OPEN_GSL, States.CLOSED_GSL}: + raise InvalidProceduralMove("Can only add speakers to the GSL") + + representation_id = event.payload.representation_id + if representation_id not in state.delegations: + raise InvalidProceduralMove("Representation not found") + if representation_id in state.gsl_queue: + raise InvalidProceduralMove("Representation already in GSL queue") + + state.gsl_queue.append(representation_id) + return state + + +def handle_grant_floor( + state: SessionLiveState, event: schemas.GrantFloorEvent, actor: SessionActor +) -> SessionLiveState: + require_chair(actor) + + representation_id = event.payload.representation_id + if representation_id not in state.delegations: + raise InvalidProceduralMove("Representation not found") + + seconds = _grant_floor_seconds(state, representation_id, event.payload.seconds) + state.current_speaker = representation_id + reset_timer(state, seconds) + + return state + + +def _grant_floor_seconds( + state: SessionLiveState, representation_id: int, requested_seconds: int | None +) -> int: + """Apply phase-specific floor rules and return the resulting speaker time.""" + if ( + state.current_state == States.VOTING_PROCEDURES + and state.voting is not None + and state.voting.target_type == enums.VotingType.SUBSTANTIVE + and state.voting.substantive_round + in { + enums.SubstantiveVoteRound.YES_WITH_RIGHTS, + enums.SubstantiveVoteRound.NO_WITH_RIGHTS, + } + ): + if representation_id not in state.voting.rights_queue: + raise InvalidProceduralMove("Representation has no right to speak") + state.voting.rights_queue.remove(representation_id) + return 30 + + if state.current_state in {States.OPEN_GSL, States.CLOSED_GSL}: + if representation_id in state.gsl_queue: + state.gsl_queue.remove(representation_id) + return requested_seconds or state.gsl_default_time_seconds + + if state.current_state == States.MODERATED_CAUCUS: + if state.debate is None: + raise InvalidProceduralMove("No active moderated caucus") + return requested_seconds or state.debate.per_speaker_seconds or 60 + + if state.current_state == States.TOUR_DE_TABLE: + if representation_id in state.caucus_list: + state.caucus_list.remove(representation_id) + return requested_seconds or state.gsl_default_time_seconds + + if state.current_state == States.UNMODERATED_CAUCUS: + raise InvalidProceduralMove("Cannot grant floor during unmoderated caucus") + raise InvalidProceduralMove("Cannot grant floor right now") + + +def handle_start_resolution_vote( + state: SessionLiveState, + event: schemas.StartResolutionVoteEvent, + actor: SessionActor, +) -> SessionLiveState: + require_chair(actor) + if state.current_state != States.VOTING_PREPARATION or state.voting is not None: + raise InvalidProceduralMove( + "Resolution voting can only start in voting preparation" + ) + resolution = next(iter(state.draft_resolutions), None) + if resolution is None: + raise InvalidProceduralMove("No draft resolution is available to vote") + state.current_state = States.VOTING_PROCEDURES + state.voting = VotingContext( + target_type=enums.VotingType.SUBSTANTIVE, + return_state=States.VOTING_PREPARATION, + resolution_in_vote=resolution, + ) + _open_next_amendment_or_substantive(state) + return state + + +def _finish_substantive_vote(state: SessionLiveState) -> None: + if state.voting is None or state.voting.resolution_in_vote is None: + raise InvalidProceduralMove("No substantive vote in progress") + resolution = state.voting.resolution_in_vote + state.draft_resolutions.remove(resolution) + state.voting = None + state.current_speaker = None + reset_timer(state) + state.current_state = States.VOTING_PREPARATION + + +def handle_record_substantive_vote( + state: SessionLiveState, + event: schemas.RecordSubstantiveVoteEvent, + actor: SessionActor, +) -> SessionLiveState: + require_chair(actor) + voting = state.voting + if state.current_state != States.VOTING_PROCEDURES or voting is None: + raise InvalidProceduralMove("No substantive vote in progress") + if voting.target_type != enums.VotingType.SUBSTANTIVE: + raise InvalidProceduralMove("No substantive vote in progress") + if event.payload.representation_id not in state.delegations: + raise InvalidProceduralMove("Representation not found") + _record_vote(state, voting, event.payload.representation_id, event.payload.vote) + return state - state.timer_is_running = False - state.timer_expiration = None # will be calculated when timer is toggled - state.timer_remaining_seconds = seconds or 60 # default to something +def handle_close_substantive_voting( + state: SessionLiveState, + event: schemas.CloseSubstantiveVotingEvent, + actor: SessionActor, +) -> SessionLiveState: + require_chair(actor) + voting = state.voting + if ( + state.current_state != States.VOTING_PROCEDURES + or voting is None + or voting.target_type != enums.VotingType.SUBSTANTIVE + or voting.resolution_in_vote is None + ): + raise InvalidProceduralMove("No substantive vote in progress") + if voting.resolution_in_vote.roll_call_vote: + raise InvalidProceduralMove( + "Use advance substantive vote round for roll call votes" + ) + _finish_substantive_vote(state) return state +def handle_advance_substantive_vote_round( + state: SessionLiveState, + event: schemas.AdvanceSubstantiveVoteRoundEvent, + actor: SessionActor, +) -> SessionLiveState: + require_chair(actor) + voting = state.voting + if ( + state.current_state != States.VOTING_PROCEDURES + or voting is None + or voting.target_type != enums.VotingType.SUBSTANTIVE + or voting.resolution_in_vote is None + or not voting.resolution_in_vote.roll_call_vote + ): + raise InvalidProceduralMove("No roll-call substantive vote in progress") + if voting.substantive_round == enums.SubstantiveVoteRound.INITIAL: + if len(voting.voting_registry) != count_present_delegations(state): + raise InvalidProceduralMove("Every eligible representation must vote") + voting.substantive_round = enums.SubstantiveVoteRound.YES_WITH_RIGHTS + voting.rights_queue = [ + representation_id + for representation_id, choice in voting.voting_registry.items() + if choice == enums.VotingChoice.YES_WITH_RIGHTS + ] + return state + if voting.substantive_round == enums.SubstantiveVoteRound.YES_WITH_RIGHTS: + if voting.rights_queue: + raise InvalidProceduralMove("Finish the rights queue before advancing") + voting.substantive_round = enums.SubstantiveVoteRound.NO_WITH_RIGHTS + voting.rights_queue = [ + representation_id + for representation_id, choice in voting.voting_registry.items() + if choice == enums.VotingChoice.NO_WITH_RIGHTS + ] + return state + if voting.substantive_round == enums.SubstantiveVoteRound.NO_WITH_RIGHTS: + if voting.rights_queue: + raise InvalidProceduralMove("Finish the rights queue before advancing") + _finish_substantive_vote(state) + return state + raise InvalidProceduralMove("Unknown substantive voting round") + + def handle_mark_roll_call( state: SessionLiveState, event: schemas.MarkRollCallEvent, actor: SessionActor ) -> SessionLiveState: @@ -783,9 +1255,9 @@ def handle_close_roll_call( for delegation_id in state.delegations: state.roll_call.registry.setdefault(delegation_id, RollCallChoice.ABSENT) - # may also empty roll call if needed, to avoid loading stale values - state.current_state = States.OPEN_GSL - state.voting_choice = { + # Initial roll call enters Open GSL; quorum roll calls restore their source state. + state.current_state = state.roll_call.return_state or States.OPEN_GSL + state.roll_call.registry = { delegation_id: RollCallChoice.PRESENT_AND_VOTING if choice == RollCallChoice.PRESENT_AND_VOTING else RollCallChoice.PRESENT @@ -795,27 +1267,14 @@ def handle_close_roll_call( return state -def handle_insert_queue( - state: SessionLiveState, event: schemas.ChairInsertQueueEvent, actor: SessionActor -) -> SessionLiveState: - require_chair(actor) - del_id: int = event.payload.target - delegate = state.delegations.get(del_id) - if delegate is None: - raise InvalidProceduralMove("Delegate not found") - state.gsl_queue.append(delegate.id) - return state - - # Signature for events/handlers, uses legacy(ish) 3.11 TypeAlias EventHandler: TypeAlias = Callable[ [SessionLiveState, Any, SessionActor], # overall signature SessionLiveState, # Return type ] -# TODO: check if list has all events, since it's generated by codex EVENT_HANDLERS: dict[DelegateEvents | ChairEvents, EventHandler] = { - DelegateEvents.SUBMIT_MOTION: handle_submit_motion, + DelegateEvents.SUBMIT_MOTION: handle_delegate_submit_motion, DelegateEvents.SUBMIT_QUESTION: handle_submit_question, DelegateEvents.JOIN_QUEUE: handle_join_queue, DelegateEvents.LEAVE_QUEUE: handle_leave_queue, @@ -827,18 +1286,25 @@ def handle_insert_queue( ChairEvents.OPEN_INFORMAL_VOTING: handle_open_informal_voting, ChairEvents.CLOSE_INFORMAL_VOTING: handle_close_informal_voting, ChairEvents.CLOSE_PROCEDURAL_VOTING: handle_close_procedural_voting, + ChairEvents.FINISH_CAUCUS: handle_finish_caucus, ChairEvents.RESOLVE_MOTION: handle_resolve_motion, + ChairEvents.LOG_MOTION: handle_chair_submit_motion, ChairEvents.SET_AGENDA: handle_set_agenda, ChairEvents.SET_AGENDA_ITEM: handle_set_agenda_item, ChairEvents.MARK_AGENDA_ITEM: handle_mark_agenda_item, ChairEvents.DELETE_AGENDA_ITEM: handle_delete_agenda_item, ChairEvents.MANUAL_PHASE_SET: handle_manual_phase_set, ChairEvents.CLOSE_SESSION: handle_close_session, - ChairEvents.CHOOSE_SPEAKER: handle_choose_speaker, + ChairEvents.NEXT_SPEAKER: handle_next_speaker, + ChairEvents.ADD_GSL_SPEAKER: handle_add_gsl_speaker, + ChairEvents.GRANT_FLOOR: handle_grant_floor, ChairEvents.MARK_ROLLCALL: handle_mark_roll_call, ChairEvents.MARK_ROLLCALL_BULK: handle_mark_roll_call_bulk, ChairEvents.CLOSE_ROLLCALL: handle_close_roll_call, - ChairEvents.INSERT_QUEUE: handle_insert_queue, + ChairEvents.START_RESOLUTION_VOTE: handle_start_resolution_vote, + ChairEvents.ADVANCE_SUBSTANTIVE_VOTE_ROUND: handle_advance_substantive_vote_round, + ChairEvents.RECORD_SUBSTANTIVE_VOTE: handle_record_substantive_vote, + ChairEvents.CLOSE_SUBSTANTIVE_VOTING: handle_close_substantive_voting, } diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py index aaa0116..a576450 100644 --- a/backend/app/session/enums.py +++ b/backend/app/session/enums.py @@ -19,6 +19,7 @@ class States(StrEnum): # States based on motions, resolutions, etc MODERATED_CAUCUS = "Moderated Caucus" UNMODERATED_CAUCUS = "Unmoderated Caucus" + TOUR_DE_TABLE = "Tour de Table" VOTING_EXECUTION = "Voting Execution" # this handles either "motion to moderated caucus" or "motion to voting procedures", for example BETWEEN_DEBATES = "Between Debates" @@ -40,8 +41,10 @@ class ChairEvents(StrEnum): INCREASE_TIMER = "IncreaseTimerEvent" OPEN_INFORMAL_VOTING = "OpenInformalVotingEvent" RESOLVE_MOTION = "ResolveMotionEvent" + LOG_MOTION = "LogMotionEvent" CLOSE_PROCEDURAL_VOTING = "CloseProceduralVotingEvent" CLOSE_INFORMAL_VOTING = "CloseInformalVotingEvent" + FINISH_CAUCUS = "FinishCaucusEvent" # Disruptive events (i.e manual override events) MANUAL_PHASE_SET = "SetPhaseEvent" @@ -52,42 +55,47 @@ class ChairEvents(StrEnum): MARK_AGENDA_ITEM = "MarkAgendaItemEvent" DELETE_AGENDA_ITEM = "DeleteAgendaItemEvent" SET_AGENDA = "SetAgenda" - CHOOSE_SPEAKER = "SpeakerEvent" + NEXT_SPEAKER = "NextSpeakerEvent" + ADD_GSL_SPEAKER = "AddGslSpeakerEvent" + GRANT_FLOOR = "GrantFloorEvent" MARK_ROLLCALL = "MarkRollCallEvent" MARK_ROLLCALL_BULK = "MarkRollCallBulkEvent" CLOSE_ROLLCALL = "CloseRollCallEvent" - INSERT_QUEUE = "InsertQueueEvent" + START_RESOLUTION_VOTE = "StartResolutionVoteEvent" + ADVANCE_SUBSTANTIVE_VOTE_ROUND = "AdvanceSubstantiveVoteRoundEvent" + RECORD_SUBSTANTIVE_VOTE = "RecordSubstantiveVoteEvent" + CLOSE_SUBSTANTIVE_VOTING = "CloseSubstantiveVotingEvent" # --- Additional Info --- class DebateTypes(StrEnum): - SPEAKERS_LIST = "Speakers List" - MODERATED_DEBATE = "Moderated Debate" # During this type, the queue to speak should not be automatic - UNMODERATED_DEBATE = "Unmoderated Debate" + SPEAKERS_LIST = "Lista de Discursos" + MODERATED_DEBATE = "Debate Moderado" # During this type, the queue to speak should not be automatic + UNMODERATED_DEBATE = "Debate não Moderado" class Motions(StrEnum): CHANGE_DEBATE_TYPE = "Mudar Tipo de Debate" - POSTPONE_SESSION = "Adiaamento de Sessão" - REOPEN_SESSION = "Reabrir Sessão" + POSTPONE_SESSION = "Adiamento de Sessão" + REOPEN_SESSION = "Reabertura de Sessão" TOUR_DE_TABLE = "Tour de Table" END_DEBATE = "Encerramento de Debate" # TODO: map this out since "motion to close debate" means clear GSL and go to voting procedures in modeldiplomat and can also mean the same as "motion to move into voting procedures" VOTE_AMENDMENT = "Votação de Emenda" # TODO: check the way this is used, since amendments MUST be voted if they're present during VOTING_PROCEDURES VOTE_BY_ROLL_CALL = "Votação por Chamada" # TODO: check the way this is used CLOSE_SPEAKERS_LIST = "Fechamento da Lista de Discursos" - REOPEN_SPEAKERS_LIST = "Reabrir a Lista de Discursos" - SPLIT_PROPOSAL = "Divisão de Proposta" - INTRODUCE_RESOLUTION_PROPOSAL = "Introdução de Proposta de Resolução" - INTRODUCE_AMENDMENT_PROPOSAL = "Introdução de Proposta de Emenda" + REOPEN_SPEAKERS_LIST = "Reabertura de Lista de Discursos" + SPLIT_PROPOSAL = "Divisão da Proposta" + INTRODUCE_RESOLUTION_PROPOSAL = "Introdução da Proposta de Resolução" + INTRODUCE_AMENDMENT_PROPOSAL = "Introdução da Proposta de Emenda" CHANGE_TOPIC = "Mudança de Tópico" - QUORUM = "Quórum" + QUORUM = "Contagem de Quórum" CUSTOM_MOTION = "" # not implemented class Questions(StrEnum): - ORDER = "Order" - QUESTION = "Question" - PERSONAL_PRIVILEGE = "Personal Privilege" + ORDER = "Ordem" + QUESTION = "Questão" + PERSONAL_PRIVILEGE = "Privilégio Pessoal" class RollCallChoice(StrEnum): @@ -112,9 +120,25 @@ class VotingChoice(StrEnum): FAVOUR = "Favour" AGAINST = "Against" ABSTAIN = "Abstain" + YES_WITH_RIGHTS = "Yes With Rights" + NO_WITH_RIGHTS = "No With Rights" + PASS = "Pass" class VotingType(StrEnum): INFORMAL = "Informal" PROCEDURAL = "Procedural" SUBSTANTIVE = "Substantive" + + +class MotionDecision(StrEnum): + """Tracks decision for LogMotionEvent for chair""" + + ACCEPT = "Accept" + DENY = "Deny" + + +class SubstantiveVoteRound(StrEnum): + INITIAL = "INITIAL" + YES_WITH_RIGHTS = "YES_WITH_RIGHTS" + NO_WITH_RIGHTS = "NO_WITH_RIGHTS" diff --git a/backend/app/session/models.py b/backend/app/session/models.py index e539e16..8fe9c6a 100644 --- a/backend/app/session/models.py +++ b/backend/app/session/models.py @@ -40,15 +40,42 @@ class MotionContext(BaseModel): priority: int = 0 type: enums.Motions delegate_id: int | None = None + timestamp: datetime debate_type: enums.DebateTypes | None = None total_duration_minutes: int | None = None per_speaker_seconds: int | None = None target_topic: str | None = None + # Substantive-related things + resolution_title: str | None = None + resolution_id: str | None = None + target_resolution_id: str | None = None + amendment_id: str | None = None + is_friendly: bool | None = None + split_title: str | None = None + split_resolution_id: str | None = None + details: str | None = None +class AmendmentContext(BaseModel): + id: str + target_resolution_id: str + is_friendly: bool + representation_id: int + + +class ResolutionContext(BaseModel): + id: str + title: str + delegate_id: int + amendments: list[AmendmentContext] = [] + + roll_call_vote: bool = False + parent_resolution_id: str | None = None + + class QuestionContext(BaseModel): id: int | None = None priority: int = 0 @@ -59,19 +86,46 @@ class QuestionContext(BaseModel): class VotingContext(BaseModel): target_type: enums.VotingType - motion_in_vote: MotionContext | None = None title: str | None = None return_state: enums.States voting_registry: dict[int, enums.VotingChoice] = {} + majority: enums.MajorityTypes | None = None + + motion_in_vote: MotionContext | None = None + resolution_in_vote: ResolutionContext | None = None + amendment_in_vote: AmendmentContext | None = None + substantive_round: enums.SubstantiveVoteRound | None = None + rights_queue: list[int] = [] + + allow_veto_power: bool = False + + def is_choice_allowed( + self, + choice: enums.VotingChoice, + is_roll_call: bool, + is_present_and_voting: bool, + ) -> bool: + if self.target_type == enums.VotingType.PROCEDURAL: + return choice in (enums.VotingChoice.FAVOUR, enums.VotingChoice.AGAINST) + + if self.target_type == enums.VotingType.SUBSTANTIVE: + if is_present_and_voting and choice == enums.VotingChoice.ABSTAIN: + return False + if not is_roll_call and choice in { + enums.VotingChoice.YES_WITH_RIGHTS, + enums.VotingChoice.NO_WITH_RIGHTS, + enums.VotingChoice.PASS, + }: + return False + + return True class DebateContext(BaseModel): debate_type: enums.DebateTypes return_state: enums.States - total_duration_seconds: int | None = None # TODO: check if this is needed - total_speeches: int | None = ( - None # Check if we use total duration or this for calculating overall time, it can also go overtime - ) + total_duration_seconds: int | None = None # check if its needed + total_speeches: int | None = None per_speaker_seconds: int | None = None expires_at: datetime | None = None topic: str | None = None @@ -79,7 +133,7 @@ class DebateContext(BaseModel): class RollCallContext(BaseModel): registry: dict[int, enums.RollCallChoice] = {} # Delegation Id as key - current_delegation: int | None = None # perhaps not needed + return_state: enums.States | None = None class AgendaItem(BaseModel): @@ -111,7 +165,6 @@ class SessionLiveState(BaseModel): gsl_default_time_seconds: int = 60 # Caucus variables - # TODO: how to add a popup placard that fades away after some moment in frontend? related to CHOOSE_SPEAKER caucus_list: list[ int ] = [] # special list that is only used during moderated caucus, has different semantic functionality than gsl queue @@ -134,9 +187,9 @@ class SessionLiveState(BaseModel): voting: VotingContext | None = None # present delegations with voting choice - voting_choice: dict[int, enums.RollCallChoice] | None = None # DelegationId as key - roll_call: RollCallContext # Not None, even if registry is empty + draft_resolutions: list[ResolutionContext] = [] + # Additional config has_veto_power: bool = False diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py index 239a93c..c875a86 100644 --- a/backend/app/session/schemas.py +++ b/backend/app/session/schemas.py @@ -14,23 +14,31 @@ class SessionCreationSchema(BaseModel): name: str | None = None -# --- Delegate Payloads --- -# TODO: refactor this to only reflect the payload received by delegates, with MotionModel being a separated entity -class DelegateMotionPayload(BaseModel): +class MotionPayload(BaseModel): + """General motion payload. Used on Delegate and Chair payloads""" + type: enums.Motions - delegate: int debate_type: enums.DebateTypes | None = None - total_duration_minutes: int | None = None per_speaker_seconds: int | None = None target_topic: str | None = None - + resolution_title: str | None = None + resolution_id: str | None = None + target_resolution_id: str | None = None + amendment_id: str | None = None + is_friendly: bool | None = None + split_title: str | None = None + split_resolution_id: str | None = None details: str | None = None +# --- Delegate Payloads --- +class DelegateMotionPayload(MotionPayload): + pass + + class DelegateQuestionPayload(BaseModel): type: enums.Questions - delegate: int details: str | None = None @@ -77,6 +85,13 @@ class AnswerRollCallEvent(BaseModel): # --- Chair Payloads --- +class ChairMotionPayload(MotionPayload): + """Extended payload for motions. Used to log motions""" + + representation_id: int + decision: enums.MotionDecision + + class ChairIncreaseTimerPayload(BaseModel): seconds: int = 5 @@ -97,9 +112,14 @@ class ChairResolveMotionPayload(BaseModel): action: bool -class ChairForceSpeakerPayload(BaseModel): - speaker_id: int | None = None # if none, will pass onto next speaker - seconds: int | None = None # if none, will be based on the current seconds +class RecordSubstantiveVotePayload(BaseModel): + representation_id: int + vote: enums.VotingChoice + + +class GrantFloorPayload(BaseModel): + representation_id: int + seconds: int | None = Field(default=None, ge=1) class ChairSetAgendaPayload(BaseModel): @@ -110,8 +130,8 @@ class ChairSetPhasePayload(BaseModel): target_phase: enums.States -class ChairInsertQueuePayload(BaseModel): - target: int # Delegate Id +class AddGslSpeakerPayload(BaseModel): + representation_id: int class SetAgendaItemPayload(BaseModel): @@ -142,6 +162,11 @@ class MarkRollCallBulkPayload(BaseModel): # --- Chair Events --- +class LogMotionEvent(BaseModel): + type: Literal[enums.ChairEvents.LOG_MOTION] + payload: ChairMotionPayload + + class OpenSessionEvent(BaseModel): type: Literal[enums.ChairEvents.OPEN_SESSION] payload: EmptyPayload @@ -172,9 +197,19 @@ class ResolveMotionEvent(BaseModel): payload: ChairResolveMotionPayload -class SpeakerEvent(BaseModel): - type: Literal[enums.ChairEvents.CHOOSE_SPEAKER] - payload: ChairForceSpeakerPayload +class NextSpeakerEvent(BaseModel): + type: Literal[enums.ChairEvents.NEXT_SPEAKER] + payload: EmptyPayload + + +class AddGslSpeakerEvent(BaseModel): + type: Literal[enums.ChairEvents.ADD_GSL_SPEAKER] + payload: AddGslSpeakerPayload + + +class GrantFloorEvent(BaseModel): + type: Literal[enums.ChairEvents.GRANT_FLOOR] + payload: GrantFloorPayload class SetAgendaEvent(BaseModel): @@ -197,6 +232,31 @@ class CloseProceduralVotingEvent(BaseModel): payload: EmptyPayload +class StartResolutionVoteEvent(BaseModel): + type: Literal[enums.ChairEvents.START_RESOLUTION_VOTE] + payload: EmptyPayload + + +class AdvanceSubstantiveVoteRoundEvent(BaseModel): + type: Literal[enums.ChairEvents.ADVANCE_SUBSTANTIVE_VOTE_ROUND] + payload: EmptyPayload + + +class RecordSubstantiveVoteEvent(BaseModel): + type: Literal[enums.ChairEvents.RECORD_SUBSTANTIVE_VOTE] + payload: RecordSubstantiveVotePayload + + +class CloseSubstantiveVotingEvent(BaseModel): + type: Literal[enums.ChairEvents.CLOSE_SUBSTANTIVE_VOTING] + payload: EmptyPayload + + +class FinishCaucusEvent(BaseModel): + type: Literal[enums.ChairEvents.FINISH_CAUCUS] + payload: EmptyPayload + + class MarkRollCallEvent(BaseModel): type: Literal[enums.ChairEvents.MARK_ROLLCALL] payload: MarkRollCallPayload @@ -212,11 +272,6 @@ class CloseRollCallEvent(BaseModel): payload: EmptyPayload -class ChairInsertQueueEvent(BaseModel): - type: Literal[enums.ChairEvents.INSERT_QUEUE] - payload: ChairInsertQueuePayload - - class MarkAgendaItemEvent(BaseModel): type: Literal[enums.ChairEvents.MARK_AGENDA_ITEM] payload: MarkAgendaItemPayload @@ -240,15 +295,23 @@ class DeleteAgendaItemEvent(BaseModel): | AnswerRollCallEvent | JoinQueueEvent | LeaveQueueEvent + | LogMotionEvent | OpenSessionEvent | CloseSessionEvent | IncreaseTimerEvent | ToggleTimerEvent | OpenInformalVotingEvent | CloseProceduralVotingEvent + | StartResolutionVoteEvent + | AdvanceSubstantiveVoteRoundEvent + | RecordSubstantiveVoteEvent + | CloseSubstantiveVotingEvent | CloseInformalVotingEvent + | FinishCaucusEvent | ResolveMotionEvent - | SpeakerEvent + | NextSpeakerEvent + | AddGslSpeakerEvent + | GrantFloorEvent | SetAgendaEvent | SetAgendaItemEvent | MarkAgendaItemEvent @@ -256,7 +319,6 @@ class DeleteAgendaItemEvent(BaseModel): | SetPhaseEvent | MarkRollCallEvent | CloseRollCallEvent - | ChairInsertQueueEvent | MarkRollCallBulkEvent, Field(discriminator="type"), ] diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py index 295b7e6..7867fcb 100644 --- a/backend/app/tests/session/test_engine.py +++ b/backend/app/tests/session/test_engine.py @@ -1,3 +1,5 @@ +from datetime import UTC, datetime + import pytest import app.session.engine as eng @@ -13,7 +15,7 @@ def open_gsl_state(session_state: md.SessionLiveState) -> md.SessionLiveState: @pytest.fixture -def voting_state(session_state: md.SessionLiveState) -> md.SessionLiveState: +def informal_voting_state(session_state: md.SessionLiveState) -> md.SessionLiveState: session_state.current_state = enums.States.VOTING_EXECUTION session_state.voting = md.VotingContext( target_type=enums.VotingType.INFORMAL, @@ -31,7 +33,6 @@ def submit_debate_motion_event( type=enums.DelegateEvents.SUBMIT_MOTION, payload=sch.DelegateMotionPayload( type=enums.Motions.CHANGE_DEBATE_TYPE, - delegate=delegate_actor.delegation.id, # type: ignore[union-attr] debate_type=enums.DebateTypes.MODERATED_DEBATE, total_duration_minutes=10, per_speaker_seconds=60, @@ -39,6 +40,21 @@ def submit_debate_motion_event( ) +@pytest.fixture +def log_motion_event(chair_actor: md.SessionActor) -> sch.LogMotionEvent: + return sch.LogMotionEvent( + type=enums.ChairEvents.LOG_MOTION, + payload=sch.ChairMotionPayload( + type=enums.Motions.CHANGE_DEBATE_TYPE, + debate_type=enums.DebateTypes.MODERATED_DEBATE, + total_duration_minutes=10, + per_speaker_seconds=60, + representation_id=1, + decision=enums.MotionDecision.ACCEPT, + ), + ) + + @pytest.fixture def submit_question_event(delegate_actor: md.SessionActor) -> sch.SubmitQuestionEvent: return sch.SubmitQuestionEvent( @@ -129,6 +145,14 @@ def close_procedural_voting_event() -> sch.CloseProceduralVotingEvent: ) +@pytest.fixture +def finish_caucus_event() -> sch.FinishCaucusEvent: + return sch.FinishCaucusEvent( + type=enums.ChairEvents.FINISH_CAUCUS, + payload=sch.EmptyPayload(), + ) + + @pytest.fixture def close_speakers_list_motion( delegate_actor: md.SessionActor, @@ -137,6 +161,7 @@ def close_speakers_list_motion( id=1, priority=1, type=enums.Motions.CLOSE_SPEAKERS_LIST, + timestamp=datetime.now(UTC), delegate_id=delegate_actor.delegation.id, # type: ignore[union-attr] ) @@ -147,6 +172,7 @@ def reopen_speakers_list_motion(delegate_actor: md.SessionActor) -> md.MotionCon id=1, priority=1, type=enums.Motions.REOPEN_SPEAKERS_LIST, + timestamp=datetime.now(UTC), delegate_id=delegate_actor.delegation.id, # type: ignore[union-attr] ) @@ -175,10 +201,26 @@ def resolve_motion_event() -> sch.ResolveMotionEvent: @pytest.fixture -def choose_speaker_event() -> sch.SpeakerEvent: - return sch.SpeakerEvent( - type=enums.ChairEvents.CHOOSE_SPEAKER, - payload=sch.ChairForceSpeakerPayload(speaker_id=1, seconds=45), +def next_speaker_event() -> sch.NextSpeakerEvent: + return sch.NextSpeakerEvent( + type=enums.ChairEvents.NEXT_SPEAKER, + payload=sch.EmptyPayload(), + ) + + +@pytest.fixture +def add_gsl_speaker_event() -> sch.AddGslSpeakerEvent: + return sch.AddGslSpeakerEvent( + type=enums.ChairEvents.ADD_GSL_SPEAKER, + payload=sch.AddGslSpeakerPayload(representation_id=1), + ) + + +@pytest.fixture +def grant_floor_event() -> sch.GrantFloorEvent: + return sch.GrantFloorEvent( + type=enums.ChairEvents.GRANT_FLOOR, + payload=sch.GrantFloorPayload(representation_id=1, seconds=45), ) @@ -206,14 +248,6 @@ def mark_roll_call_bulk_event() -> sch.MarkRollCallBulkEvent: ) -@pytest.fixture -def insert_queue_event() -> sch.ChairInsertQueueEvent: - return sch.ChairInsertQueueEvent( - type=enums.ChairEvents.INSERT_QUEUE, - payload=sch.ChairInsertQueuePayload(target=0), - ) - - @pytest.fixture def open_session_event() -> sch.OpenSessionEvent: return sch.OpenSessionEvent( @@ -272,10 +306,29 @@ def test_delegate_cannot_submit_motion_outside_allowed_phase( engine.dispatch(session_state, submit_debate_motion_event, delegate_actor) -@pytest.mark.xfail( - strict=True, - reason="handle_submit_motion does not currently reject chair actors.", -) +def test_delegate_cannot_submit_chair_motion( + engine: eng.SessionEngine, + session_state: md.SessionLiveState, + log_motion_event: sch.LogMotionEvent, + delegate_actor: md.SessionActor, +) -> None: + with pytest.raises(eng.InvalidProceduralMove, match="Chair role required"): + engine.dispatch(session_state, log_motion_event, delegate_actor) + + +def test_chair_can_log_motion( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + log_motion_event: sch.LogMotionEvent, + chair_actor: md.SessionActor, +) -> None: + state = engine.dispatch(open_gsl_state, log_motion_event, chair_actor) + assert state.current_state == enums.States.VOTING_EXECUTION + assert state.voting is not None + assert state.voting.motion_in_vote is not None + assert state.voting.motion_in_vote.type == log_motion_event.payload.type + + def test_chair_cannot_submit_delegate_motion( engine: eng.SessionEngine, open_gsl_state: md.SessionLiveState, @@ -368,11 +421,11 @@ def test_delegate_cannot_leave_queue_when_not_queued( def test_delegate_can_cast_vote( engine: eng.SessionEngine, - voting_state: md.SessionLiveState, + informal_voting_state: md.SessionLiveState, cast_vote_event: sch.CastVoteEvent, delegate_actor: md.SessionActor, ) -> None: - state = engine.dispatch(voting_state, cast_vote_event, delegate_actor) + state = engine.dispatch(informal_voting_state, cast_vote_event, delegate_actor) assert state.voting is not None assert state.voting.voting_registry == {0: enums.VotingChoice.FAVOUR} @@ -380,14 +433,14 @@ def test_delegate_can_cast_vote( def test_delegate_cannot_cast_vote_twice( engine: eng.SessionEngine, - voting_state: md.SessionLiveState, + informal_voting_state: md.SessionLiveState, cast_vote_event: sch.CastVoteEvent, delegate_actor: md.SessionActor, ) -> None: - engine.dispatch(voting_state, cast_vote_event, delegate_actor) + engine.dispatch(informal_voting_state, cast_vote_event, delegate_actor) with pytest.raises(eng.InvalidProceduralMove, match="Already cast vote"): - engine.dispatch(voting_state, cast_vote_event, delegate_actor) + engine.dispatch(informal_voting_state, cast_vote_event, delegate_actor) def test_delegate_cannot_cast_vote_without_voting_context( @@ -429,12 +482,89 @@ def test_chair_can_close_roll_call( state = engine.dispatch(session_state, close_roll_call_event, chair_actor) assert state.current_state == enums.States.OPEN_GSL - assert state.voting_choice == { + assert state.roll_call.registry == { 1: enums.RollCallChoice.PRESENT, 2: enums.RollCallChoice.PRESENT_AND_VOTING, } +def test_quorum_roll_call_restores_closed_gsl( + engine: eng.SessionEngine, + session_state: md.SessionLiveState, + close_procedural_voting_event: sch.CloseProceduralVotingEvent, + close_roll_call_event: sch.CloseRollCallEvent, + chair_actor: md.SessionActor, +) -> None: + session_state.current_state = enums.States.VOTING_EXECUTION + session_state.roll_call.registry = { + 0: enums.RollCallChoice.PRESENT, + 1: enums.RollCallChoice.PRESENT, + 2: enums.RollCallChoice.PRESENT, + } + session_state.voting = md.VotingContext( + target_type=enums.VotingType.PROCEDURAL, + return_state=enums.States.CLOSED_GSL, + motion_in_vote=md.MotionContext( + id=1, + type=enums.Motions.QUORUM, + timestamp=datetime.now(UTC), + ), + voting_registry={ + 0: enums.VotingChoice.FAVOUR, + 1: enums.VotingChoice.FAVOUR, + }, + ) + + state = engine.dispatch(session_state, close_procedural_voting_event, chair_actor) + + assert state.current_state == enums.States.ROLL_CALL + assert state.roll_call.return_state == enums.States.CLOSED_GSL + + state = engine.dispatch(state, close_roll_call_event, chair_actor) + + assert state.current_state == enums.States.CLOSED_GSL + + +def test_quorum_roll_call_restores_moderated_caucus( + engine: eng.SessionEngine, + session_state: md.SessionLiveState, + close_procedural_voting_event: sch.CloseProceduralVotingEvent, + close_roll_call_event: sch.CloseRollCallEvent, + chair_actor: md.SessionActor, +) -> None: + debate = md.DebateContext( + debate_type=enums.DebateTypes.MODERATED_DEBATE, + return_state=enums.States.OPEN_GSL, + per_speaker_seconds=60, + ) + session_state.current_state = enums.States.VOTING_EXECUTION + session_state.debate = debate + session_state.roll_call.registry = { + 0: enums.RollCallChoice.PRESENT, + 1: enums.RollCallChoice.PRESENT, + 2: enums.RollCallChoice.PRESENT, + } + session_state.voting = md.VotingContext( + target_type=enums.VotingType.PROCEDURAL, + return_state=enums.States.MODERATED_CAUCUS, + motion_in_vote=md.MotionContext( + id=1, + type=enums.Motions.QUORUM, + timestamp=datetime.now(UTC), + ), + voting_registry={ + 0: enums.VotingChoice.FAVOUR, + 1: enums.VotingChoice.FAVOUR, + }, + ) + + state = engine.dispatch(session_state, close_procedural_voting_event, chair_actor) + state = engine.dispatch(state, close_roll_call_event, chair_actor) + + assert state.current_state == enums.States.MODERATED_CAUCUS + assert state.debate == debate + + def test_delegate_cannot_close_roll_call( engine: eng.SessionEngine, session_state: md.SessionLiveState, @@ -522,11 +652,13 @@ def test_delegate_cannot_open_informal_voting( def test_chair_can_close_informal_voting( engine: eng.SessionEngine, - voting_state: md.SessionLiveState, + informal_voting_state: md.SessionLiveState, close_informal_voting_event: sch.CloseInformalVotingEvent, chair_actor: md.SessionActor, ) -> None: - state = engine.dispatch(voting_state, close_informal_voting_event, chair_actor) + state = engine.dispatch( + informal_voting_state, close_informal_voting_event, chair_actor + ) assert state.current_state == enums.States.OPEN_GSL assert state.voting is None @@ -598,7 +730,7 @@ def test_chair_can_close_passed_procedural_vote( close_procedural_voting_event: sch.CloseProceduralVotingEvent, chair_actor: md.SessionActor, ) -> None: - procedural_voting_state.voting_choice = { + procedural_voting_state.roll_call.registry = { 0: enums.RollCallChoice.PRESENT, 1: enums.RollCallChoice.PRESENT, 2: enums.RollCallChoice.PRESENT, @@ -626,7 +758,7 @@ def test_chair_can_close_failed_procedural_vote( close_procedural_voting_event: sch.CloseProceduralVotingEvent, chair_actor: md.SessionActor, ) -> None: - procedural_voting_state.voting_choice = { + procedural_voting_state.roll_call.registry = { 0: enums.RollCallChoice.PRESENT, 1: enums.RollCallChoice.PRESENT, 2: enums.RollCallChoice.PRESENT, @@ -662,6 +794,56 @@ def test_delegate_cannot_close_procedural_vote( ) +def test_chair_can_finish_caucus_and_restore_original_gsl_state( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + finish_caucus_event: sch.FinishCaucusEvent, + chair_actor: md.SessionActor, +) -> None: + open_gsl_state.current_state = enums.States.MODERATED_CAUCUS + open_gsl_state.current_speaker = 0 + open_gsl_state.caucus_list = [0, 1] + open_gsl_state.timer_is_running = True + open_gsl_state.timer_remaining_seconds = 30 + open_gsl_state.debate = md.DebateContext( + debate_type=enums.DebateTypes.MODERATED_DEBATE, + return_state=enums.States.OPEN_GSL, + total_duration_seconds=600, + per_speaker_seconds=60, + expires_at=datetime.now(UTC), + ) + + state = engine.dispatch(open_gsl_state, finish_caucus_event, chair_actor) + + assert state.current_state == enums.States.OPEN_GSL + assert state.debate is None + assert state.current_speaker is None + assert state.caucus_list == [] + assert state.timer_is_running is False + assert state.timer_expiration is None + assert state.timer_remaining_seconds == 0 + + +def test_delegate_cannot_finish_caucus( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + finish_caucus_event: sch.FinishCaucusEvent, + delegate_actor: md.SessionActor, +) -> None: + with pytest.raises(eng.InvalidProceduralMove, match="Chair role required"): + engine.dispatch(open_gsl_state, finish_caucus_event, delegate_actor) + + +def test_chair_cannot_finish_without_active_caucus( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + finish_caucus_event: sch.FinishCaucusEvent, + chair_actor: md.SessionActor, +) -> None: + with pytest.raises(eng.InvalidProceduralMove, match="No active caucus"): + engine.dispatch(open_gsl_state, finish_caucus_event, chair_actor) + + def test_tally_votes_correctly_marks_success_simple( reopen_speakers_list_motion: md.MotionContext, ) -> None: @@ -701,28 +883,162 @@ def test_tally_votes_correctly_marks_fail_majority( assert not res -def test_chair_can_choose_speaker( +def test_chair_can_advance_gsl_speaker( engine: eng.SessionEngine, open_gsl_state: md.SessionLiveState, - choose_speaker_event: sch.SpeakerEvent, + next_speaker_event: sch.NextSpeakerEvent, chair_actor: md.SessionActor, ) -> None: - state = engine.dispatch(open_gsl_state, choose_speaker_event, chair_actor) + open_gsl_state.gsl_queue = [1, 2] + + state = engine.dispatch(open_gsl_state, next_speaker_event, chair_actor) assert state.current_speaker == 1 + assert state.gsl_queue == [2] assert state.timer_is_running is False assert state.timer_expiration is None - assert state.timer_remaining_seconds == 45 + assert state.timer_remaining_seconds == state.gsl_default_time_seconds + + +def test_chair_can_advance_tour_de_table_speaker( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + next_speaker_event: sch.NextSpeakerEvent, + chair_actor: md.SessionActor, +) -> None: + open_gsl_state.current_state = enums.States.TOUR_DE_TABLE + open_gsl_state.caucus_list = [1, 2] + state = engine.dispatch(open_gsl_state, next_speaker_event, chair_actor) + + assert state.current_speaker == 1 + assert state.caucus_list == [2] + assert state.timer_remaining_seconds == state.gsl_default_time_seconds -def test_delegate_cannot_choose_speaker( + +def test_next_speaker_clears_current_speaker_when_gsl_empty( engine: eng.SessionEngine, open_gsl_state: md.SessionLiveState, - choose_speaker_event: sch.SpeakerEvent, + next_speaker_event: sch.NextSpeakerEvent, + chair_actor: md.SessionActor, +) -> None: + open_gsl_state.current_speaker = 1 + open_gsl_state.timer_is_running = True + + state = engine.dispatch(open_gsl_state, next_speaker_event, chair_actor) + + assert state.current_speaker is None + assert state.timer_remaining_seconds == 0 + assert state.timer_is_running is False + + +def test_next_speaker_rejects_moderated_caucus( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + next_speaker_event: sch.NextSpeakerEvent, + chair_actor: md.SessionActor, +) -> None: + open_gsl_state.current_state = enums.States.MODERATED_CAUCUS + + with pytest.raises(eng.InvalidProceduralMove, match="must grant floor"): + engine.dispatch(open_gsl_state, next_speaker_event, chair_actor) + + +def test_delegate_cannot_advance_speaker( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + next_speaker_event: sch.NextSpeakerEvent, delegate_actor: md.SessionActor, ) -> None: with pytest.raises(eng.InvalidProceduralMove, match="Chair role required"): - engine.dispatch(open_gsl_state, choose_speaker_event, delegate_actor) + engine.dispatch(open_gsl_state, next_speaker_event, delegate_actor) + + +def test_chair_can_add_gsl_speaker( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + add_gsl_speaker_event: sch.AddGslSpeakerEvent, + chair_actor: md.SessionActor, +) -> None: + state = engine.dispatch(open_gsl_state, add_gsl_speaker_event, chair_actor) + + assert state.gsl_queue == [1] + + +def test_add_gsl_speaker_rejects_duplicates( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + add_gsl_speaker_event: sch.AddGslSpeakerEvent, + chair_actor: md.SessionActor, +) -> None: + open_gsl_state.gsl_queue = [1] + + with pytest.raises(eng.InvalidProceduralMove, match="already in GSL queue"): + engine.dispatch(open_gsl_state, add_gsl_speaker_event, chair_actor) + + +def test_chair_can_grant_floor_and_remove_gsl_queue_entry( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + grant_floor_event: sch.GrantFloorEvent, + chair_actor: md.SessionActor, +) -> None: + open_gsl_state.gsl_queue = [0, 1, 2] + + state = engine.dispatch(open_gsl_state, grant_floor_event, chair_actor) + + assert state.current_speaker == 1 + assert state.gsl_queue == [0, 2] + assert state.timer_remaining_seconds == 45 + + +def test_chair_can_grant_floor_and_remove_tour_de_table_entry( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + grant_floor_event: sch.GrantFloorEvent, + chair_actor: md.SessionActor, +) -> None: + open_gsl_state.current_state = enums.States.TOUR_DE_TABLE + open_gsl_state.caucus_list = [0, 1, 2] + + state = engine.dispatch(open_gsl_state, grant_floor_event, chair_actor) + + assert state.current_speaker == 1 + assert state.caucus_list == [0, 2] + assert state.timer_remaining_seconds == 45 + + +def test_chair_can_grant_floor_in_moderated_caucus( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + grant_floor_event: sch.GrantFloorEvent, + chair_actor: md.SessionActor, +) -> None: + open_gsl_state.current_state = enums.States.MODERATED_CAUCUS + open_gsl_state.debate = md.DebateContext( + debate_type=enums.DebateTypes.MODERATED_DEBATE, + return_state=enums.States.OPEN_GSL, + per_speaker_seconds=60, + ) + open_gsl_state.caucus_list = [0, 1] + + state = engine.dispatch(open_gsl_state, grant_floor_event, chair_actor) + + assert state.current_speaker == 1 + assert state.caucus_list == [0, 1] + assert state.timer_remaining_seconds == 45 + + +def test_grant_floor_rejects_unmoderated_caucus( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + grant_floor_event: sch.GrantFloorEvent, + chair_actor: md.SessionActor, +) -> None: + open_gsl_state.current_state = enums.States.UNMODERATED_CAUCUS + + with pytest.raises(eng.InvalidProceduralMove, match="unmoderated caucus"): + engine.dispatch(open_gsl_state, grant_floor_event, chair_actor) def test_chair_can_mark_roll_call( @@ -794,17 +1110,6 @@ def test_chair_cannot_mark_roll_call_bulk_nonexistent_delegations( engine.dispatch(session_state, event, chair_actor) -def test_chair_insert_queue_uses_delegation_id( - engine: eng.SessionEngine, - open_gsl_state: md.SessionLiveState, - insert_queue_event: sch.ChairInsertQueueEvent, - chair_actor: md.SessionActor, -) -> None: - state = engine.dispatch(open_gsl_state, insert_queue_event, chair_actor) - - assert state.gsl_queue == [0] - - def test_chair_open_session_starts_roll_call( engine: eng.SessionEngine, session_state: md.SessionLiveState, @@ -843,3 +1148,162 @@ def test_chair_can_manually_set_phase( state = engine.dispatch(session_state, manual_phase_set_event, chair_actor) assert state.current_state == enums.States.OPEN_GSL + + +def _submit_resolution_motion(engine, state, actor, **payload): + return engine.dispatch( + state, + sch.SubmitMotionEvent( + type=enums.DelegateEvents.SUBMIT_MOTION, + payload=sch.DelegateMotionPayload(**payload), + ), + actor, + ) + + +def _resolve_last_motion(engine, state, chair): + return engine.dispatch( + state, + sch.ResolveMotionEvent( + type=enums.ChairEvents.RESOLVE_MOTION, + payload=sch.ChairResolveMotionPayload( + motion_id=state.submitted_motions[-1].id, action=True + ), + ), + chair, + ) + + +def test_resolution_and_pending_amendment_are_voted_in_order( + engine, session_state, delegate_actor, chair_actor +): + session_state.current_state = enums.States.OPEN_GSL + _resolve_last_motion( + engine, + _submit_resolution_motion( + engine, + session_state, + delegate_actor, + type=enums.Motions.INTRODUCE_RESOLUTION_PROPOSAL, + resolution_title="Clean Water", + resolution_id="resolution-clean-water", + ), + chair_actor, + ) + resolution = session_state.draft_resolutions[0] + _resolve_last_motion( + engine, + _submit_resolution_motion( + engine, + session_state, + delegate_actor, + type=enums.Motions.INTRODUCE_AMENDMENT_PROPOSAL, + target_resolution_id=resolution.id, + amendment_id="amendment-clean-water-1", + is_friendly=False, + ), + chair_actor, + ) + + session_state.current_state = enums.States.VOTING_PREPARATION + engine.dispatch( + session_state, + sch.StartResolutionVoteEvent( + type=enums.ChairEvents.START_RESOLUTION_VOTE, + payload=sch.EmptyPayload(), + ), + chair_actor, + ) + assert session_state.voting is not None + assert session_state.voting.amendment_in_vote is not None + + session_state.roll_call.registry = {0: enums.RollCallChoice.PRESENT} + engine.dispatch( + session_state, + sch.CastVoteEvent( + type=enums.DelegateEvents.CAST_VOTE, + payload=sch.DelegateVotingPayload(vote=enums.VotingChoice.FAVOUR), + ), + delegate_actor, + ) + engine.dispatch( + session_state, + sch.CloseProceduralVotingEvent( + type=enums.ChairEvents.CLOSE_PROCEDURAL_VOTING, + payload=sch.EmptyPayload(), + ), + chair_actor, + ) + assert resolution.amendments == [] + assert session_state.voting is not None + assert session_state.voting.target_type == enums.VotingType.SUBSTANTIVE + + +def test_roll_call_requires_all_votes_then_processes_rights_queue( + engine, session_state, chair_actor +): + resolution = md.ResolutionContext( + id="resolution-1", title="Test", delegate_id=0, roll_call_vote=True + ) + session_state.draft_resolutions.append(resolution) + session_state.current_state = enums.States.VOTING_PREPARATION + session_state.roll_call.registry = { + 0: enums.RollCallChoice.PRESENT, + 1: enums.RollCallChoice.PRESENT_AND_VOTING, + } + engine.dispatch( + session_state, + sch.StartResolutionVoteEvent( + type=enums.ChairEvents.START_RESOLUTION_VOTE, + payload=sch.EmptyPayload(), + ), + chair_actor, + ) + for representation_id, vote in ( + (0, enums.VotingChoice.YES_WITH_RIGHTS), + (1, enums.VotingChoice.AGAINST), + ): + engine.dispatch( + session_state, + sch.RecordSubstantiveVoteEvent( + type=enums.ChairEvents.RECORD_SUBSTANTIVE_VOTE, + payload=sch.RecordSubstantiveVotePayload( + representation_id=representation_id, vote=vote + ), + ), + chair_actor, + ) + advance = sch.AdvanceSubstantiveVoteRoundEvent( + type=enums.ChairEvents.ADVANCE_SUBSTANTIVE_VOTE_ROUND, + payload=sch.EmptyPayload(), + ) + engine.dispatch(session_state, advance, chair_actor) + assert session_state.voting is not None + assert session_state.voting.rights_queue == [0] + engine.dispatch( + session_state, + sch.NextSpeakerEvent( + type=enums.ChairEvents.NEXT_SPEAKER, payload=sch.EmptyPayload() + ), + chair_actor, + ) + assert session_state.timer_remaining_seconds == 30 + engine.dispatch(session_state, advance, chair_actor) + engine.dispatch(session_state, advance, chair_actor) + assert resolution not in session_state.draft_resolutions + assert session_state.current_state == enums.States.VOTING_PREPARATION + + +def test_duplicate_pending_resolution_id_is_rejected( + engine, session_state, delegate_actor +): + session_state.current_state = enums.States.OPEN_GSL + payload = { + "type": enums.Motions.INTRODUCE_RESOLUTION_PROPOSAL, + "resolution_title": "Clean Water", + "resolution_id": "resolution-clean-water", + } + _submit_resolution_motion(engine, session_state, delegate_actor, **payload) + + with pytest.raises(eng.InvalidProceduralMove, match="already reserved"): + _submit_resolution_motion(engine, session_state, delegate_actor, **payload) diff --git a/docs/realtime/events-and-payloads.md b/docs/realtime/events-and-payloads.md index b339380..7553eed 100644 --- a/docs/realtime/events-and-payloads.md +++ b/docs/realtime/events-and-payloads.md @@ -16,7 +16,7 @@ An event is an action a client requests. Its payload is the event-specific input ## Delegate events - `SubmitMotionEvent` - - Payload: `type`, `delegate`, and optional `debate_type`, + - Payload: `type` and optional `debate_type`, `total_duration_minutes`, `per_speaker_seconds`, `target_topic`, and `details`. - Submits a motion allowed in the current phase. The backend records the @@ -35,9 +35,10 @@ An event is an action a client requests. Its payload is the event-specific input - Removes the authenticated delegate from the open GSL queue. - `CastVoteEvent` - - Payload: `type` (`FORMAL` or `INFORMAL`), `vote` (`FAVOUR`, `AGAINST`, or - `ABSTAIN`), and optional `motion_id` and `title`. + - Payload: `vote`. - Records one vote from the authenticated delegate while voting is active. + Substantive votes permit `Favour`, `Against`, and `Abstain`; a roll-call + initial round also permits `Yes With Rights` and `No With Rights`. - `AnswerRollCallEvent` - Payload: `choice` (`Present` or `Present and Voting`). @@ -53,6 +54,19 @@ An event is an action a client requests. Its payload is the event-specific input `SubmitMotionEvent.payload.type` is one of the backend `Motions` values, such as `Mudar Tipo de Debate`, `Encerramento de Debate`, or `Quórum`. Which motion types are allowed depends on `current_state`. The optional fields are validated when the selected motion needs them; for example, a debate-type motion can need `debate_type` and duration information. +Resolution-related motions require the following additional fields: + +- `Introdução da Proposta de Resolução`: `resolution_id`, `resolution_title`. +- `Introdução da Proposta de Emenda`: `target_resolution_id`, `amendment_id`, + `is_friendly`. +- `Divisão da Proposta`: `target_resolution_id`, `split_resolution_id`, + `split_title`. +- `Votação por Chamada`: `target_resolution_id`. + +These IDs are supplied by the delegate and are accepted into live state only +after chair acceptance. The authenticated actor supplies the submitting +representation; clients do not send it. + ### Question payload values `SubmitQuestionEvent.payload.type` is `Order`, `Question`, or `Personal @@ -69,6 +83,10 @@ Privilege`. - Moves an allowed session to `Finished` and clears active debate and timer data. +- `LogMotionEvent` + - Payload: same variables from `SubmitMotionEvent` plus `representation_id` and `decision` (`Accept` or `DENY`) + - Records the decision from chair for a specific motion, not sent in the system by any delegation + - `ToggleTimerEvent` - Payload: optional `toggle` (defaults to `true`). - Starts or pauses the timer. The current implementation toggles based on @@ -97,11 +115,57 @@ Privilege`. - Tallies the current procedural vote and applies its result or returns to the earlier phase. -- `SpeakerEvent` - - Payload: optional `speaker_id` and `seconds`. - - Sets a current speaker and the timer duration. Although the schema allows - an omitted `speaker_id`, the current handler requires an existing - delegation ID. +- `StartResolutionVoteEvent` + - Payload: `{}`. + - Valid only in Voting Preparation. Starts the first `DRAFT` resolution in + `draft_resolutions` list order and enters Voting Procedures. Pending + unfriendly amendments open procedural votes before the substantive vote. + +- `RecordSubstantiveVoteEvent` + - Payload: `representation_id`, `vote`. + - Lets the chair record one allowed substantive vote for an eligible + representation. The representation must exist in the session and cannot + be recorded twice. + +- `CloseSubstantiveVotingEvent` + - Payload: `{}`. + - Closes and tallies a standard substantive resolution vote. It is not used + for a roll-call substantive vote. + +- `AdvanceSubstantiveVoteRoundEvent` + - Payload: `{}`. + - Advances a roll-call substantive vote from its initial vote to the + yes-with-rights queue, then the no-with-rights queue, then the final tally. + Every eligible representation must vote before the first advance, and the + active rights queue must be empty before later advances. + +- `FinishCaucusEvent` + - Payload: `{}`. + - Ends an active moderated or unmoderated caucus. Clears its speaker, timer, + and caucus list, then returns to the GSL state stored when the caucus was + opened. + +- `NextSpeakerEvent` + - Payload: `{}`. + - In Open or Closed GSL, pops the next `representation_id` from `gsl_queue` + into `current_speaker`. In Tour de Table, does the same with `caucus_list`. + An empty list clears the current speaker and timer. During a roll-call + rights round, it serves the next representation in that rights queue for + 30 seconds. It is not valid for a moderated or unmoderated caucus. + +- `AddGslSpeakerEvent` + - Payload: `representation_id`. + - Adds a valid representation to the GSL queue during Open or Closed GSL. + Duplicate queue entries are rejected. + +- `GrantFloorEvent` + - Payload: `representation_id` and optional positive `seconds`. + - Makes a valid representation the current speaker in GSL, moderated + caucus, or Tour de Table. In GSL and Tour de Table, removes that + representation from its pending queue/list first. In moderated caucus, it + leaves `caucus_list` unchanged. During a roll-call rights round, only a + queued representation may receive the floor and the duration is forced to + 30 seconds. It is not valid during unmoderated caucus. - `MarkRollCallEvent` - Payload: `delegation_id` and `choice` (`Present`, `Present and Voting`, or @@ -118,10 +182,6 @@ Privilege`. - Marks unrecorded delegations absent, creates voting eligibility, and moves to Open GSL. -- `InsertQueueEvent` - - Payload: `target`. - - Adds the target delegation to the GSL queue. - - `SetAgendaEvent` - Payload: `agenda`, a list of strings. - Declared in the schema, but its handler is not implemented. Do not use it diff --git a/docs/realtime/mun-flow.md b/docs/realtime/mun-flow.md index d894067..b38f731 100644 --- a/docs/realtime/mun-flow.md +++ b/docs/realtime/mun-flow.md @@ -41,12 +41,19 @@ Open GSL <-------------------------------+ v procedural motion / chair action -Open or Closed GSL -- accepted end-debate motion --> Voting Procedures +Open or Closed GSL -- accepted end-debate motion --> Voting Preparation | - | substantive-resolution flow - | is not implemented yet + | chair starts next draft v - Finished + Voting Procedures + | + pending amendments -> procedural amendment votes + | + v + substantive resolution vote + | + v + Voting Preparation ``` `Finished` can also be reached when the chair closes the session from an @@ -123,10 +130,48 @@ WebMUN currently supports two distinct voting mechanisms: 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. +An accepted end-debate motion enters `Voting Preparation`. The chair then uses +`StartResolutionVoteEvent` to start the first `DRAFT` item in +`draft_resolutions`, in list order. The session enters `Voting Procedures`. + +Before a resolution's substantive vote, each pending unfriendly amendment on +that resolution is voted procedurally in submission order. A passed amendment +becomes adopted; a failed amendment becomes rejected. Friendly amendments are +accepted directly when the chair accepts their introduction. + +The substantive vote has two forms: + +- **Standard vote:** each eligible delegate may submit `Favour`, `Against`, or + `Abstain`; the chair may record a vote on a delegation's behalf and closes + the vote with `CloseSubstantiveVotingEvent`. +- **Roll-call vote:** a passed `Vote By Roll Call` motion marks one draft for + roll call. Every representation marked `Present` or `Present and Voting` in + the completed roll call must vote. The initial round also accepts + `Yes With Rights` and `No With Rights`. The chair advances through the yes + and no rights queues using `AdvanceSubstantiveVoteRoundEvent`; the existing + floor controls serve each queued representation for 30 seconds. + +`Present and Voting` representations cannot abstain. A terminal substantive +vote returns to `Voting Preparation`, where the chair may start the next draft +when one is available. + +### Resolution preparation motions + +Delegates submit resolution-related motion fields as strings. The chair must +accept the introduction before the draft enters live state; acceptance is the +formatting and procedural gate. + +- Resolution introduction requires `resolution_id` and `resolution_title`. +- Amendment introduction requires `target_resolution_id`, `amendment_id`, and + `is_friendly`. +- Split Proposal requires `target_resolution_id`, `split_resolution_id`, and + `split_title`. When passed in Voting Preparation, it retains the parent and + appends a child draft with the parent's submitter and roll-call setting. +- Vote By Roll Call requires `target_resolution_id` and, when passed in Voting + Preparation, marks only that draft for a roll-call substantive vote. + +`VOTE_AMENDMENT` is not an available motion. Pending unfriendly amendments are +handled automatically as part of their target resolution's vote. ### 6. Close the session @@ -151,6 +196,12 @@ The state includes both the phase and the context required to continue it: - submitted motions and questions; and - the current voting context and the phase to return to. +`draft_resolutions` contains only active drafts. A terminal substantive vote +removes its resolution from the live list, and a completed unfriendly amendment +is removed from its pending list. The planned audit/event stream will retain +the outcome and tally, then notify connected clients with explicit result +events. + ### Commands in, snapshots out Clients connect to the session WebSocket, authenticate with a Supabase JWT, diff --git a/docs/realtime/session-state.md b/docs/realtime/session-state.md index 405bfc8..13061f5 100644 --- a/docs/realtime/session-state.md +++ b/docs/realtime/session-state.md @@ -25,7 +25,21 @@ event. It is also persisted so an active session can be restored. `agenda_topics` hold pending procedural information. - **Voting and attendance:** `voting`, `voting_choice`, and `roll_call` hold - voting context and roll-call records. + voting context and roll-call records. New substantive eligibility is derived + from `roll_call.registry`; `voting_choice` is legacy state planned for + removal. + +- **Drafts:** `draft_resolutions` holds accepted active resolution drafts. A + draft has its delegate-supplied `id`, title, submitter, optional parent ID, + roll-call flag, and amendments. Amendments record their delegate-supplied ID, + target, submitter, and friendliness. Terminal resolutions and completed + unfriendly amendments are removed from live state. + +- **Active vote:** for a resolution vote, `voting.resolution_in_vote` identifies + the draft. Pending amendment procedural votes also set + `voting.amendment_in_vote`. A roll-call substantive vote uses + `voting.substantive_round` and `voting.rights_queue` to represent its active + stage and speaker order. ## Using a snapshot on the frontend diff --git a/frontend/src/components/session/Agenda.tsx b/frontend/src/components/session/Agenda.tsx index a5ca987..a33278d 100644 --- a/frontend/src/components/session/Agenda.tsx +++ b/frontend/src/components/session/Agenda.tsx @@ -84,7 +84,7 @@ export default function Agenda() { @@ -96,7 +96,7 @@ export default function Agenda() { @@ -108,7 +108,7 @@ export default function Agenda() { @@ -145,7 +145,7 @@ export default function Agenda() { () => { if(numinput.current && topicinput.current) { sendMessage({type:ChairEvents.SET_AGENDA_ITEM_EVENT, - payload:{index:numinput.current.value, topic: topicinput.current.value}} as SetAgendaItemEvent) + payload:{index:numinput.current.value, topic: topicinput.current.value}} satisfies SetAgendaItemEvent) numinput.current.value = "" topicinput.current.value = "" } diff --git a/frontend/src/components/session/bottom-bar-buttons/MotionsButton.tsx b/frontend/src/components/session/bottom-bar-buttons/MotionsButton.tsx index b1661ce..5de6b28 100644 --- a/frontend/src/components/session/bottom-bar-buttons/MotionsButton.tsx +++ b/frontend/src/components/session/bottom-bar-buttons/MotionsButton.tsx @@ -39,53 +39,42 @@ import { import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { Label } from "@/components/ui/label" import { useCommitteeStore } from "@/store/useCommitteeStore" -import { States } from "@/schemas/types.gen" +import { MajorityTypes, States } from "@/schemas/types.gen" import { useSession } from "@/context/SessionContext" import { SessionRoles } from "@/schemas/types.gen" +import { + Motions, + Questions, + DebateTypes, + DelegateEvents, + type SubmitMotionEvent, + type SubmitQuestionEvent, + type DelegateQuestionPayload, + type DelegateMotionPayload } from "@/schemas/types.gen" +import { sendMessage } from "@/context/SessionContext" -const motions = [ - "Moção para Adiamento de Sessão", - "Moção para Reabertura de Sessão", - "Moção para Mudar Tipo de Debate", - "Moção para Tour de Table", - "Moção para Encerramento de Debate", - "Moção para Votação de Emenda", - "Moção para Fechamento de Lista de Discursos", - "Moção para Reabertura de Lista de Discursos", - "Moção para Divisão da Proposta", - "Moção para Introdução da Proposta de Resolução", - "Moção para Introdução de Proposta de Emenda", - "Moção para Votação por Chamada", - "Moção para contagem de Quórum", -] as const - -const points = [ - "Questão de Privilégio Pessoal", - "Questão de Ordem", - "Questão de Dúvida", -] as const - -const motionRequiredMajority: Record = { - "Moção para Adiamento de Sessão": "Maioria simples", - "Moção para Reabertura de Sessão": "Maioria simples", - "Moção para Mudar Tipo de Debate": "Maioria simples", - "Moção para Tour de Table": "Maioria simples", - "Moção para Encerramento de Debate": "Maioria qualificada", - "Moção para Votação de Emenda": "Maioria qualificada", - "Moção para Fechamento de Lista de Discursos": "Maioria simples", - "Moção para Reabertura de Lista de Discursos": "Maioria simples", - "Moção para Divisão da Proposta": "Maioria simples", - "Moção para Introdução da Proposta de Resolução": "Maioria simples", - "Moção para Introdução de Proposta de Emenda": "Maioria simples", - "Moção para Votação por Chamada": "Maioria simples", - "Moção para contagem de Quórum": "Maioria simples", +const motionRequiredMajority: Record = { + [Motions.ADIAMENTO_DE_SESSÃO]: MajorityTypes.MAIORIA_SIMPLES, + [Motions.REABERTURA_DE_SESSÃO]: MajorityTypes.MAIORIA_SIMPLES, + [Motions.MUDAR_TIPO_DE_DEBATE]: MajorityTypes.MAIORIA_SIMPLES, + [Motions.TOUR_DE_TABLE]: MajorityTypes.MAIORIA_SIMPLES, + [Motions.ENCERRAMENTO_DE_DEBATE]: MajorityTypes.MAIORIA_QUALIFICADA, + [Motions.VOTAÇÃO_DE_EMENDA]: MajorityTypes.MAIORIA_QUALIFICADA, + [Motions.FECHAMENTO_DA_LISTA_DE_DISCURSOS]: MajorityTypes.MAIORIA_SIMPLES, + [Motions.REABERTURA_DE_LISTA_DE_DISCURSOS]: MajorityTypes.MAIORIA_SIMPLES, + [Motions.DIVISÃO_DA_PROPOSTA]: MajorityTypes.MAIORIA_SIMPLES, + [Motions.INTRODUÇÃO_DA_PROPOSTA_DE_RESOLUÇÃO]: MajorityTypes.MAIORIA_SIMPLES, + [Motions.INTRODUÇÃO_DA_PROPOSTA_DE_EMENDA]: MajorityTypes.MAIORIA_SIMPLES, + [Motions.VOTAÇÃO_POR_CHAMADA]: MajorityTypes.MAIORIA_SIMPLES, + [Motions.MUDANÇA_DE_TÓPICO]: MajorityTypes.MAIORIA_SIMPLES, + [Motions.CONTAGEM_DE_QUÓRUM]: MajorityTypes.MAIORIA_SIMPLES, + [Motions[""]]: "", } type MotionKind = "moção" | "questão" -type DebateKind = "moderado" | "não moderado" | "lista de discursos" | "" function QuestionsMotionsList(type: MotionKind) { - return type === "moção" ? motions : points + return Object.values(type === "moção" ? Motions : Questions) } export default function TestButton() { @@ -95,8 +84,9 @@ export default function TestButton() { const currentState = useCommitteeStore((state) => state.current_state) const [motionKind, setMotionKind] = useState("moção") - const [selectedMotion, setSelectedMotion] = useState("") - const [debateKindChange, setDebateKind] = useState("") + const [selectedMotion, setSelectedMotion] = useState("") + const [selectedQuestion, setSelectedQuestion] = useState("") + const [debateKindChange, setDebateKind] = useState("") const [unmoderatedMinutes, setUnmoderatedMinutes] = useState("") const [speechCount, setSpeechCount] = useState("") const [minutesPerSpeech, setMinutesPerSpeech] = useState("") @@ -105,14 +95,33 @@ export default function TestButton() { const [answerText, setAnswerText] = useState("") const motionOptions = QuestionsMotionsList(motionKind) - const showDebateKindField = selectedMotion === "Moção para Mudar Tipo de Debate" - const showUnmoderatedField = showDebateKindField && debateKindChange === "não moderado" - const showModeratedFields = showDebateKindField && debateKindChange === "moderado" + const showDebateKindField = selectedMotion === Motions.MUDAR_TIPO_DE_DEBATE + const showUnmoderatedField = showDebateKindField && debateKindChange === DebateTypes.DEBATE_NÃO_MODERADO + const showModeratedFields = showDebateKindField && debateKindChange === DebateTypes.DEBATE_MODERADO const showMotionDecision = motionKind === "moção" && selectedMotion.length > 0 const selectedMotionMajority = motionRequiredMajority[selectedMotion] ?? "Maioria não definida" + const motionBody : DelegateMotionPayload = { + type: selectedMotion, + ...(unmoderatedMinutes !== "" && {total_duration_minutes: Number(unmoderatedMinutes)}), + ...(minutesPerSpeech !== "" && {per_speaker_seconds: Number(minutesPerSpeech)}), //TODO: Fix inconsitency in minutes / seconds + ...(debateKindChange !== "" && {debate_type: debateKindChange}), + ...(minutesPerSpeech !== "" && {per_speaker_seconds: Number(minutesPerSpeech)}), + //TODO: add change topic + } + + const questionBody : DelegateQuestionPayload | null = + selectedQuestion === "" ? null : + { + type: selectedQuestion, + details: questionText + } + + + const resetMotionFields = () => { setSelectedMotion("") + setSelectedQuestion("") setDebateKind("") setUnmoderatedMinutes("") setSpeechCount("") @@ -199,8 +208,9 @@ export default function TestButton() { @@ -228,15 +240,15 @@ export default function TestButton() { {showDebateKindField && ( Para qual tipo de debate? - setDebateKind(value as DebateTypes)}> - Lista de Discursos - Debate moderado - Debate não moderado + Lista de Discursos + Debate moderado + Debate não moderado @@ -305,7 +317,7 @@ export default function TestButton() { )} {showMotionDecision && !isChair && (
-
@@ -321,6 +333,7 @@ export default function TestButton() { onChange={(event) => setQuestionText(event.target.value)} />
+ { isChair && ( Digite a resposta setAnswerText(event.target.value)} /> - - )} diff --git a/frontend/src/components/session/bottom-bar-buttons/SessionButton.tsx b/frontend/src/components/session/bottom-bar-buttons/SessionButton.tsx index 448661c..8db02ea 100644 --- a/frontend/src/components/session/bottom-bar-buttons/SessionButton.tsx +++ b/frontend/src/components/session/bottom-bar-buttons/SessionButton.tsx @@ -51,13 +51,13 @@ export default function TestButton() {
{currentState === States.SETUP_ROOM && - } + } {currentState === States.ROLL_CALL && - } {currentState !== States.SETUP_ROOM && currentState !== States.ROLL_CALL && - }
diff --git a/frontend/src/components/session/bottom-bar-buttons/VoteButton.tsx b/frontend/src/components/session/bottom-bar-buttons/VoteButton.tsx index 2721088..49ca724 100644 --- a/frontend/src/components/session/bottom-bar-buttons/VoteButton.tsx +++ b/frontend/src/components/session/bottom-bar-buttons/VoteButton.tsx @@ -60,8 +60,8 @@ export default function VoteButton() {

- - + + diff --git a/frontend/src/components/session/delegation-map.tsx b/frontend/src/components/session/delegation-map.tsx index 7d1e901..c4bfbc6 100644 --- a/frontend/src/components/session/delegation-map.tsx +++ b/frontend/src/components/session/delegation-map.tsx @@ -160,10 +160,10 @@ export default function DelegationMap({ Ações sobre a Delegação - sendMessage({ type: ChairEvents.INSERT_QUEUE_EVENT, payload: { target: delegation.id } } as ChairInsertQueueEvent)}> + sendMessage({ type: ChairEvents.INSERT_QUEUE_EVENT, payload: { target: delegation.id } } satisfies ChairInsertQueueEvent)}> Colocar na Lista de Discursos - sendMessage({ type: ChairEvents.SPEAKER_EVENT, payload: { speaker_id: delegation.id } } as SpeakerEvent)}> + sendMessage({ type: ChairEvents.SPEAKER_EVENT, payload: { speaker_id: delegation.id } } satisfies SpeakerEvent)}> Dar a palavra @@ -173,13 +173,13 @@ export default function DelegationMap({ Mudar Presença - sendMessage({ type: ChairEvents.MARK_ROLL_CALL_EVENT, payload: { delegation_id: delegation.id, choice: RollCallChoice.PRESENT_AND_VOTING } } as MarkRollCallEvent)}> + sendMessage({ type: ChairEvents.MARK_ROLL_CALL_EVENT, payload: { delegation_id: delegation.id, choice: RollCallChoice.PRESENT_AND_VOTING } } satisfies MarkRollCallEvent)}> Presente Votante - sendMessage({ type: ChairEvents.MARK_ROLL_CALL_EVENT, payload: { delegation_id: delegation.id, choice: RollCallChoice.PRESENT } } as MarkRollCallEvent)}> + sendMessage({ type: ChairEvents.MARK_ROLL_CALL_EVENT, payload: { delegation_id: delegation.id, choice: RollCallChoice.PRESENT } } satisfies MarkRollCallEvent)}> Presente - sendMessage({ type: ChairEvents.MARK_ROLL_CALL_EVENT, payload: { delegation_id: delegation.id, choice: RollCallChoice.ABSENT } } as MarkRollCallEvent)}> + sendMessage({ type: ChairEvents.MARK_ROLL_CALL_EVENT, payload: { delegation_id: delegation.id, choice: RollCallChoice.ABSENT } } satisfies MarkRollCallEvent)}> Ausente diff --git a/frontend/src/components/session/manual-quorum.tsx b/frontend/src/components/session/manual-quorum.tsx index 0fa3589..5c15ff4 100644 --- a/frontend/src/components/session/manual-quorum.tsx +++ b/frontend/src/components/session/manual-quorum.tsx @@ -95,7 +95,7 @@ export default function ManualQuorum() { diff --git a/frontend/src/components/session/motions-list.tsx b/frontend/src/components/session/motions-list.tsx index b81608c..4b6f7b9 100644 --- a/frontend/src/components/session/motions-list.tsx +++ b/frontend/src/components/session/motions-list.tsx @@ -12,41 +12,22 @@ import { } from "@/components/ui/item" import { Badge } from "@/components/ui/badge" import Flags from "@/components/ui/flags" -import { useSession } from "@/context/SessionContext" -import { SessionRoles } from "@/schemas/types.gen" +import { sendMessage,useSession } from "@/context/SessionContext" +import { SessionRoles, ChairEvents, type ResolveMotionEvent} from "@/schemas/types.gen" +import { useCommitteeStore } from "@/store/useCommitteeStore" -export type Motion = { - id: string - timestamp: string - title: string - proposer: string - proposerCode: string - priority: number -} -type MotionsListProps = { - motions: Motion[] -} - - -export default function MotionsList({ motions }: MotionsListProps) { +export default function MotionsList() { const {role} = useSession() const isChair = role===SessionRoles.CHAIR - const toMinutes = (time: string): number => { - const [hours, minutes] = time.split(":").map(Number) - if (Number.isNaN(hours) || Number.isNaN(minutes)) { - return Number.MAX_SAFE_INTEGER - } - return hours * 60 + minutes - } + const motions = useCommitteeStore((state)=>state.submitted_motions)! + const delegations = useCommitteeStore((state)=>state.delegations) const sortedMotions = [...motions].sort((a, b) => { - if (b.priority !== a.priority) { - return b.priority - a.priority - } + if(b.priority !== a.priority) return b.priority! - a.priority! - return toMinutes(a.timestamp) - toMinutes(b.timestamp) + return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); }) const queueCount = sortedMotions.length @@ -60,21 +41,26 @@ export default function MotionsList({ motions }: MotionsListProps) {
-

{motion.timestamp}

+

{new Date(motion.timestamp).toLocaleTimeString('pt-BR', { + hour: '2-digit', + minute: '2-digit' +})}

- {motion.title} + {motion.type} - - {motion.proposer} + + {delegations[motion.delegate_id!].name} {isChair && (
- - + +
)} diff --git a/frontend/src/components/session/speaker-list.tsx b/frontend/src/components/session/speaker-list.tsx index 33f4eac..b07c3b5 100644 --- a/frontend/src/components/session/speaker-list.tsx +++ b/frontend/src/components/session/speaker-list.tsx @@ -16,20 +16,21 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import { sendMessage } from "@/context/SessionContext" -import { type SpeakerEvent, ChairEvents } from "@/schemas/types.gen" +import { type JoinQueueEvent, type SpeakerEvent, ChairEvents, DelegateEvents } from "@/schemas/types.gen" import { useSession } from "@/context/SessionContext" import { SessionRoles } from "@/schemas/types.gen" -const isAlredyInQueue = true // Replace with actual logic to determine if the user is already in the queue //TODO determine if queue is open, if not obscure the button and show a message that the queue is closed export default function SpeakerList() { - const {role} = useSession() + const {role, representation_id} = useSession() const isChair = role===SessionRoles.CHAIR const gslQueue = useCommitteeStore((state) => state.gsl_queue ?? []) const currentSpeaker = useCommitteeStore((state) => state.current_speaker) const delegationsById = useCommitteeStore((state) => state.delegations) + + const alreadyInQueue = representation_id ? gslQueue.includes(representation_id) : false const queuedDelegations = gslQueue.flatMap((delegationId) => { const delegation = delegationsById[String(delegationId)] return delegation ? [delegation] : [] @@ -79,7 +80,8 @@ export default function SpeakerList() { @@ -92,7 +94,7 @@ export default function SpeakerList() { variant="outline" className="flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap" disabled={waitingCount === 0} - onClick={() => sendMessage({ type: ChairEvents.SPEAKER_EVENT, payload: {} } as SpeakerEvent)} + onClick={() => sendMessage({ type: ChairEvents.SPEAKER_EVENT, payload: {} } satisfies SpeakerEvent)} > Proximo Proximo Orador diff --git a/frontend/src/components/session/timer.tsx b/frontend/src/components/session/timer.tsx index a09c441..6693a67 100644 --- a/frontend/src/components/session/timer.tsx +++ b/frontend/src/components/session/timer.tsx @@ -73,10 +73,10 @@ const [Seconds, setRemainingSeconds] = useState(0); {isChair && (
- - +
)} diff --git a/frontend/src/components/session/voting-popup.tsx b/frontend/src/components/session/voting-popup.tsx index a8038b2..814b633 100644 --- a/frontend/src/components/session/voting-popup.tsx +++ b/frontend/src/components/session/voting-popup.tsx @@ -16,8 +16,8 @@ import { } from "@/components/ui/field" import { useState } from "react" import { useCommitteeStore } from "@/store/useCommitteeStore" -import { sendMessage } from "@/context/SessionContext" -import { DelegateEvents, VotingChoice, type CastVoteEvent } from "@/schemas/types.gen" +import { sendMessage, useSession } from "@/context/SessionContext" +import { DelegateEvents, SessionRoles, VotingChoice, type CastVoteEvent } from "@/schemas/types.gen" type VoteType = "rollCall1" | "rollCall2" | "procedural" | "informal" @@ -27,6 +27,9 @@ const voteType : VoteType = "procedural" export default function VotingPopup() { + const {role, representation_id} = useSession() + const isChair = role == SessionRoles.CHAIR + //TODO: Implement rollcall voting const isRollCall1 = voteType === "rollCall1" const isRollCall2 = voteType === "rollCall2" @@ -35,11 +38,12 @@ export default function VotingPopup() { //TEMPORARY^^^^ const voting = useCommitteeStore((state) => state.voting ?? null) + const voted = voting && representation_id ? representation_id in voting.voting_registry! : true const voteTitle = voting?.title - const [voted, setVoted] = useState(false) + const [clicked, setClicked] = useState(false) return ( - + Votação @@ -49,7 +53,7 @@ export default function VotingPopup() {
{isRollCall1 ? "Primeira rodada: Sua delegação pode votar com ou sem direitos. Caso deseje pular, podera votar apos todos, porem sem a possibilidade de pedir direitos" - : "Segunda rodada: apenas as delegações que pularam na primeira rodada podem votar, sem direitos."} + : "Segunda rodada: apenas satisfies delegações que pularam na primeira rodada podem votar, sem direitos."}
)} @@ -77,11 +81,11 @@ export default function VotingPopup() {
)} -
diff --git a/frontend/src/schemas/types.gen.ts b/frontend/src/schemas/types.gen.ts index 6723ddf..3ea3f68 100644 --- a/frontend/src/schemas/types.gen.ts +++ b/frontend/src/schemas/types.gen.ts @@ -271,9 +271,9 @@ export type DebateContext = { * DebateTypes */ export const DebateTypes = { - SPEAKERS_LIST: 'Speakers List', - MODERATED_DEBATE: 'Moderated Debate', - UNMODERATED_DEBATE: 'Unmoderated Debate' + LISTA_DE_DISCURSOS: 'Lista de Discursos', + DEBATE_MODERADO: 'Debate Moderado', + DEBATE_NÃO_MODERADO: 'Debate não Moderado' } as const; /** @@ -304,10 +304,6 @@ export type DelegateEvents = typeof DelegateEvents[keyof typeof DelegateEvents]; */ export type DelegateMotionPayload = { type: Motions; - /** - * Delegate - */ - delegate: number; debate_type?: DebateTypes | null; /** * Total Duration Minutes @@ -332,10 +328,6 @@ export type DelegateMotionPayload = { */ export type DelegateQuestionPayload = { type: Questions; - /** - * Delegate - */ - delegate: number; /** * Details */ @@ -557,6 +549,10 @@ export type MotionContext = { * Delegate Id */ delegate_id?: number | null; + /** + * Timestamp + */ + timestamp: string; debate_type?: DebateTypes | null; /** * Total Duration Minutes @@ -581,19 +577,19 @@ export type MotionContext = { */ export const Motions = { MUDAR_TIPO_DE_DEBATE: 'Mudar Tipo de Debate', - ADIAAMENTO_DE_SESSÃO: 'Adiaamento de Sessão', - REABRIR_SESSÃO: 'Reabrir Sessão', + ADIAMENTO_DE_SESSÃO: 'Adiamento de Sessão', + REABERTURA_DE_SESSÃO: 'Reabertura de Sessão', TOUR_DE_TABLE: 'Tour de Table', ENCERRAMENTO_DE_DEBATE: 'Encerramento de Debate', VOTAÇÃO_DE_EMENDA: 'Votação de Emenda', VOTAÇÃO_POR_CHAMADA: 'Votação por Chamada', FECHAMENTO_DA_LISTA_DE_DISCURSOS: 'Fechamento da Lista de Discursos', - REABRIR_A_LISTA_DE_DISCURSOS: 'Reabrir a Lista de Discursos', - DIVISÃO_DE_PROPOSTA: 'Divisão de Proposta', - INTRODUÇÃO_DE_PROPOSTA_DE_RESOLUÇÃO: 'Introdução de Proposta de Resolução', - INTRODUÇÃO_DE_PROPOSTA_DE_EMENDA: 'Introdução de Proposta de Emenda', + REABERTURA_DE_LISTA_DE_DISCURSOS: 'Reabertura de Lista de Discursos', + DIVISÃO_DA_PROPOSTA: 'Divisão da Proposta', + INTRODUÇÃO_DA_PROPOSTA_DE_RESOLUÇÃO: 'Introdução da Proposta de Resolução', + INTRODUÇÃO_DA_PROPOSTA_DE_EMENDA: 'Introdução da Proposta de Emenda', MUDANÇA_DE_TÓPICO: 'Mudança de Tópico', - QUÓRUM: 'Quórum', + CONTAGEM_DE_QUÓRUM: 'Contagem de Quórum', '': '' } as const; @@ -651,9 +647,9 @@ export type QuestionContext = { * Questions */ export const Questions = { - ORDER: 'Order', - QUESTION: 'Question', - PERSONAL_PRIVILEGE: 'Personal Privilege' + ORDEM: 'Ordem', + QUESTÃO: 'Questão', + PRIVILÉGIO_PESSOAL: 'Privilégio Pessoal' } as const; /** @@ -971,7 +967,10 @@ export type ValidationError = { export const VotingChoice = { FAVOUR: 'Favour', AGAINST: 'Against', - ABSTAIN: 'Abstain' + ABSTAIN: 'Abstain', + YES_WITH_RIGHTS: 'Yes With Rights', + NO_WITH_RIGHTS: 'No With Rights', + PASS: 'Pass' } as const; /** @@ -984,7 +983,6 @@ export type VotingChoice = typeof VotingChoice[keyof typeof VotingChoice]; */ export type VotingContext = { target_type: VotingType; - motion_in_vote?: MotionContext | null; /** * Title */ @@ -996,11 +994,12 @@ export type VotingContext = { voting_registry?: { [key: string]: VotingChoice; }; - majority: MajorityTypes; + majority?: MajorityTypes | null; + motion_in_vote?: MotionContext | null; /** - * Veto Power + * Allow Veto Power */ - veto_power: boolean; + allow_veto_power?: boolean; }; /** diff --git a/sonar-project.properties b/sonar-project.properties index 02e03c5..d633df7 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -12,6 +12,6 @@ sonar.python.version=3.11 sonar.sources=backend/app,frontend/src sonar.tests=backend/app/tests -sonar.exclusions=backend/app/tests/** +sonar.exclusions=backend/app/tests/**, frontend/src/components/ui/** sonar.coverage.exclusions=frontend/src/**