diff --git a/availabili.tf/src/assets/base.css b/availabili.tf/src/assets/base.css index b9a05e5..0e1d421 100644 --- a/availabili.tf/src/assets/base.css +++ b/availabili.tf/src/assets/base.css @@ -66,6 +66,35 @@ --vs-dropdown-option--active-bg: var(--accent)!important; --vs-dropdown-option--active-color: var(--base)!important; --vs-border-color: var(--overlay-0)!important; + + /* + --rosewater: #f4dbd6; + --flamingo: #f0c6c6; + --pink: #f5bde6; + --mauve: #c6a0f6; + --red: #ed8796; + --maroon: #ee99a0; + --peach: #f5a97f; + --yellow: #eed49f; + --green: #a6da95; + --teal: #8bd5ca; + --sky: #91d7e3; + --sapphire: #7dc4e4; + --blue: #8aadf4; + --lavender: #b7bdf8; + --text: #cad3f5; + --subtext-1: #b8c0e0; + --subtext-0: #a5adcb; + --overlay-2: #939ab7; + --overlay-1: #8087a2; + --overlay-0: #6e738d; + --surface-2: #5b6078; + --surface-1: #494d64; + --surface-0: #363a4f; + --base: #24273a; + --mantle: #1e2030; + --crust: #181926; +*/ } /* semantic color variables for this project */ diff --git a/availabili.tf/src/client/index.ts b/availabili.tf/src/client/index.ts index 9033139..b76aa83 100644 --- a/availabili.tf/src/client/index.ts +++ b/availabili.tf/src/client/index.ts @@ -17,6 +17,9 @@ export type { CreateTeamJson } from './models/CreateTeamJson'; export type { EditMemberRolesJson } from './models/EditMemberRolesJson'; export type { EventSchema } from './models/EventSchema'; export type { EventSchemaList } from './models/EventSchemaList'; +export type { GetEventPlayersResponse } from './models/GetEventPlayersResponse'; +export type { PlayerEventRolesSchema } from './models/PlayerEventRolesSchema'; +export type { PlayerRoleSchema } from './models/PlayerRoleSchema'; export type { PlayerSchema } from './models/PlayerSchema'; export type { PlayerTeamAvailabilityRoleSchema } from './models/PlayerTeamAvailabilityRoleSchema'; export type { PutScheduleForm } from './models/PutScheduleForm'; diff --git a/availabili.tf/src/client/models/CreateEventJson.ts b/availabili.tf/src/client/models/CreateEventJson.ts index 1866ba6..2530f85 100644 --- a/availabili.tf/src/client/models/CreateEventJson.ts +++ b/availabili.tf/src/client/models/CreateEventJson.ts @@ -2,10 +2,11 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { PlayerRoleSchema } from './PlayerRoleSchema'; export type CreateEventJson = { description: string; name: string; - playerIds: Array; + playerRoles: Array; startTime: string; }; diff --git a/availabili.tf/src/client/models/CreateTeamJson.ts b/availabili.tf/src/client/models/CreateTeamJson.ts index 16b4907..3a05185 100644 --- a/availabili.tf/src/client/models/CreateTeamJson.ts +++ b/availabili.tf/src/client/models/CreateTeamJson.ts @@ -3,7 +3,7 @@ /* tslint:disable */ /* eslint-disable */ export type CreateTeamJson = { - discordWebhookUrl?: string; + discordWebhookUrl?: (string | null); leagueTimezone: string; minuteOffset?: number; teamName: string; diff --git a/availabili.tf/src/client/models/EventSchema.ts b/availabili.tf/src/client/models/EventSchema.ts index faba155..c49c273 100644 --- a/availabili.tf/src/client/models/EventSchema.ts +++ b/availabili.tf/src/client/models/EventSchema.ts @@ -4,7 +4,7 @@ /* eslint-disable */ export type EventSchema = { createdAt: string; - description?: string; + description: (string | null); id: number; name: string; startTime: string; diff --git a/availabili.tf/src/client/models/GetEventPlayersResponse.ts b/availabili.tf/src/client/models/GetEventPlayersResponse.ts new file mode 100644 index 0000000..f79844a --- /dev/null +++ b/availabili.tf/src/client/models/GetEventPlayersResponse.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { PlayerEventRolesSchema } from './PlayerEventRolesSchema'; +export type GetEventPlayersResponse = { + players: Array; +}; + diff --git a/availabili.tf/src/client/models/PlayerEventRolesSchema.ts b/availabili.tf/src/client/models/PlayerEventRolesSchema.ts new file mode 100644 index 0000000..ddc16d6 --- /dev/null +++ b/availabili.tf/src/client/models/PlayerEventRolesSchema.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { PlayerSchema } from './PlayerSchema'; +import type { RoleSchema } from './RoleSchema'; +export type PlayerEventRolesSchema = { + hasConfirmed: boolean; + player: PlayerSchema; + playtime: number; + role: (RoleSchema | null); + roles: Array; +}; + diff --git a/availabili.tf/src/client/models/PlayerRoleSchema.ts b/availabili.tf/src/client/models/PlayerRoleSchema.ts new file mode 100644 index 0000000..21a385b --- /dev/null +++ b/availabili.tf/src/client/models/PlayerRoleSchema.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { PlayerSchema } from './PlayerSchema'; +import type { RoleSchema } from './RoleSchema'; +export type PlayerRoleSchema = { + player: PlayerSchema; + role: RoleSchema; +}; + diff --git a/availabili.tf/src/client/models/TeamIntegrationSchema.ts b/availabili.tf/src/client/models/TeamIntegrationSchema.ts index 89c6667..15e7266 100644 --- a/availabili.tf/src/client/models/TeamIntegrationSchema.ts +++ b/availabili.tf/src/client/models/TeamIntegrationSchema.ts @@ -5,7 +5,7 @@ import type { TeamDiscordIntegrationSchema } from './TeamDiscordIntegrationSchema'; import type { TeamLogsTfIntegrationSchema } from './TeamLogsTfIntegrationSchema'; export type TeamIntegrationSchema = { - discordIntegration?: TeamDiscordIntegrationSchema; - logsTfIntegration?: TeamLogsTfIntegrationSchema; + discordIntegration: (TeamDiscordIntegrationSchema | null); + logsTfIntegration: (TeamLogsTfIntegrationSchema | null); }; diff --git a/availabili.tf/src/client/models/TeamLogsTfIntegrationSchema.ts b/availabili.tf/src/client/models/TeamLogsTfIntegrationSchema.ts index 30b1d7e..7f88f9f 100644 --- a/availabili.tf/src/client/models/TeamLogsTfIntegrationSchema.ts +++ b/availabili.tf/src/client/models/TeamLogsTfIntegrationSchema.ts @@ -3,7 +3,7 @@ /* tslint:disable */ /* eslint-disable */ export type TeamLogsTfIntegrationSchema = { - logsTfApiKey: string; + logsTfApiKey: (string | null); minTeamMemberCount: number; }; diff --git a/availabili.tf/src/client/models/TeamRole.ts b/availabili.tf/src/client/models/TeamRole.ts index 6bf79f5..2d3021b 100644 --- a/availabili.tf/src/client/models/TeamRole.ts +++ b/availabili.tf/src/client/models/TeamRole.ts @@ -2,9 +2,6 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -/** - * An enumeration. - */ export enum TeamRole { '_0' = 0, '_1' = 1, diff --git a/availabili.tf/src/client/services/DefaultService.ts b/availabili.tf/src/client/services/DefaultService.ts index 89f3271..595d244 100644 --- a/availabili.tf/src/client/services/DefaultService.ts +++ b/availabili.tf/src/client/services/DefaultService.ts @@ -8,6 +8,7 @@ import type { CreateTeamJson } from '../models/CreateTeamJson'; import type { EditMemberRolesJson } from '../models/EditMemberRolesJson'; import type { EventSchema } from '../models/EventSchema'; import type { EventSchemaList } from '../models/EventSchemaList'; +import type { GetEventPlayersResponse } from '../models/GetEventPlayersResponse'; import type { PlayerSchema } from '../models/PlayerSchema'; import type { PutScheduleForm } from '../models/PutScheduleForm'; import type { SetUsernameJson } from '../models/SetUsernameJson'; @@ -62,7 +63,7 @@ export class DefaultService { 'team_id': teamId, }, errors: { - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -73,7 +74,7 @@ export class DefaultService { * @returns EventSchema OK * @throws ApiError */ - public postApiEventsTeamIdTeamId( + public createEvent( teamId: number, requestBody?: CreateEventJson, ): CancelablePromise { @@ -86,7 +87,7 @@ export class DefaultService { body: requestBody, mediaType: 'application/json', errors: { - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -123,7 +124,27 @@ export class DefaultService { 'event_id': eventId, }, errors: { - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, + }, + }); + } + /** + * get_event_players + * @param eventId + * @returns GetEventPlayersResponse OK + * @throws ApiError + */ + public getEventPlayers( + eventId: number, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/api/events/{event_id}/players', + path: { + 'event_id': eventId, + }, + errors: { + 422: `Unprocessable Content`, }, }); } @@ -144,6 +165,52 @@ export class DefaultService { }, }); } + /** + * unattend_event + * @param eventId + * @param teamId + * @returns void + * @throws ApiError + */ + public deleteApiEventsEventIdTeamIdTeamIdAttendance( + eventId: number, + teamId: number, + ): CancelablePromise { + 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 + * @param eventId + * @param teamId + * @returns void + * @throws ApiError + */ + public putApiEventsEventIdTeamIdTeamIdAttendance( + eventId: number, + teamId: number, + ): CancelablePromise { + 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 * @returns void @@ -188,7 +255,7 @@ export class DefaultService { url: '/api/login/get-user', errors: { 401: `Unauthorized`, - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -214,7 +281,7 @@ export class DefaultService { 'windowSizeDays': windowSizeDays, }, errors: { - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -256,7 +323,7 @@ export class DefaultService { 'windowSizeDays': windowSizeDays, }, errors: { - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -279,7 +346,7 @@ export class DefaultService { 'teamId': teamId, }, errors: { - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -299,7 +366,7 @@ export class DefaultService { mediaType: 'application/json', errors: { 403: `Forbidden`, - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -315,7 +382,7 @@ export class DefaultService { errors: { 403: `Forbidden`, 404: `Not Found`, - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -337,7 +404,7 @@ export class DefaultService { errors: { 403: `Forbidden`, 404: `Not Found`, - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -359,7 +426,7 @@ export class DefaultService { errors: { 403: `Forbidden`, 404: `Not Found`, - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -383,7 +450,7 @@ export class DefaultService { }, errors: { 404: `Not Found`, - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -412,7 +479,7 @@ export class DefaultService { errors: { 403: `Forbidden`, 404: `Not Found`, - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -432,12 +499,12 @@ export class DefaultService { 'team_id': teamId, }, errors: { - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } /** - * update_integration + * update_integrations * @param teamId * @param requestBody * @returns TeamIntegrationSchema OK @@ -456,7 +523,7 @@ export class DefaultService { body: requestBody, mediaType: 'application/json', errors: { - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -477,7 +544,7 @@ export class DefaultService { }, errors: { 404: `Not Found`, - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -498,7 +565,7 @@ export class DefaultService { }, errors: { 404: `Not Found`, - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -522,7 +589,7 @@ export class DefaultService { }, errors: { 404: `Not Found`, - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -551,7 +618,7 @@ export class DefaultService { errors: { 403: `Forbidden`, 404: `Not Found`, - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -576,7 +643,7 @@ export class DefaultService { errors: { 403: `Forbidden`, 404: `Not Found`, - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -598,7 +665,7 @@ export class DefaultService { errors: { 403: `Forbidden`, 404: `Not Found`, - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } @@ -617,7 +684,7 @@ export class DefaultService { body: requestBody, mediaType: 'application/json', errors: { - 422: `Unprocessable Entity`, + 422: `Unprocessable Content`, }, }); } diff --git a/availabili.tf/src/components/EventCard.vue b/availabili.tf/src/components/EventCard.vue index 520a2e8..6aac2dc 100644 --- a/availabili.tf/src/components/EventCard.vue +++ b/availabili.tf/src/components/EventCard.vue @@ -41,19 +41,19 @@ const props = defineProps<{

{{ event.name }}

- {{ event.description }} - No description provided. + + + {{ formattedTime }} +
- - - {{ formattedTime }} - - + {{ event.description }} + No description provided. +
diff --git a/availabili.tf/src/router/index.ts b/availabili.tf/src/router/index.ts index d4f2f74..a1f98f4 100644 --- a/availabili.tf/src/router/index.ts +++ b/availabili.tf/src/router/index.ts @@ -35,6 +35,11 @@ const router = createRouter({ name: "roster-builder", component: RosterBuilderView }, + { + path: "/schedule/roster/event/:eventId", + name: "roster-builder-event", + component: RosterBuilderView + }, { path: "/team/register", name: "team-registration", diff --git a/availabili.tf/src/stores/roster.ts b/availabili.tf/src/stores/roster.ts index 9301fb1..e0b5f67 100644 --- a/availabili.tf/src/stores/roster.ts +++ b/availabili.tf/src/stores/roster.ts @@ -2,10 +2,16 @@ import { type Player, type PlayerTeamRoleFlat } from "@/player"; import { defineStore } from "pinia"; import { computed, reactive, ref, type Reactive, type Ref } from "vue"; import { useClientStore } from "./client"; +import { type EventSchema, type CreateEventJson, type PlayerRoleSchema } from "@/client"; +import { useTeamDetails } from "@/composables/team-details"; +import moment from "moment"; +import { useRoute, useRouter } from "vue-router"; export const useRosterStore = defineStore("roster", () => { const clientStore = useClientStore(); const client = clientStore.client; + const router = useRouter(); + const route = useRoute(); const neededRoles: Reactive> = reactive([ "PocketScout", @@ -106,22 +112,94 @@ export const useRosterStore = defineStore("roster", () => { fetchAvailablePlayers.name, () => client.default.viewAvailableAtTime(startTime.toString(), teamId), (response) => { - availablePlayers.value = response.players.flatMap((schema) => { - return schema.roles.map((role) => ({ - steamId: schema.player.steamId, - name: schema.player.username, - role: role.role, - isMain: role.isMain, - availability: schema.availability, - playtime: schema.playtime, - })); - }); + availablePlayers.value = response.players + .flatMap((schema) => { + return schema.roles.map((role) => ({ + steamId: schema.player.steamId, + name: schema.player.username, + role: role.role, + isMain: role.isMain, + availability: schema.availability, + playtime: schema.playtime, + })); + }); return response; } ) } + function fetchPlayersFromEvent(eventId: number) { + return clientStore.call( + fetchPlayersFromEvent.name, + () => client.default.getEventPlayers(eventId), + (response) => { + availablePlayers.value = response.players + .flatMap((schema) => { + return schema.roles.map((role) => ({ + steamId: schema.player.steamId, + name: schema.player.username, + role: role.role, + isMain: role.isMain, + availability: schema.hasConfirmed ? 2 : 1, + playtime: schema.playtime, + })); + }); + + response.players + .forEach((schema) => { + if (schema.role) { + selectedPlayers[schema.role.role] = { + steamId: schema.player.steamId, + name: schema.player.username, + role: schema.role.role, + isMain: schema.role.isMain, + availability: schema.hasConfirmed ? 2 : 1, + playtime: schema.playtime, + } + } + }); + + return response; + } + ) + } + + const currentEvent = ref(undefined); + + const startTime = ref(); + + function saveRoster(teamId: number) { + if (startTime.value == undefined) { + throw new Error("No start time set"); + } + + if (!currentEvent.value) { + const body: CreateEventJson = { + name: "Test", + description: "test description", + startTime: startTime.value.toString(), + playerRoles: Object.values(selectedPlayers).map((player) => ({ + player: { + steamId: player.steamId, + username: player.name, + }, + role: { + role: player.role, + isMain: player.isMain, + }, + })), + }; + + clientStore.client.default.createEvent(teamId, body) + .then(() => { + + }); + } else { + // TODO: update event + } + } + return { neededRoles, selectedPlayers, @@ -136,5 +214,8 @@ export const useRosterStore = defineStore("roster", () => { mainRoles, alternateRoles, fetchAvailablePlayers, + fetchPlayersFromEvent, + startTime, + saveRoster, } }); diff --git a/availabili.tf/src/views/RosterBuilderView.vue b/availabili.tf/src/views/RosterBuilderView.vue index 2cd9c60..dab0e78 100644 --- a/availabili.tf/src/views/RosterBuilderView.vue +++ b/availabili.tf/src/views/RosterBuilderView.vue @@ -6,8 +6,10 @@ import { computed, reactive, onMounted } from "vue"; import { useRosterStore } from "../stores/roster"; import { useRoute } from "vue-router"; import moment from "moment"; +import { useEventsStore } from "@/stores/events"; const rosterStore = useRosterStore(); +const eventsStore = useEventsStore(); const route = useRoute(); @@ -19,8 +21,21 @@ const hasAlternates = computed(() => { return rosterStore.alternateRoles.length > 0; }); -onMounted(() => { - rosterStore.fetchAvailablePlayers(route.params.startTime, route.params.teamId); +const eventId = computed(() => Number(route.params.eventId)); + +function saveRoster() { + rosterStore.saveRoster(Number(route.params.teamId)); +} + +onMounted(async () => { + if (eventId.value) { + const event = await eventsStore.fetchEvent(eventId.value); + rosterStore.startTime = moment(event.startTime).unix(); + rosterStore.fetchPlayersFromEvent(eventId.value); + } else { + rosterStore.startTime = Number(route.params.startTime); + rosterStore.fetchAvailablePlayers(rosterStore.startTime, Number(route.params.teamId)); + } }); @@ -29,14 +44,14 @@ onMounted(() => {

Roster for Snus Brotherhood - + @ - {{ moment.unix(route.params.startTime).format("L LT") }} + {{ moment.unix(rosterStore.startTime).format("L LT") }}

- +
diff --git a/backend-flask/app_db.py b/backend-flask/app_db.py index 0fc8657..400b433 100644 --- a/backend-flask/app_db.py +++ b/backend-flask/app_db.py @@ -23,4 +23,4 @@ def connect_db_with_app(): metadata = MetaData(naming_convention=convention) app = Flask(__name__) db = SQLAlchemy(model_class=BaseModel, metadata=metadata) -migrate = Migrate(render_as_batch=True) +migrate = Migrate(app, db, render_as_batch=True) diff --git a/backend-flask/events.py b/backend-flask/events.py index 0b664cf..93e3737 100644 --- a/backend-flask/events.py +++ b/backend-flask/events.py @@ -8,12 +8,16 @@ from datetime import datetime -from flask import Blueprint, abort +from flask import Blueprint, abort, make_response from spectree import Response -from models.player_event import PlayerEvent +from sqlalchemy.sql import tuple_ from models.player import Player +from models.player_event import PlayerEvent, PlayerEventRolesSchema +from models.player_team_availability import PlayerTeamAvailability +from models.player_team_role import PlayerRoleSchema, PlayerTeamRole +from models.team import Team from spec import BaseModel, spec -from middleware import assert_team_authority, requires_authentication, requires_team_membership +from middleware import assert_team_authority, assert_team_membership, requires_authentication, requires_team_membership from models.event import Event, EventSchema from models.player_team import PlayerTeam from app_db import db @@ -65,17 +69,18 @@ class CreateEventJson(BaseModel): name: str description: str start_time: datetime - player_ids: list[int] + player_roles: list[PlayerRoleSchema] @api_events.post("/team/id/") @spec.validate( resp=Response( HTTP_200=EventSchema, - ) + ), + operation_id="create_event", ) @requires_authentication @requires_team_membership() -def create_event(player_team: PlayerTeam, json: CreateEventJson, **_): +def create_event(player_team: PlayerTeam, team_id: int, json: CreateEventJson, **_): event = Event() event.team_id = player_team.team_id event.name = json.name @@ -86,27 +91,149 @@ def create_event(player_team: PlayerTeam, json: CreateEventJson, **_): db.session.flush() db.session.refresh(event) - players_teams = db.session.query( - PlayerTeam + tuples = map(lambda x: (x.player.steam_id, x.role.role), json.player_roles) + + results = db.session.query( + PlayerTeam, PlayerTeamRole.id, PlayerTeamAvailability.availability ).join( - Player + PlayerTeamRole + ).outerjoin( + PlayerTeamAvailability, + (PlayerTeamAvailability.player_team_id == PlayerTeam.id) & + (PlayerTeamAvailability.start_time <= event.start_time) & + (PlayerTeamAvailability.end_time > event.start_time) ).where( - PlayerTeam.team_id == player_team.team_id + PlayerTeam.team_id == team_id ).where( - PlayerTeam.player_id.in_(json.player_ids) + # (player_id, role) in (...) + tuple_(PlayerTeam.player_id, PlayerTeamRole.role).in_(tuples) ).all() - for player_team in players_teams: - player = player_team.player + for player_team, role_id, availability in map(lambda x: x.tuple(), results): player_event = PlayerEvent() - player_event.player_id = player.steam_id + player_event.player_id = player_team.player_id player_event.event_id = event.id + player_event.player_team_role_id = role_id + + # autoconfirm if availability = 2 + player_event.has_confirmed = (availability == 2) + db.session.add(player_event) db.session.commit() + event.update_discord_message() + return EventSchema.from_model(event).dict(by_alias=True), 200 +@api_events.put("//attendance") +@spec.validate( + resp=Response( + HTTP_204=None, + ) +) +@requires_authentication +@requires_team_membership() +def attend_event(player_team: PlayerTeam, event_id: int, **_): + player_event = db.session.query( + PlayerEvent + ).where( + PlayerEvent.event_id == event_id + ).where( + PlayerEvent.player_id == player_team.player_id + ).join( + Event + ).where( + Event.team_id == player_team.team_id + ).one_or_none() + + if not player_event: + player_event = PlayerEvent() + player_event.event_id = event_id + player_event.player_id = player_team.player_id + db.session.add(player_event) + + player_event.has_confirmed = True + + db.session.commit() + + player_event.event.update_discord_message() + + return make_response({ }, 204) + +@api_events.delete("//attendance") +@spec.validate( + resp=Response( + HTTP_204=None, + ) +) +@requires_authentication +@requires_team_membership() +def unattend_event(player_team: PlayerTeam, event_id: int, **_): + result = db.session.query( + PlayerEvent, Event + ).where( + PlayerEvent.event_id == event_id + ).where( + PlayerEvent.player_id == player_team.player_id + ).join( + Event + ).where( + Event.team_id == player_team.team_id + ).one_or_none() + + if not result: + abort(404) + + player_event, event = result.tuple() + + db.session.delete(player_event) + db.session.commit() + + event.update_discord_message() + + return make_response({ }, 204) + +class GetEventPlayersResponse(BaseModel): + players: list[PlayerEventRolesSchema] + +@api_events.get("//players") +@spec.validate( + resp=Response( + HTTP_200=GetEventPlayersResponse, + ), + operation_id="get_event_players", +) +@requires_authentication +def get_event_players(player: Player, 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) + + players_events = db.session.query( + PlayerEvent + ).join( + Event, + Event.id == PlayerEvent.event_id + ).join( + PlayerTeam, + PlayerTeam.team_id == Event.team_id & PlayerEvent.player_id == PlayerTeam.player_id + ).where( + PlayerEvent.event_id == event_id + ).all() + + player_event_roles = [ + PlayerEventRolesSchema.from_event_player_team( + player_event, player_event.player_team + ) + for player_event in players_events + ] + + return GetEventPlayersResponse( + players=player_event_roles + ).dict(by_alias=True), 200 + @api_events.patch("//players") @requires_authentication @requires_team_membership() diff --git a/backend-flask/middleware.py b/backend-flask/middleware.py index 17afbca..b0c3b9b 100644 --- a/backend-flask/middleware.py +++ b/backend-flask/middleware.py @@ -6,6 +6,7 @@ from app_db import db from models.auth_session import AuthSession from models.player import Player from models.player_team import PlayerTeam +from models.team import Team def requires_authentication(f): @@ -72,6 +73,20 @@ def requires_team_membership( return decorator return wrapper +def assert_team_membership(player: Player, team: Team): + player_team = db.session.query( + PlayerTeam + ).where( + PlayerTeam.player == player + ).where( + PlayerTeam.team == team + ).one_or_none() + + if not player_team: + abort(404) + + return player_team + def assert_team_authority( player_team: PlayerTeam, target_player_team: PlayerTeam | None = None, diff --git a/backend-flask/migrations/versions/286ee26b9e5d_add_event_discord_message_id.py b/backend-flask/migrations/versions/286ee26b9e5d_add_event_discord_message_id.py new file mode 100644 index 0000000..89e08eb --- /dev/null +++ b/backend-flask/migrations/versions/286ee26b9e5d_add_event_discord_message_id.py @@ -0,0 +1,32 @@ +"""Add event.discord_message_id + +Revision ID: 286ee26b9e5d +Revises: 392454b91293 +Create Date: 2024-11-25 21:00:08.444434 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '286ee26b9e5d' +down_revision = '392454b91293' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('events', schema=None) as batch_op: + batch_op.add_column(sa.Column('discord_message_id', sa.BigInteger(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('events', schema=None) as batch_op: + batch_op.drop_column('discord_message_id') + + # ### end Alembic commands ### diff --git a/backend-flask/models/event.py b/backend-flask/models/event.py index 35ec206..2aa6419 100644 --- a/backend-flask/models/event.py +++ b/backend-flask/models/event.py @@ -3,9 +3,10 @@ from sqlalchemy.orm import mapped_column, relationship from sqlalchemy.orm.attributes import Mapped from sqlalchemy.orm.properties import ForeignKey from sqlalchemy.schema import UniqueConstraint -from sqlalchemy.types import TIMESTAMP, Integer, String, Text +from sqlalchemy.types import TIMESTAMP, BigInteger, Integer, String, Text from sqlalchemy.sql import func from sqlalchemy_utc import UtcDateTime +from discord_webhook import DiscordWebhook import app_db import spec @@ -23,14 +24,81 @@ class Event(app_db.BaseModel): description: Mapped[str] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now()) + discord_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) team: Mapped["Team"] = relationship("Team", back_populates="events") - players: Mapped["PlayerEvent"] = relationship("PlayerEvent", back_populates="event") + players: Mapped[list["PlayerEvent"]] = relationship("PlayerEvent", back_populates="event") __table_args__ = ( UniqueConstraint("team_id", "name", "start_time"), ) + def get_discord_content(self): + start_timestamp = int(self.start_time.timestamp()) + players = list(self.players) + # players with a role should be sorted first + players.sort(key=lambda p: p.role is not None, reverse=True) + players_info = [] + + for player in players: + player_info = "- " + + if player.role: + player_info += f"**{player.role.role.name}:** " + + player_info += f"{player.player.username}" + + if player.has_confirmed: + player_info += " ✅" + else: + player_info += " ⏳" + + players_info.append(player_info) + + return "\n".join([ + f"# {self.name}", + "", + self.description or "*No description.*", + "", + f"", + "\n".join(players_info), + "", + "[Confirm availability here]" + + f"(https://availabili.tf/team/id/{self.team.id}/events/{self.id})", + ]) + + def get_or_create_webhook(self): + integration = app_db.db.session.query( + TeamDiscordIntegration + ).where( + TeamDiscordIntegration.team_id == self.team_id + ).first() + + if not integration: + return None + + if self.discord_message_id: + return DiscordWebhook( + integration.webhook_url, + id=str(self.discord_message_id), + ) + else: + return DiscordWebhook(integration.webhook_url) + + def update_discord_message(self): + webhook = self.get_or_create_webhook() + if webhook: + webhook.content = self.get_discord_content() + if webhook.id: + webhook.edit() + else: + webhook.execute() + if webhook_id := webhook.id: + self.discord_message_id = int(webhook_id) + app_db.db.session.commit() + else: + raise Exception("Failed to create webhook") + class EventSchema(spec.BaseModel): id: int team_id: int @@ -50,6 +118,17 @@ class EventSchema(spec.BaseModel): created_at=model.created_at, ) +class EventPlayersSchema(spec.BaseModel): + players: list["PlayerEventRolesSchema"] + + @classmethod + def from_model(cls, model: Event) -> "EventPlayersSchema": + return cls( + players=[PlayerEventRolesSchema.from_model(player) for player in model.players], + roles=[RoleSchema.from_model(player.role.role) for player in model.players if player.role], + ) + from models.team import Team from models.player_event import PlayerEvent +from models.team_integration import TeamDiscordIntegration diff --git a/backend-flask/models/player_event.py b/backend-flask/models/player_event.py index cbb21ac..5943d29 100644 --- a/backend-flask/models/player_event.py +++ b/backend-flask/models/player_event.py @@ -1,8 +1,10 @@ +from typing import Optional from sqlalchemy.orm import mapped_column, relationship from sqlalchemy.orm.attributes import Mapped from sqlalchemy.orm.properties import ForeignKey from sqlalchemy.types import Boolean import app_db +import spec class PlayerEvent(app_db.BaseModel): @@ -15,9 +17,34 @@ class PlayerEvent(app_db.BaseModel): event: Mapped["Event"] = relationship("Event", back_populates="players") player: Mapped["Player"] = relationship("Player", back_populates="events") + player_team: Mapped["PlayerTeam"] = relationship( + "PlayerTeam", + secondary="players", + primaryjoin="PlayerEvent.player_id == Player.steam_id", + secondaryjoin="PlayerTeam.player_id == Player.steam_id", + viewonly=True, + ) role: Mapped["PlayerTeamRole"] = relationship("PlayerTeamRole") +class PlayerEventRolesSchema(spec.BaseModel): + player: "PlayerSchema" + role: Optional["RoleSchema"] + roles: list["RoleSchema"] + has_confirmed: bool + playtime: int + + @classmethod + def from_event_player_team(cls, player_event: "PlayerEvent", player_team: "PlayerTeam"): + return cls( + player=PlayerSchema.from_model(player_event.player), + role=RoleSchema.from_model(player_event.role) if player_event.role else None, + roles=[RoleSchema.from_model(role) for role in player_team.player_roles], + has_confirmed=player_event.has_confirmed, + playtime=int(player_team.playtime.total_seconds()), + ) + from models.event import Event -from models.player import Player -from models.player_team_role import PlayerTeamRole +from models.player import Player, PlayerSchema +from models.player_team_role import PlayerTeamRole, RoleSchema +from models.player_team import PlayerTeam diff --git a/backend-flask/models/player_team_role.py b/backend-flask/models/player_team_role.py index b84f2d6..72ac2d6 100644 --- a/backend-flask/models/player_team_role.py +++ b/backend-flask/models/player_team_role.py @@ -53,7 +53,6 @@ class PlayerTeamRole(app_db.BaseModel): UniqueConstraint("player_team_id", "role"), ) - class RoleSchema(spec.BaseModel): role: str is_main: bool @@ -62,5 +61,10 @@ class RoleSchema(spec.BaseModel): def from_model(cls, role: PlayerTeamRole): return cls(role=role.role.name, is_main=role.is_main) +class PlayerRoleSchema(spec.BaseModel): + player: "PlayerSchema" + role: RoleSchema + from models.player_team import PlayerTeam +from models.player import PlayerSchema diff --git a/backend-flask/requirements.txt b/backend-flask/requirements.txt index a56038c..cb36873 100644 --- a/backend-flask/requirements.txt +++ b/backend-flask/requirements.txt @@ -20,3 +20,5 @@ Flask-Migrate requests pytz # timezone handling + +discord-webhook # for sending messages to Discord webhooks diff --git a/backend-flask/schedule.py b/backend-flask/schedule.py index 06b611a..3676864 100644 --- a/backend-flask/schedule.py +++ b/backend-flask/schedule.py @@ -1,11 +1,13 @@ import datetime -from typing import cast +from typing import Optional, cast from flask import Blueprint, abort, jsonify, make_response, request from spectree import Response +from sqlalchemy import Row from sqlalchemy.orm import contains_eager, joinedload from sqlalchemy.sql import and_, select from app_db import db from models.player import Player, PlayerSchema +from models.player_event import PlayerEvent, PlayerEventRolesSchema from models.player_team import PlayerTeam from models.player_team_availability import AvailabilitySchema, PlayerTeamAvailability, PlayerTeamAvailabilityRoleSchema from models.player_team_role import PlayerTeamRole, RoleSchema diff --git a/backend-flask/team.py b/backend-flask/team.py index bb22c0a..3b9ae62 100644 --- a/backend-flask/team.py +++ b/backend-flask/team.py @@ -397,22 +397,47 @@ def edit_member_roles( if not target_player: abort(401) - # TODO: change this to a MERGE statement + + """ + MERGE INTO players_teams_roles AS target + USING ( + VALUES + ('PocketScout', 1), + ('PocketScout', 0), + ) AS source(role, is_main) + ON (target.player_team_id = :player_team_id AND target.role = source.role) + WHEN MATCHED THEN + UPDATE SET + target.role = source.role, + target.is_main = source.is_main + WHEN NOT MATCHED BY TARGET THEN + INSERT (player_team_id, role, is_main) + VALUES (:player_team_id, source.role, source.is_main) + WHEN NOT MATCHED BY SOURCE THEN + DELETE; + """ for role in target_player.player_roles: # delete role if not found in json f = filter(lambda x: x.role == role.role.name, json.roles) matched_role = next(f, None) - if not matched_role: + if matched_role: + # update + role.is_main = matched_role.is_main + else: db.session.delete(role) for schema in json.roles: - role = PlayerTeamRole() - role.player_team = target_player - role.role = PlayerTeamRole.Role[schema.role] - role.is_main = schema.is_main - db.session.merge(role) + # insert if not found in target + f = filter(lambda x: x.role.name == schema.role, target_player.player_roles) + + if not next(f, None): + role = PlayerTeamRole() + role.player_team_id = target_player.id + role.role = PlayerTeamRole.Role[schema.role] + role.is_main = schema.is_main + db.session.add(role) db.session.commit()