Implement event stuff

master
John Montagu, the 4th Earl of Sandvich 2024-11-27 01:03:41 -08:00
parent e11bcc2a08
commit e1c6a7bb14
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
26 changed files with 630 additions and 85 deletions

View File

@ -66,6 +66,35 @@
--vs-dropdown-option--active-bg: var(--accent)!important; --vs-dropdown-option--active-bg: var(--accent)!important;
--vs-dropdown-option--active-color: var(--base)!important; --vs-dropdown-option--active-color: var(--base)!important;
--vs-border-color: var(--overlay-0)!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 */ /* semantic color variables for this project */

View File

@ -17,6 +17,9 @@ 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 { 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 { PlayerSchema } from './models/PlayerSchema';
export type { PlayerTeamAvailabilityRoleSchema } from './models/PlayerTeamAvailabilityRoleSchema'; export type { PlayerTeamAvailabilityRoleSchema } from './models/PlayerTeamAvailabilityRoleSchema';
export type { PutScheduleForm } from './models/PutScheduleForm'; export type { PutScheduleForm } from './models/PutScheduleForm';

View File

@ -2,10 +2,11 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { PlayerRoleSchema } from './PlayerRoleSchema';
export type CreateEventJson = { export type CreateEventJson = {
description: string; description: string;
name: string; name: string;
playerIds: Array<number>; playerRoles: Array<PlayerRoleSchema>;
startTime: string; startTime: string;
}; };

View File

@ -3,7 +3,7 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type CreateTeamJson = { export type CreateTeamJson = {
discordWebhookUrl?: string; discordWebhookUrl?: (string | null);
leagueTimezone: string; leagueTimezone: string;
minuteOffset?: number; minuteOffset?: number;
teamName: string; teamName: string;

View File

@ -4,7 +4,7 @@
/* eslint-disable */ /* eslint-disable */
export type EventSchema = { export type EventSchema = {
createdAt: string; createdAt: string;
description?: string; description: (string | null);
id: number; id: number;
name: string; name: string;
startTime: string; startTime: string;

View File

@ -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<PlayerEventRolesSchema>;
};

View File

@ -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<RoleSchema>;
};

View File

@ -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;
};

View File

@ -5,7 +5,7 @@
import type { TeamDiscordIntegrationSchema } from './TeamDiscordIntegrationSchema'; import type { TeamDiscordIntegrationSchema } from './TeamDiscordIntegrationSchema';
import type { TeamLogsTfIntegrationSchema } from './TeamLogsTfIntegrationSchema'; import type { TeamLogsTfIntegrationSchema } from './TeamLogsTfIntegrationSchema';
export type TeamIntegrationSchema = { export type TeamIntegrationSchema = {
discordIntegration?: TeamDiscordIntegrationSchema; discordIntegration: (TeamDiscordIntegrationSchema | null);
logsTfIntegration?: TeamLogsTfIntegrationSchema; logsTfIntegration: (TeamLogsTfIntegrationSchema | null);
}; };

View File

@ -3,7 +3,7 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type TeamLogsTfIntegrationSchema = { export type TeamLogsTfIntegrationSchema = {
logsTfApiKey: string; logsTfApiKey: (string | null);
minTeamMemberCount: number; minTeamMemberCount: number;
}; };

View File

@ -2,9 +2,6 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
/**
* An enumeration.
*/
export enum TeamRole { export enum TeamRole {
'_0' = 0, '_0' = 0,
'_1' = 1, '_1' = 1,

View File

@ -8,6 +8,7 @@ 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 { EventSchemaList } from '../models/EventSchemaList';
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';
import type { SetUsernameJson } from '../models/SetUsernameJson'; import type { SetUsernameJson } from '../models/SetUsernameJson';
@ -62,7 +63,7 @@ export class DefaultService {
'team_id': teamId, 'team_id': teamId,
}, },
errors: { errors: {
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -73,7 +74,7 @@ export class DefaultService {
* @returns EventSchema OK * @returns EventSchema OK
* @throws ApiError * @throws ApiError
*/ */
public postApiEventsTeamIdTeamId( public createEvent(
teamId: number, teamId: number,
requestBody?: CreateEventJson, requestBody?: CreateEventJson,
): CancelablePromise<EventSchema> { ): CancelablePromise<EventSchema> {
@ -86,7 +87,7 @@ export class DefaultService {
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -123,7 +124,27 @@ export class DefaultService {
'event_id': eventId, 'event_id': eventId,
}, },
errors: { errors: {
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
},
});
}
/**
* get_event_players <GET>
* @param eventId
* @returns GetEventPlayersResponse OK
* @throws ApiError
*/
public getEventPlayers(
eventId: number,
): CancelablePromise<GetEventPlayersResponse> {
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 <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
@ -188,7 +255,7 @@ export class DefaultService {
url: '/api/login/get-user', url: '/api/login/get-user',
errors: { errors: {
401: `Unauthorized`, 401: `Unauthorized`,
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -214,7 +281,7 @@ export class DefaultService {
'windowSizeDays': windowSizeDays, 'windowSizeDays': windowSizeDays,
}, },
errors: { errors: {
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -256,7 +323,7 @@ export class DefaultService {
'windowSizeDays': windowSizeDays, 'windowSizeDays': windowSizeDays,
}, },
errors: { errors: {
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -279,7 +346,7 @@ export class DefaultService {
'teamId': teamId, 'teamId': teamId,
}, },
errors: { errors: {
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -299,7 +366,7 @@ export class DefaultService {
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
403: `Forbidden`, 403: `Forbidden`,
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -315,7 +382,7 @@ export class DefaultService {
errors: { errors: {
403: `Forbidden`, 403: `Forbidden`,
404: `Not Found`, 404: `Not Found`,
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -337,7 +404,7 @@ export class DefaultService {
errors: { errors: {
403: `Forbidden`, 403: `Forbidden`,
404: `Not Found`, 404: `Not Found`,
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -359,7 +426,7 @@ export class DefaultService {
errors: { errors: {
403: `Forbidden`, 403: `Forbidden`,
404: `Not Found`, 404: `Not Found`,
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -383,7 +450,7 @@ export class DefaultService {
}, },
errors: { errors: {
404: `Not Found`, 404: `Not Found`,
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -412,7 +479,7 @@ export class DefaultService {
errors: { errors: {
403: `Forbidden`, 403: `Forbidden`,
404: `Not Found`, 404: `Not Found`,
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -432,12 +499,12 @@ export class DefaultService {
'team_id': teamId, 'team_id': teamId,
}, },
errors: { errors: {
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
/** /**
* update_integration <PUT> * update_integrations <PUT>
* @param teamId * @param teamId
* @param requestBody * @param requestBody
* @returns TeamIntegrationSchema OK * @returns TeamIntegrationSchema OK
@ -456,7 +523,7 @@ export class DefaultService {
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -477,7 +544,7 @@ export class DefaultService {
}, },
errors: { errors: {
404: `Not Found`, 404: `Not Found`,
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -498,7 +565,7 @@ export class DefaultService {
}, },
errors: { errors: {
404: `Not Found`, 404: `Not Found`,
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -522,7 +589,7 @@ export class DefaultService {
}, },
errors: { errors: {
404: `Not Found`, 404: `Not Found`,
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -551,7 +618,7 @@ export class DefaultService {
errors: { errors: {
403: `Forbidden`, 403: `Forbidden`,
404: `Not Found`, 404: `Not Found`,
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -576,7 +643,7 @@ export class DefaultService {
errors: { errors: {
403: `Forbidden`, 403: `Forbidden`,
404: `Not Found`, 404: `Not Found`,
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -598,7 +665,7 @@ export class DefaultService {
errors: { errors: {
403: `Forbidden`, 403: `Forbidden`,
404: `Not Found`, 404: `Not Found`,
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }
@ -617,7 +684,7 @@ export class DefaultService {
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
422: `Unprocessable Entity`, 422: `Unprocessable Content`,
}, },
}); });
} }

View File

@ -41,19 +41,19 @@ const props = defineProps<{
<div> <div>
<h3>{{ event.name }}</h3> <h3>{{ event.name }}</h3>
<div> <div>
<span v-if="event.description">{{ event.description }}</span> <span>
<em v-else class="subtext">No description provided.</em> <i class="bi bi-clock-fill margin" />
{{ formattedTime }}
</span>
</div> </div>
</div> </div>
<div class="subdetails"> <div class="subdetails">
<span> <span v-if="event.description">{{ event.description }}</span>
<i class="bi bi-clock-fill margin" /> <em v-else class="subtext">No description provided.</em>
{{ formattedTime }} <button class="class-info">
</span>
<span class="class-info">
<i class="tf2class tf2-PocketScout margin" /> <i class="tf2class tf2-PocketScout margin" />
Pocket Scout Accept as Pocket Scout
</span> </button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -35,6 +35,11 @@ const router = createRouter({
name: "roster-builder", name: "roster-builder",
component: RosterBuilderView component: RosterBuilderView
}, },
{
path: "/schedule/roster/event/:eventId",
name: "roster-builder-event",
component: RosterBuilderView
},
{ {
path: "/team/register", path: "/team/register",
name: "team-registration", name: "team-registration",

View File

@ -2,10 +2,16 @@ import { type Player, type PlayerTeamRoleFlat } from "@/player";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { computed, reactive, ref, type Reactive, type Ref } from "vue"; import { computed, reactive, ref, type Reactive, type Ref } from "vue";
import { useClientStore } from "./client"; 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", () => { export const useRosterStore = defineStore("roster", () => {
const clientStore = useClientStore(); const clientStore = useClientStore();
const client = clientStore.client; const client = clientStore.client;
const router = useRouter();
const route = useRoute();
const neededRoles: Reactive<Array<String>> = reactive([ const neededRoles: Reactive<Array<String>> = reactive([
"PocketScout", "PocketScout",
@ -106,22 +112,94 @@ export const useRosterStore = defineStore("roster", () => {
fetchAvailablePlayers.name, fetchAvailablePlayers.name,
() => client.default.viewAvailableAtTime(startTime.toString(), teamId), () => client.default.viewAvailableAtTime(startTime.toString(), teamId),
(response) => { (response) => {
availablePlayers.value = response.players.flatMap((schema) => { availablePlayers.value = response.players
return schema.roles.map((role) => ({ .flatMap((schema) => {
steamId: schema.player.steamId, return schema.roles.map((role) => ({
name: schema.player.username, steamId: schema.player.steamId,
role: role.role, name: schema.player.username,
isMain: role.isMain, role: role.role,
availability: schema.availability, isMain: role.isMain,
playtime: schema.playtime, availability: schema.availability,
})); playtime: schema.playtime,
}); }));
});
return response; 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<EventSchema | undefined>(undefined);
const startTime = ref<number>();
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 { return {
neededRoles, neededRoles,
selectedPlayers, selectedPlayers,
@ -136,5 +214,8 @@ export const useRosterStore = defineStore("roster", () => {
mainRoles, mainRoles,
alternateRoles, alternateRoles,
fetchAvailablePlayers, fetchAvailablePlayers,
fetchPlayersFromEvent,
startTime,
saveRoster,
} }
}); });

View File

@ -6,8 +6,10 @@ 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";
const rosterStore = useRosterStore(); const rosterStore = useRosterStore();
const eventsStore = useEventsStore();
const route = useRoute(); const route = useRoute();
@ -19,8 +21,21 @@ const hasAlternates = computed(() => {
return rosterStore.alternateRoles.length > 0; return rosterStore.alternateRoles.length > 0;
}); });
onMounted(() => { const eventId = computed<number | undefined>(() => Number(route.params.eventId));
rosterStore.fetchAvailablePlayers(route.params.startTime, route.params.teamId);
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));
}
}); });
</script> </script>
@ -29,14 +44,14 @@ onMounted(() => {
<div class="top"> <div class="top">
<h1 class="roster-title"> <h1 class="roster-title">
Roster for Snus Brotherhood Roster for Snus Brotherhood
<em class="aside date"> <em class="aside date" v-if="rosterStore.startTime">
@ @
{{ moment.unix(route.params.startTime).format("L LT") }} {{ moment.unix(rosterStore.startTime).format("L LT") }}
</em> </em>
</h1> </h1>
<div class="button-group"> <div class="button-group">
<button>Cancel</button> <button>Cancel</button>
<button class="accent">Save Roster</button> <button class="accent" @click="saveRoster">Save Roster</button>
</div> </div>
</div> </div>
<div class="columns"> <div class="columns">

View File

@ -23,4 +23,4 @@ def connect_db_with_app():
metadata = MetaData(naming_convention=convention) metadata = MetaData(naming_convention=convention)
app = Flask(__name__) app = Flask(__name__)
db = SQLAlchemy(model_class=BaseModel, metadata=metadata) db = SQLAlchemy(model_class=BaseModel, metadata=metadata)
migrate = Migrate(render_as_batch=True) migrate = Migrate(app, db, render_as_batch=True)

View File

@ -8,12 +8,16 @@
from datetime import datetime from datetime import datetime
from flask import Blueprint, abort from flask import Blueprint, abort, make_response
from spectree import Response from spectree import Response
from models.player_event import PlayerEvent 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_team_availability import PlayerTeamAvailability
from models.player_team_role import PlayerRoleSchema, PlayerTeamRole
from models.team import Team
from spec import BaseModel, spec 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.event import Event, EventSchema
from models.player_team import PlayerTeam from models.player_team import PlayerTeam
from app_db import db from app_db import db
@ -65,17 +69,18 @@ class CreateEventJson(BaseModel):
name: str name: str
description: str description: str
start_time: datetime start_time: datetime
player_ids: list[int] player_roles: list[PlayerRoleSchema]
@api_events.post("/team/id/<int:team_id>") @api_events.post("/team/id/<int:team_id>")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=EventSchema, HTTP_200=EventSchema,
) ),
operation_id="create_event",
) )
@requires_authentication @requires_authentication
@requires_team_membership() @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 = Event()
event.team_id = player_team.team_id event.team_id = player_team.team_id
event.name = json.name event.name = json.name
@ -86,27 +91,149 @@ def create_event(player_team: PlayerTeam, json: CreateEventJson, **_):
db.session.flush() db.session.flush()
db.session.refresh(event) db.session.refresh(event)
players_teams = db.session.query( tuples = map(lambda x: (x.player.steam_id, x.role.role), json.player_roles)
PlayerTeam
results = db.session.query(
PlayerTeam, PlayerTeamRole.id, PlayerTeamAvailability.availability
).join( ).join(
Player PlayerTeamRole
).outerjoin(
PlayerTeamAvailability,
(PlayerTeamAvailability.player_team_id == PlayerTeam.id) &
(PlayerTeamAvailability.start_time <= event.start_time) &
(PlayerTeamAvailability.end_time > event.start_time)
).where( ).where(
PlayerTeam.team_id == player_team.team_id PlayerTeam.team_id == team_id
).where( ).where(
PlayerTeam.player_id.in_(json.player_ids) # (player_id, role) in (...)
tuple_(PlayerTeam.player_id, PlayerTeamRole.role).in_(tuples)
).all() ).all()
for player_team in players_teams: for player_team, role_id, availability in map(lambda x: x.tuple(), results):
player = player_team.player
player_event = PlayerEvent() 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.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.add(player_event)
db.session.commit() db.session.commit()
event.update_discord_message()
return EventSchema.from_model(event).dict(by_alias=True), 200 return EventSchema.from_model(event).dict(by_alias=True), 200
@api_events.put("/<int:event_id>/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("/<int:event_id>/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("/<int:event_id>/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("/<int:event_id>/players") @api_events.patch("/<int:event_id>/players")
@requires_authentication @requires_authentication
@requires_team_membership() @requires_team_membership()

View File

@ -6,6 +6,7 @@ from app_db import db
from models.auth_session import AuthSession from models.auth_session import AuthSession
from models.player import Player from models.player import Player
from models.player_team import PlayerTeam from models.player_team import PlayerTeam
from models.team import Team
def requires_authentication(f): def requires_authentication(f):
@ -72,6 +73,20 @@ def requires_team_membership(
return decorator return decorator
return wrapper 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( def assert_team_authority(
player_team: PlayerTeam, player_team: PlayerTeam,
target_player_team: PlayerTeam | None = None, target_player_team: PlayerTeam | None = None,

View File

@ -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 ###

View File

@ -3,9 +3,10 @@ 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
from sqlalchemy.schema import UniqueConstraint 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.sql import func
from sqlalchemy_utc import UtcDateTime from sqlalchemy_utc import UtcDateTime
from discord_webhook import DiscordWebhook
import app_db import app_db
import spec import spec
@ -23,14 +24,81 @@ class Event(app_db.BaseModel):
description: Mapped[str] = mapped_column(Text, nullable=True) description: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now()) 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") 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__ = ( __table_args__ = (
UniqueConstraint("team_id", "name", "start_time"), 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"<t:{start_timestamp}: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): class EventSchema(spec.BaseModel):
id: int id: int
team_id: int team_id: int
@ -50,6 +118,17 @@ class EventSchema(spec.BaseModel):
created_at=model.created_at, 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.team import Team
from models.player_event import PlayerEvent from models.player_event import PlayerEvent
from models.team_integration import TeamDiscordIntegration

View File

@ -1,8 +1,10 @@
from typing import Optional
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
from sqlalchemy.types import Boolean from sqlalchemy.types import Boolean
import app_db import app_db
import spec
class PlayerEvent(app_db.BaseModel): class PlayerEvent(app_db.BaseModel):
@ -15,9 +17,34 @@ class PlayerEvent(app_db.BaseModel):
event: Mapped["Event"] = relationship("Event", back_populates="players") event: Mapped["Event"] = relationship("Event", back_populates="players")
player: Mapped["Player"] = relationship("Player", back_populates="events") 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") 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.event import Event
from models.player import Player from models.player import Player, PlayerSchema
from models.player_team_role import PlayerTeamRole from models.player_team_role import PlayerTeamRole, RoleSchema
from models.player_team import PlayerTeam

View File

@ -53,7 +53,6 @@ class PlayerTeamRole(app_db.BaseModel):
UniqueConstraint("player_team_id", "role"), UniqueConstraint("player_team_id", "role"),
) )
class RoleSchema(spec.BaseModel): class RoleSchema(spec.BaseModel):
role: str role: str
is_main: bool is_main: bool
@ -62,5 +61,10 @@ class RoleSchema(spec.BaseModel):
def from_model(cls, role: PlayerTeamRole): def from_model(cls, role: PlayerTeamRole):
return cls(role=role.role.name, is_main=role.is_main) 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_team import PlayerTeam
from models.player import PlayerSchema

View File

@ -20,3 +20,5 @@ Flask-Migrate
requests requests
pytz # timezone handling pytz # timezone handling
discord-webhook # for sending messages to Discord webhooks

View File

@ -1,11 +1,13 @@
import datetime import datetime
from typing import cast from typing import Optional, cast
from flask import Blueprint, abort, jsonify, make_response, request from flask import Blueprint, abort, jsonify, make_response, request
from spectree import Response from spectree import Response
from sqlalchemy import Row
from sqlalchemy.orm import contains_eager, joinedload from sqlalchemy.orm import contains_eager, joinedload
from sqlalchemy.sql import and_, select from sqlalchemy.sql import and_, select
from app_db import db from app_db import db
from models.player import Player, PlayerSchema from models.player import Player, PlayerSchema
from models.player_event import PlayerEvent, PlayerEventRolesSchema
from models.player_team import PlayerTeam from models.player_team import PlayerTeam
from models.player_team_availability import AvailabilitySchema, PlayerTeamAvailability, PlayerTeamAvailabilityRoleSchema from models.player_team_availability import AvailabilitySchema, PlayerTeamAvailability, PlayerTeamAvailabilityRoleSchema
from models.player_team_role import PlayerTeamRole, RoleSchema from models.player_team_role import PlayerTeamRole, RoleSchema

View File

@ -397,22 +397,47 @@ def edit_member_roles(
if not target_player: if not target_player:
abort(401) 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: for role in target_player.player_roles:
# delete role if not found in json # delete role if not found in json
f = filter(lambda x: x.role == role.role.name, json.roles) f = filter(lambda x: x.role == role.role.name, json.roles)
matched_role = next(f, None) 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) db.session.delete(role)
for schema in json.roles: for schema in json.roles:
role = PlayerTeamRole() # insert if not found in target
role.player_team = target_player f = filter(lambda x: x.role.name == schema.role, target_player.player_roles)
role.role = PlayerTeamRole.Role[schema.role]
role.is_main = schema.is_main if not next(f, None):
db.session.merge(role) 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() db.session.commit()