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/app/session/engine.py b/backend/app/session/engine.py index e1705cc..02fe754 100644 --- a/backend/app/session/engine.py +++ b/backend/app/session/engine.py @@ -98,19 +98,10 @@ 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 ) -> 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 - ) + """Should validate motion payload before submitting""" # can also raise error if there are missing fields if ( @@ -120,29 +111,25 @@ def validate_motion_payload( raise InvalidProceduralMove("Cannot submit motion without speaking time") -def validate_question_payload( - payload: schemas.DelegateQuestionPayload, state: SessionLiveState -) -> None: ... - - def count_present_delegations(state: SessionLiveState) -> int: """Count total present delegations. A delegation is considered present (even if AFK) if it's Roll Call Choice is Present / Present and Voting""" - if state.voting_choice is None: + 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 +137,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: @@ -170,12 +164,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 @@ -251,10 +243,12 @@ 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""" + require_delegate(actor) + # Extract payload (as DelegateMotionSchema) payload = event.payload current_state = state.current_state @@ -269,16 +263,15 @@ def handle_submit_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") - validate_motion_payload(payload, state) 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, @@ -292,15 +285,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, @@ -382,8 +373,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 +500,113 @@ 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_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 + + 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={}, 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 handle_close_procedural_voting( state: SessionLiveState, event: schemas.CloseProceduralVotingEvent, @@ -534,104 +631,8 @@ def handle_close_procedural_voting( 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 - - 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") - - # additional case: if we went from GSL to something, save gsl structures - state.current_state = next_state + 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,6 +642,26 @@ def handle_close_procedural_voting( return state +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 + + # handles setting state into VOTING_EXECUTION or rejecting the motion def handle_resolve_motion( state: SessionLiveState, event: schemas.ResolveMotionEvent, actor: SessionActor @@ -657,12 +678,19 @@ def handle_resolve_motion( if motion is None: raise InvalidProceduralMove("Motion not found") + majority_type = ( + enums.MajorityTypes.SIMPLE + if needs_simple_majority_type(motion.type) + else enums.MajorityTypes.QUALIFIED + ) + if payload.action: 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 @@ -672,6 +700,47 @@ def handle_resolve_motion( 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=payload, state=state) + + # 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, + 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 + + def handle_set_agenda( state: SessionLiveState, event: schemas.SetAgendaEvent, actor: SessionActor ) -> SessionLiveState: ... @@ -718,24 +787,81 @@ 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 - ): + 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) + 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") + + if state.current_state in {States.OPEN_GSL, States.CLOSED_GSL}: + if representation_id in state.gsl_queue: + state.gsl_queue.remove(representation_id) + seconds = event.payload.seconds or state.gsl_default_time_seconds + elif state.current_state == States.MODERATED_CAUCUS: + if state.debate is None: + raise InvalidProceduralMove("No active moderated caucus") + seconds = event.payload.seconds or state.debate.per_speaker_seconds or 60 + elif state.current_state == States.TOUR_DE_TABLE: + if representation_id in state.caucus_list: + state.caucus_list.remove(representation_id) + seconds = event.payload.seconds or state.gsl_default_time_seconds + elif state.current_state == States.UNMODERATED_CAUCUS: + raise InvalidProceduralMove("Cannot grant floor during unmoderated caucus") else: - state.current_speaker = event.payload.speaker_id + raise InvalidProceduralMove("Cannot grant floor right now") - 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 + state.current_speaker = representation_id + reset_timer(state, seconds) return state @@ -783,9 +909,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 +921,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 +940,21 @@ 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, } diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py index aaa0116..fd51869 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,43 @@ 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" # --- 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 +116,19 @@ 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" diff --git a/backend/app/session/models.py b/backend/app/session/models.py index e539e16..0e86ab4 100644 --- a/backend/app/session/models.py +++ b/backend/app/session/models.py @@ -40,6 +40,7 @@ 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 @@ -59,19 +60,28 @@ 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 + + allow_veto_power: bool = False + + def is_choice_allowed(self, choice: enums.VotingChoice) -> bool: + if self.target_type == enums.VotingType.PROCEDURAL: + return choice in (enums.VotingChoice.FAVOUR, enums.VotingChoice.AGAINST) + + 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 +89,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 +121,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,8 +143,6 @@ 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 # Additional config diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py index 239a93c..79bc205 100644 --- a/backend/app/session/schemas.py +++ b/backend/app/session/schemas.py @@ -14,23 +14,24 @@ 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 - details: str | None = None +# --- Delegate Payloads --- +class DelegateMotionPayload(MotionPayload): + pass + + class DelegateQuestionPayload(BaseModel): type: enums.Questions - delegate: int details: str | None = None @@ -77,6 +78,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 +105,9 @@ 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 GrantFloorPayload(BaseModel): + representation_id: int + seconds: int | None = Field(default=None, ge=1) class ChairSetAgendaPayload(BaseModel): @@ -110,8 +118,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 +150,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 +185,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 +220,11 @@ class CloseProceduralVotingEvent(BaseModel): 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 +240,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,6 +263,7 @@ class DeleteAgendaItemEvent(BaseModel): | AnswerRollCallEvent | JoinQueueEvent | LeaveQueueEvent + | LogMotionEvent | OpenSessionEvent | CloseSessionEvent | IncreaseTimerEvent @@ -247,8 +271,11 @@ class DeleteAgendaItemEvent(BaseModel): | OpenInformalVotingEvent | CloseProceduralVotingEvent | CloseInformalVotingEvent + | FinishCaucusEvent | ResolveMotionEvent - | SpeakerEvent + | NextSpeakerEvent + | AddGslSpeakerEvent + | GrantFloorEvent | SetAgendaEvent | SetAgendaItemEvent | MarkAgendaItemEvent @@ -256,7 +283,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..18afbfd 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,28 @@ 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, + session_state: md.SessionLiveState, + log_motion_event: sch.LogMotionEvent, + chair_actor: md.SessionActor, +) -> None: + state = engine.dispatch(session_state, log_motion_event, chair_actor) + assert state.current_state == enums.States.VOTING_EXECUTION + assert state.voting is not None and 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 +420,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 +432,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 +481,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 +651,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 +729,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 +757,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 +793,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 +882,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) -def test_delegate_cannot_choose_speaker( + assert state.current_speaker == 1 + assert state.caucus_list == [2] + assert state.timer_remaining_seconds == state.gsl_default_time_seconds + + +def test_next_speaker_clears_current_speaker_when_gsl_empty( + engine: eng.SessionEngine, + open_gsl_state: md.SessionLiveState, + 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, - choose_speaker_event: sch.SpeakerEvent, + 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 +1109,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, diff --git a/docs/realtime/events-and-payloads.md b/docs/realtime/events-and-payloads.md index b339380..69d9924 100644 --- a/docs/realtime/events-and-payloads.md +++ b/docs/realtime/events-and-payloads.md @@ -69,6 +69,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 +101,30 @@ 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. +- `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. 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. It is not valid during unmoderated caucus. - `MarkRollCallEvent` - Payload: `delegation_id` and `choice` (`Present`, `Present and Voting`, or @@ -118,10 +141,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/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/**