feat: Add event details in roster builder

master
John Montagu, the 4th Earl of Sandvich 2024-11-29 00:06:34 -08:00
parent 3d5d9574e3
commit 6f49053dac
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
13 changed files with 269 additions and 138 deletions

View File

@ -16,7 +16,8 @@ export type { CreateEventJson } from './models/CreateEventJson';
export type { CreateTeamJson } from './models/CreateTeamJson'; export type { CreateTeamJson } from './models/CreateTeamJson';
export type { EditMemberRolesJson } from './models/EditMemberRolesJson'; export type { EditMemberRolesJson } from './models/EditMemberRolesJson';
export type { EventSchema } from './models/EventSchema'; export type { EventSchema } from './models/EventSchema';
export type { EventSchemaList } from './models/EventSchemaList'; export type { EventWithPlayerSchema } from './models/EventWithPlayerSchema';
export type { EventWithPlayerSchemaList } from './models/EventWithPlayerSchemaList';
export type { GetEventPlayersResponse } from './models/GetEventPlayersResponse'; export type { GetEventPlayersResponse } from './models/GetEventPlayersResponse';
export type { PlayerEventRolesSchema } from './models/PlayerEventRolesSchema'; export type { PlayerEventRolesSchema } from './models/PlayerEventRolesSchema';
export type { PlayerRoleSchema } from './models/PlayerRoleSchema'; export type { PlayerRoleSchema } from './models/PlayerRoleSchema';

View File

@ -1,6 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { EventSchema } from './EventSchema';
export type EventSchemaList = Array<EventSchema>;

View File

@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { EventSchema } from './EventSchema';
import type { PlayerEventRolesSchema } from './PlayerEventRolesSchema';
export type EventWithPlayerSchema = {
event: EventSchema;
playerEvent: (PlayerEventRolesSchema | null);
};

View File

@ -0,0 +1,6 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { EventWithPlayerSchema } from './EventWithPlayerSchema';
export type EventWithPlayerSchemaList = Array<EventWithPlayerSchema>;

View File

@ -7,7 +7,8 @@ import type { CreateEventJson } from '../models/CreateEventJson';
import type { CreateTeamJson } from '../models/CreateTeamJson'; import type { CreateTeamJson } from '../models/CreateTeamJson';
import type { EditMemberRolesJson } from '../models/EditMemberRolesJson'; import type { EditMemberRolesJson } from '../models/EditMemberRolesJson';
import type { EventSchema } from '../models/EventSchema'; import type { EventSchema } from '../models/EventSchema';
import type { EventSchemaList } from '../models/EventSchemaList'; import type { EventWithPlayerSchema } from '../models/EventWithPlayerSchema';
import type { EventWithPlayerSchemaList } from '../models/EventWithPlayerSchemaList';
import type { GetEventPlayersResponse } from '../models/GetEventPlayersResponse'; import type { GetEventPlayersResponse } from '../models/GetEventPlayersResponse';
import type { PlayerSchema } from '../models/PlayerSchema'; import type { PlayerSchema } from '../models/PlayerSchema';
import type { PutScheduleForm } from '../models/PutScheduleForm'; import type { PutScheduleForm } from '../models/PutScheduleForm';
@ -50,12 +51,12 @@ export class DefaultService {
/** /**
* get_team_events <GET> * get_team_events <GET>
* @param teamId * @param teamId
* @returns EventSchemaList OK * @returns EventWithPlayerSchemaList OK
* @throws ApiError * @throws ApiError
*/ */
public getTeamEvents( public getTeamEvents(
teamId: number, teamId: number,
): CancelablePromise<EventSchemaList> { ): CancelablePromise<EventWithPlayerSchemaList> {
return this.httpRequest.request({ return this.httpRequest.request({
method: 'GET', method: 'GET',
url: '/api/events/team/id/{team_id}', url: '/api/events/team/id/{team_id}',
@ -128,6 +129,46 @@ export class DefaultService {
}, },
}); });
} }
/**
* unattend_event <DELETE>
* @param eventId
* @returns EventWithPlayerSchema OK
* @throws ApiError
*/
public unattendEvent(
eventId: number,
): CancelablePromise<EventWithPlayerSchema> {
return this.httpRequest.request({
method: 'DELETE',
url: '/api/events/{event_id}/attendance',
path: {
'event_id': eventId,
},
errors: {
422: `Unprocessable Content`,
},
});
}
/**
* attend_event <PUT>
* @param eventId
* @returns EventWithPlayerSchema OK
* @throws ApiError
*/
public attendEvent(
eventId: number,
): CancelablePromise<EventWithPlayerSchema> {
return this.httpRequest.request({
method: 'PUT',
url: '/api/events/{event_id}/attendance',
path: {
'event_id': eventId,
},
errors: {
422: `Unprocessable Content`,
},
});
}
/** /**
* get_event_players <GET> * get_event_players <GET>
* @param eventId * @param eventId
@ -165,52 +206,6 @@ export class DefaultService {
}, },
}); });
} }
/**
* unattend_event <DELETE>
* @param eventId
* @param teamId
* @returns void
* @throws ApiError
*/
public deleteApiEventsEventIdTeamIdTeamIdAttendance(
eventId: number,
teamId: number,
): CancelablePromise<void> {
return this.httpRequest.request({
method: 'DELETE',
url: '/api/events/{event_id}/team/id/{team_id}/attendance',
path: {
'event_id': eventId,
'team_id': teamId,
},
errors: {
422: `Unprocessable Content`,
},
});
}
/**
* attend_event <PUT>
* @param eventId
* @param teamId
* @returns void
* @throws ApiError
*/
public putApiEventsEventIdTeamIdTeamIdAttendance(
eventId: number,
teamId: number,
): CancelablePromise<void> {
return this.httpRequest.request({
method: 'PUT',
url: '/api/events/{event_id}/team/id/{team_id}/attendance',
path: {
'event_id': eventId,
'team_id': teamId,
},
errors: {
422: `Unprocessable Content`,
},
});
}
/** /**
* logout <DELETE> * logout <DELETE>
* @returns void * @returns void

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import { useRosterStore } from "@/stores/roster";
import moment from "moment";
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
const rosterStore = useRosterStore();
function saveRoster() {
rosterStore.saveRoster(Number(route.params.teamId))
.then(() => {
router.push({
name: "team-details",
params: {
id: route.params.teamId
}
});
});
}
</script>
<template>
<div class="event-scheduler-container">
<h1 class="roster-title">
Roster for Snus Brotherhood
</h1>
<div v-if="rosterStore.startTime">
<span class="aside date">
{{ moment.unix(rosterStore.startTime).format("LL LT") }}
</span>
</div>
<div class="form-group margin">
<h3>Event Name</h3>
<input v-model="rosterStore.title" />
</div>
<div class="form-group margin">
<h3>Description (optional)</h3>
<input v-model="rosterStore.description" />
</div>
<div class="form-group margin">
<div class="action-buttons">
<button class="accent" @click="saveRoster">Save roster</button>
</div>
</div>
</div>
</template>
<style scoped>
em.aside.date {
font-size: 11pt;
}
</style>

View File

@ -0,0 +1,14 @@
import type { PlayerRoleSchema } from "@/client";
import { ref } from "vue";
export function useEventForm() {
const title = ref("");
const description = ref("");
const players = ref<PlayerRoleSchema[]>([]);
return {
title,
description,
players,
}
}

View File

@ -6,6 +6,7 @@ import { type EventSchema, type CreateEventJson, type PlayerRoleSchema } from "@
import { useTeamDetails } from "@/composables/team-details"; import { useTeamDetails } from "@/composables/team-details";
import moment from "moment"; import moment from "moment";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useEventForm } from "@/composables/event-form";
export const useRosterStore = defineStore("roster", () => { export const useRosterStore = defineStore("roster", () => {
const clientStore = useClientStore(); const clientStore = useClientStore();
@ -169,6 +170,8 @@ export const useRosterStore = defineStore("roster", () => {
const startTime = ref<number>(); const startTime = ref<number>();
const { title, description } = useEventForm();
function saveRoster(teamId: number) { function saveRoster(teamId: number) {
if (startTime.value == undefined) { if (startTime.value == undefined) {
throw new Error("No start time set"); throw new Error("No start time set");
@ -176,8 +179,8 @@ export const useRosterStore = defineStore("roster", () => {
if (!currentEvent.value) { if (!currentEvent.value) {
const body: CreateEventJson = { const body: CreateEventJson = {
name: "Test", name: title.value,
description: "test description", description: description.value,
startTime: startTime.value.toString(), startTime: startTime.value.toString(),
playerRoles: Object.values(selectedPlayers).map((player) => ({ playerRoles: Object.values(selectedPlayers).map((player) => ({
player: { player: {
@ -191,12 +194,10 @@ export const useRosterStore = defineStore("roster", () => {
})), })),
}; };
clientStore.client.default.createEvent(teamId, body) return clientStore.client.default.createEvent(teamId, body);
.then(() => {
});
} else { } else {
// TODO: update event // TODO: update event
throw "Not implemented";
} }
} }
@ -217,5 +218,7 @@ export const useRosterStore = defineStore("roster", () => {
fetchPlayersFromEvent, fetchPlayersFromEvent,
startTime, startTime,
saveRoster, saveRoster,
title,
description,
} }
}); });

View File

@ -1,45 +1,62 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { useClientStore } from "../client"; import { useClientStore } from "../client";
import type { EventSchema, EventSchemaList } from "@/client"; import type { EventWithPlayerSchema } from "@/client";
import { useEventsStore } from "../events"; import { useEventsStore } from "../events";
import { computed } from "vue"; import { computed, reactive } from "vue";
export const useTeamsEventsStore = defineStore("teamsEvents", () => { export const useTeamsEventsStore = defineStore("teamsEvents", () => {
const clientStore = useClientStore(); const clientStore = useClientStore();
const client = clientStore.client; const client = clientStore.client;
const eventsStore = useEventsStore(); //const eventsStore = useEventsStore();
const teamEvents = computed(() => { const teamEvents = reactive<{ [teamId: number]: EventWithPlayerSchema[] }>({ });
console.log("Recomputing teamEvents"); //const teamEvents = computed(() => {
// console.log("Recomputing teamEvents");
// map events to objects with teamId as key, and array of events as value // // map events to objects with teamId as key, and array of events as value
return eventsStore.events // return eventsStore.events
.reduce((acc, event) => { // .reduce((acc, event) => {
if (!acc[event.teamId]) { // if (!acc[event.teamId]) {
acc[event.teamId] = []; // acc[event.teamId] = [];
} // }
acc[event.teamId].push(event); // acc[event.teamId].push(event);
return acc; // return acc;
}, { } as { [teamId: number]: EventSchema[] }); // }, { } as { [teamId: number]: EventSchema[] });
}); //});
function fetchTeamEvents(teamId: number) { function fetchTeamEvents(teamId: number) {
return clientStore.call( return clientStore.call(
fetchTeamEvents.name, fetchTeamEvents.name,
() => client.default.getTeamEvents(teamId), () => client.default.getTeamEvents(teamId),
(result: EventSchemaList) => { (result: EventWithPlayerSchema[]) => {
result.forEach((event) => { teamEvents[teamId] = result;
// insert into event store
//eventsStore.events[event.id] = event;
eventsStore.events.push(event);
});
return result; return result;
} }
); );
} }
function attendEvent(eventId: number) {
client.default.attendEvent(eventId)
.then((response) => {
let index = teamEvents[response.event.teamId]
.findIndex((event) => event.event.id == response.event.id);
teamEvents[response.event.teamId][index] = response;
});
}
function unattendEvent(eventId: number) {
client.default.unattendEvent(eventId)
.then((response) => {
let index = teamEvents[response.event.teamId]
.findIndex((event) => event.event.id == response.event.id);
teamEvents[response.event.teamId][index].playerEvent = null;
});
}
return { return {
teamEvents, teamEvents,
fetchTeamEvents, fetchTeamEvents,
attendEvent,
unattendEvent,
} }
}); });

View File

@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import PlayerCard from "../components/PlayerCard.vue"; import PlayerCard from "../components/PlayerCard.vue";
import RoleSlot from "../components/RoleSlot.vue";
import PlayerTeamRole from "../player.ts";
import { computed, reactive, onMounted } from "vue"; import { computed, reactive, onMounted } from "vue";
import { useRosterStore } from "../stores/roster"; import { useRosterStore } from "../stores/roster";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import moment from "moment"; import moment from "moment";
import { useEventsStore } from "@/stores/events"; import { useEventsStore } from "@/stores/events";
import EventSchedulerForm from "@/components/EventSchedulerForm.vue";
const rosterStore = useRosterStore(); const rosterStore = useRosterStore();
const eventsStore = useEventsStore(); const eventsStore = useEventsStore();
@ -23,10 +22,6 @@ const hasAlternates = computed(() => {
const eventId = computed<number | undefined>(() => Number(route.params.eventId)); const eventId = computed<number | undefined>(() => Number(route.params.eventId));
function saveRoster() {
rosterStore.saveRoster(Number(route.params.teamId));
}
onMounted(async () => { onMounted(async () => {
if (eventId.value) { if (eventId.value) {
const event = await eventsStore.fetchEvent(eventId.value); const event = await eventsStore.fetchEvent(eventId.value);
@ -42,26 +37,19 @@ onMounted(async () => {
<template> <template>
<main> <main>
<div class="top"> <div class="top">
<h1 class="roster-title"> <a>
Roster for Snus Brotherhood <i class="bi bi-arrow-left" />
<em class="aside date" v-if="rosterStore.startTime"> Back
@ </a>
{{ moment.unix(rosterStore.startTime).format("L LT") }}
</em>
</h1>
<div class="button-group">
<button>Cancel</button>
<button class="accent" @click="saveRoster">Save Roster</button>
</div>
</div> </div>
<div class="columns"> <div class="columns">
<div class="column"> <div class="form-group margin column">
<PlayerCard v-for="role in rosterStore.neededRoles" <PlayerCard v-for="role in rosterStore.neededRoles"
:player="rosterStore.selectedPlayers[role]" :player="rosterStore.selectedPlayers[role]"
:role-title="role" :role-title="role"
is-roster /> is-roster />
</div> </div>
<div class="column"> <div class="form-group margin column" v-if="rosterStore.selectedRole">
<PlayerCard v-for="player in rosterStore.mainRoles" <PlayerCard v-for="player in rosterStore.mainRoles"
:player="player" :player="player"
:role-title="player.role" /> :role-title="player.role" />
@ -75,6 +63,15 @@ onMounted(async () => {
<PlayerCard v-if="rosterStore.selectedRole" <PlayerCard v-if="rosterStore.selectedRole"
is-ringer is-ringer
:role-title="rosterStore.selectedRole" /> :role-title="rosterStore.selectedRole" />
<div class="action-buttons">
<button class="accent">
<i class="bi bi-check" />
Done
</button>
</div>
</div>
<div class="column" v-else>
<EventSchedulerForm />
</div> </div>
</div> </div>
</main> </main>
@ -120,9 +117,4 @@ onMounted(async () => {
display: flex; display: flex;
gap: 0.5em; gap: 0.5em;
} }
em.aside.date {
font-size: 14px;
vertical-align: middle;
}
</style> </style>

View File

@ -7,12 +7,14 @@
from datetime import datetime from datetime import datetime
from typing import Optional
from flask import Blueprint, abort, make_response from flask import Blueprint, abort, make_response
from spectree import Response from spectree import Response
from sqlalchemy import Row
from sqlalchemy.sql import tuple_ from sqlalchemy.sql import tuple_
from models.player import Player from models.player import Player
from models.player_event import PlayerEvent, PlayerEventRolesSchema from models.player_event import EventWithPlayerSchema, PlayerEvent, PlayerEventRolesSchema
from models.player_team_availability import PlayerTeamAvailability from models.player_team_availability import PlayerTeamAvailability
from models.player_team_role import PlayerRoleSchema, PlayerTeamRole from models.player_team_role import PlayerRoleSchema, PlayerTeamRole
from models.team import Team from models.team import Team
@ -43,23 +45,30 @@ def get_event(event_id: int):
@api_events.get("/team/id/<int:team_id>") @api_events.get("/team/id/<int:team_id>")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=list[EventSchema], HTTP_200=list[EventWithPlayerSchema],
), ),
operation_id="get_team_events", operation_id="get_team_events",
) )
def get_team_events(team_id: int): @requires_authentication
events = db.session.query( @requires_team_membership()
Event def get_team_events(player_team: PlayerTeam, team_id: int, **_):
).filter( rows = db.session.query(
Event, PlayerEvent
).outerjoin(
PlayerEvent,
(PlayerEvent.event_id == Event.id) & (PlayerEvent.player_id == player_team.player_id)
).where(
Event.team_id == team_id Event.team_id == team_id
).order_by( ).order_by(
Event.start_time Event.start_time
).all() ).all()
def map_to_schema(event: Event): def map_to_schema(row: Row[tuple[Event, PlayerEvent]]):
return EventSchema.from_model(event).dict(by_alias=True) return EventWithPlayerSchema.from_event_player_event(
*row.tuple()
).dict(by_alias=True)
return list(map(map_to_schema, events)) return list(map(map_to_schema, rows))
@api_events.get("/user/id/<int:user_id>") @api_events.get("/user/id/<int:user_id>")
def get_user_events(user_id: int): def get_user_events(user_id: int):
@ -129,28 +138,33 @@ def create_event(player_team: PlayerTeam, team_id: int, json: CreateEventJson, *
@api_events.put("/<int:event_id>/attendance") @api_events.put("/<int:event_id>/attendance")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_204=None, HTTP_200=EventWithPlayerSchema,
) ),
operation_id="attend_event",
) )
@requires_authentication @requires_authentication
@requires_team_membership() def attend_event(player: Player, event_id: int, **_):
def attend_event(player_team: PlayerTeam, event_id: int, **_): event = db.session.query(Event).where(Event.id == event_id).one_or_none()
if not event:
abort(404)
assert_team_membership(player, event.team)
player_event = db.session.query( player_event = db.session.query(
PlayerEvent PlayerEvent
).where( ).where(
PlayerEvent.event_id == event_id PlayerEvent.event_id == event_id
).where( ).where(
PlayerEvent.player_id == player_team.player_id PlayerEvent.player_id == player.steam_id
).join( ).join(
Event Event
).where(
Event.team_id == player_team.team_id
).one_or_none() ).one_or_none()
if not player_event: if not player_event:
player_event = PlayerEvent() player_event = PlayerEvent()
player_event.event_id = event_id player_event.event_id = event_id
player_event.player_id = player_team.player_id player_event.player_id = player.steam_id
db.session.add(player_event) db.session.add(player_event)
player_event.has_confirmed = True player_event.has_confirmed = True
@ -159,27 +173,28 @@ def attend_event(player_team: PlayerTeam, event_id: int, **_):
player_event.event.update_discord_message() player_event.event.update_discord_message()
return make_response({ }, 204) return EventWithPlayerSchema.from_event_player_event(
player_event.event,
player_event,
).dict(by_alias=True)
@api_events.delete("/<int:event_id>/attendance") @api_events.delete("/<int:event_id>/attendance")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_204=None, HTTP_200=EventWithPlayerSchema,
) ),
operation_id="unattend_event",
) )
@requires_authentication @requires_authentication
@requires_team_membership() def unattend_event(player: Player, event_id: int, **_):
def unattend_event(player_team: PlayerTeam, event_id: int, **_):
result = db.session.query( result = db.session.query(
PlayerEvent, Event PlayerEvent, Event
).where( ).where(
PlayerEvent.event_id == event_id PlayerEvent.player_id == player.steam_id
).where(
PlayerEvent.player_id == player_team.player_id
).join( ).join(
Event Event
).where( ).where(
Event.team_id == player_team.team_id Event.id == event_id
).one_or_none() ).one_or_none()
if not result: if not result:
@ -192,7 +207,10 @@ def unattend_event(player_team: PlayerTeam, event_id: int, **_):
event.update_discord_message() event.update_discord_message()
return make_response({ }, 204) return EventWithPlayerSchema.from_event_player_event(
event,
None,
).dict(by_alias=True)
class GetEventPlayersResponse(BaseModel): class GetEventPlayersResponse(BaseModel):
players: list[PlayerEventRolesSchema] players: list[PlayerEventRolesSchema]

View File

@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
import threading
from sqlalchemy.orm import mapped_column, relationship from sqlalchemy.orm import mapped_column, relationship
from sqlalchemy.orm.attributes import Mapped from sqlalchemy.orm.attributes import Mapped
from sqlalchemy.orm.properties import ForeignKey from sqlalchemy.orm.properties import ForeignKey
@ -63,7 +64,7 @@ class Event(app_db.BaseModel):
f"<t:{start_timestamp}:f>", f"<t:{start_timestamp}:f>",
"\n".join(players_info), "\n".join(players_info),
"", "",
"[Confirm availability here]" + "[Confirm attendance here]" +
f"(https://availabili.tf/team/id/{self.team.id}/events/{self.id})", f"(https://availabili.tf/team/id/{self.team.id}/events/{self.id})",
]) ])
@ -81,16 +82,23 @@ class Event(app_db.BaseModel):
return DiscordWebhook( return DiscordWebhook(
integration.webhook_url, integration.webhook_url,
id=str(self.discord_message_id), id=str(self.discord_message_id),
username=integration.webhook_bot_name,
avatar_url=integration.webhook_bot_profile_picture,
) )
else: else:
return DiscordWebhook(integration.webhook_url) return DiscordWebhook(
integration.webhook_url,
username=integration.webhook_bot_name,
avatar_url=integration.webhook_bot_profile_picture,
)
def update_discord_message(self): def update_discord_message(self):
webhook = self.get_or_create_webhook() webhook = self.get_or_create_webhook()
if webhook: if webhook:
webhook.content = self.get_discord_content() webhook.content = self.get_discord_content()
if webhook.id: if webhook.id:
webhook.edit() # fire and forget
threading.Thread(target=webhook.edit).start()
else: else:
webhook.execute() webhook.execute()
if webhook_id := webhook.id: if webhook_id := webhook.id:

View File

@ -26,6 +26,24 @@ class PlayerEvent(app_db.BaseModel):
) )
role: Mapped["PlayerTeamRole"] = relationship("PlayerTeamRole") role: Mapped["PlayerTeamRole"] = relationship("PlayerTeamRole")
class EventWithPlayerSchema(spec.BaseModel):
event: "EventSchema"
player_event: Optional["PlayerEventRolesSchema"]
@classmethod
def from_event_player_event(cls, event: "Event", player_event: Optional["PlayerEvent"]):
res = cls(
event=EventSchema.from_model(event),
player_event=None,
)
if player_event:
res.player_event = PlayerEventRolesSchema.from_event_player_team(
player_event, player_event.player_team
)
return res
class PlayerEventRolesSchema(spec.BaseModel): class PlayerEventRolesSchema(spec.BaseModel):
player: "PlayerSchema" player: "PlayerSchema"
role: Optional["RoleSchema"] role: Optional["RoleSchema"]
@ -44,7 +62,7 @@ class PlayerEventRolesSchema(spec.BaseModel):
) )
from models.event import Event from models.event import Event, EventSchema
from models.player import Player, PlayerSchema from models.player import Player, PlayerSchema
from models.player_team_role import PlayerTeamRole, RoleSchema from models.player_team_role import PlayerTeamRole, RoleSchema
from models.player_team import PlayerTeam from models.player_team import PlayerTeam