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-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 */

View File

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

View File

@ -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<number>;
playerRoles: Array<PlayerRoleSchema>;
startTime: string;
};

View File

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

View File

@ -4,7 +4,7 @@
/* eslint-disable */
export type EventSchema = {
createdAt: string;
description?: string;
description: (string | null);
id: number;
name: 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 { TeamLogsTfIntegrationSchema } from './TeamLogsTfIntegrationSchema';
export type TeamIntegrationSchema = {
discordIntegration?: TeamDiscordIntegrationSchema;
logsTfIntegration?: TeamLogsTfIntegrationSchema;
discordIntegration: (TeamDiscordIntegrationSchema | null);
logsTfIntegration: (TeamLogsTfIntegrationSchema | null);
};

View File

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

View File

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

View File

@ -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<EventSchema> {
@ -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 <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>
* @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 <PUT>
* update_integrations <PUT>
* @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`,
},
});
}

View File

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

View File

@ -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",

View File

@ -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<Array<String>> = 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<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 {
neededRoles,
selectedPlayers,
@ -136,5 +214,8 @@ export const useRosterStore = defineStore("roster", () => {
mainRoles,
alternateRoles,
fetchAvailablePlayers,
fetchPlayersFromEvent,
startTime,
saveRoster,
}
});

View File

@ -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 | undefined>(() => 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));
}
});
</script>
@ -29,14 +44,14 @@ onMounted(() => {
<div class="top">
<h1 class="roster-title">
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>
</h1>
<div class="button-group">
<button>Cancel</button>
<button class="accent">Save Roster</button>
<button class="accent" @click="saveRoster">Save Roster</button>
</div>
</div>
<div class="columns">

View File

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

View File

@ -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/<int: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("/<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")
@requires_authentication
@requires_team_membership()

View File

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

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.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"<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):
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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()