Compare commits

...

5 Commits

Author SHA1 Message Date
John Montagu, the 4th Earl of Sandvich 45ac071a7f
Improve readability 2024-12-10 10:24:50 -08:00
John Montagu, the 4th Earl of Sandvich a509c797ff
feat(backend): Implement submitting matches 2024-12-10 10:22:32 -08:00
John Montagu, the 4th Earl of Sandvich aaa4d40ed9
feat(backend): Add Celery + redis 2024-12-09 17:04:40 -08:00
John Montagu, the 4th Earl of Sandvich b620470739
feat: Improve site user experience
- Added changes for better mobile responsive UI
- AvailabilityGrid shows players available at the selected time
2024-12-08 12:10:42 -08:00
John Montagu, the 4th Earl of Sandvich 36bc19c96d
feat: Add consume invite dialog 2024-12-08 12:10:23 -08:00
47 changed files with 1320 additions and 107 deletions

View File

@ -76,6 +76,17 @@ i.bi.margin {
margin-right: 0.5em; margin-right: 0.5em;
} }
button.checkbox, button.checkbox.icon {
outline: 1px solid var(--overlay-0);
font-size: 1rem;
height: 24px;
width: 24px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
button:hover { button:hover {
background-color: var(--surface-0); background-color: var(--surface-0);
} }
@ -206,7 +217,7 @@ main {
padding: 2rem; padding: 2rem;
} }
input { input, textarea {
display: block; display: block;
width: 100%; width: 100%;
color: var(--text); color: var(--text);
@ -233,6 +244,10 @@ input {
sans-serif; sans-serif;
} }
textarea {
resize: vertical;
}
.form-group { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -307,6 +322,33 @@ hr {
width: 100%; width: 100%;
} }
.dialog-overlay {
background-color: #00000055;
z-index: 1;
position: fixed;
inset: 0;
animation: smooth-appear 0.2s ease;
}
[role="dialog"] {
position: fixed;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
background-color: var(--base);
z-index: 10;
animation: smooth-appear 0.2s ease;
}
@keyframes smooth-appear {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
div.banner { div.banner {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 10pt; font-size: 10pt;

View File

@ -13,6 +13,7 @@ export type { OpenAPIConfig } from './core/OpenAPI';
export type { AddPlayerJson } from './models/AddPlayerJson'; export type { AddPlayerJson } from './models/AddPlayerJson';
export type { AttendanceJson } from './models/AttendanceJson'; export type { AttendanceJson } from './models/AttendanceJson';
export type { AvailabilitySchema } from './models/AvailabilitySchema'; export type { AvailabilitySchema } from './models/AvailabilitySchema';
export type { ConsumeInviteResponse } from './models/ConsumeInviteResponse';
export type { CreateEventJson } from './models/CreateEventJson'; export type { CreateEventJson } from './models/CreateEventJson';
export type { CreateTeamJson } from './models/CreateTeamJson'; export type { CreateTeamJson } from './models/CreateTeamJson';
export type { EditMemberRolesJson } from './models/EditMemberRolesJson'; export type { EditMemberRolesJson } from './models/EditMemberRolesJson';

View File

@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ConsumeInviteResponse = {
teamId: number;
};

View File

@ -4,6 +4,7 @@
/* eslint-disable */ /* eslint-disable */
import type { AddPlayerJson } from '../models/AddPlayerJson'; import type { AddPlayerJson } from '../models/AddPlayerJson';
import type { AttendanceJson } from '../models/AttendanceJson'; import type { AttendanceJson } from '../models/AttendanceJson';
import type { ConsumeInviteResponse } from '../models/ConsumeInviteResponse';
import type { CreateEventJson } from '../models/CreateEventJson'; import type { CreateEventJson } from '../models/CreateEventJson';
import type { CreateTeamJson } from '../models/CreateTeamJson'; import type { CreateTeamJson } from '../models/CreateTeamJson';
import type { EditMemberRolesJson } from '../models/EditMemberRolesJson'; import type { EditMemberRolesJson } from '../models/EditMemberRolesJson';
@ -415,6 +416,27 @@ export class DefaultService {
}, },
}); });
} }
/**
* consume_invite <POST>
* @param key
* @returns ConsumeInviteResponse OK
* @throws ApiError
*/
public consumeInvite(
key: string,
): CancelablePromise<ConsumeInviteResponse> {
return this.httpRequest.request({
method: 'POST',
url: '/api/team/consume-invite/{key}',
path: {
'key': key,
},
errors: {
404: `Not Found`,
422: `Unprocessable Content`,
},
});
}
/** /**
* delete_team <DELETE> * delete_team <DELETE>
* @param teamId * @param teamId
@ -483,30 +505,6 @@ export class DefaultService {
}, },
}); });
} }
/**
* consume_invite <POST>
* @param teamId
* @param key
* @returns void
* @throws ApiError
*/
public consumeInvite(
teamId: string,
key: string,
): CancelablePromise<void> {
return this.httpRequest.request({
method: 'POST',
url: '/api/team/id/{team_id}/consume-invite/{key}',
path: {
'team_id': teamId,
'key': key,
},
errors: {
404: `Not Found`,
422: `Unprocessable Content`,
},
});
}
/** /**
* edit_member_roles <PATCH> * edit_member_roles <PATCH>
* @param teamId * @param teamId

View File

@ -9,6 +9,8 @@ const model = defineModel();
const selectedTime = defineModel("selectedTime"); const selectedTime = defineModel("selectedTime");
const selectedIndex = defineModel("selectedIndex");
const hoveredIndex = defineModel("hoveredIndex"); const hoveredIndex = defineModel("hoveredIndex");
const props = defineProps({ const props = defineProps({
@ -156,6 +158,7 @@ function onSlotClick(dayIndex, hour) {
} }
selectedTime.value = getTimeAtCell(dayIndex, hour); selectedTime.value = getTimeAtCell(dayIndex, hour);
scheduleStore.selectIndex(24 * dayIndex + hour);
} }
function onKeyUp($event) { function onKeyUp($event) {

View File

@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { EventWithPlayerSchema } from "@/client"; import type { EventWithPlayerSchema, TeamSchema } from "@/client";
import EventCard from "./EventCard.vue"; import EventCard from "./EventCard.vue";
const props = defineProps<{ const props = defineProps<{
events: EventWithPlayerSchema[]; events: EventWithPlayerSchema[];
teamContext: TeamSchema;
}>(); }>();
</script> </script>
@ -12,7 +13,20 @@ const props = defineProps<{
<EventCard v-for="event in props.events" :key="event.event.id" :event="event" /> <EventCard v-for="event in props.events" :key="event.event.id" :event="event" />
</div> </div>
<div class="events-list" v-else> <div class="events-list" v-else>
<em class="subtext">No upcoming events.</em> <em class="subtext">
No upcoming events. Create one in the
<router-link
:to="{
name: 'schedule',
query: {
teamId: props.teamContext.id
}
}"
>
schedule
</router-link>
page.
</em>
</div> </div>
</template> </template>

View File

@ -1,16 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
import { useEventForm } from "@/composables/event-form"; import { useEventForm } from "@/composables/event-form";
import { useTeamDetails } from "@/composables/team-details";
import { useRosterStore } from "@/stores/roster"; import { useRosterStore } from "@/stores/roster";
import { useTeamsStore } from "@/stores/teams";
import moment from "moment"; import moment from "moment";
import { computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const rosterStore = useRosterStore(); const rosterStore = useRosterStore();
const teamsStore = useTeamsStore();
const { eventId } = useEventForm(); const { eventId } = useEventForm();
const { team, teamId } = useTeamDetails();
const startTime = computed(() => {
if (rosterStore.startTime) {
return moment.unix(rosterStore.startTime).format("LL LT z");
}
});
const startTimeTeamTz = computed(() => {
if (rosterStore.startTime) {
// if team timezone is the same as ours, then do nothing
if (team.value?.tzTimezone === moment.tz.guess()) {
return undefined;
}
return moment.unix(rosterStore.startTime)
.tz(team.value?.tzTimezone)
.format("LL LT z");
}
});
function saveRoster() { function saveRoster() {
if (eventId.value) { if (eventId.value) {
rosterStore.updateRoster(eventId.value); rosterStore.updateRoster(eventId.value);
@ -26,16 +50,26 @@ function saveRoster() {
}); });
} }
} }
onMounted(() => {
if (!team.value) {
teamsStore.fetchTeam(teamId.value);
}
});
</script> </script>
<template> <template>
<div class="event-scheduler-container"> <div class="event-scheduler-container">
<h1 class="roster-title"> <h1 class="roster-title">
Roster for Snus Brotherhood Roster for {{ team?.teamName }}
</h1> </h1>
<div v-if="rosterStore.startTime"> <div v-if="rosterStore.startTime">
<span class="aside date"> <span class="aside date">
{{ moment.unix(rosterStore.startTime).format("LL LT") }} {{ startTime }}
</span>
<br v-if="startTimeTeamTz">
<span class="aside date">
{{ startTimeTeamTz }}
</span> </span>
</div> </div>
<div class="form-group margin"> <div class="form-group margin">

View File

@ -13,7 +13,7 @@ onMounted(() => {
<template> <template>
<div class="commit-history"> <div class="commit-history">
<div class="header"> <div class="header">
<h2>Commit History</h2> <h2>Changelog/Commit History</h2>
<a <a
class="icon" class="icon"
href="https://github.com/HumanoidSandvichDispenser/availabili.tf/commits" href="https://github.com/HumanoidSandvichDispenser/availabili.tf/commits"

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
import { useInvitesStore } from "@/stores/teams/invites";
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger } from "radix-vue";
import { ref } from "vue";
import { useRouter } from "vue-router";
const invitesStore = useInvitesStore();
const key = ref("");
const router = useRouter();
function submit() {
invitesStore.consumeInvite(key.value)
.then((response) => {
console.log(response);
router.push({
name: "team-details",
params: {
id: response.teamId,
}
});
});
}
</script>
<template>
<DialogRoot>
<DialogTrigger>
<i class="bi bi-person-plus-fill margin" />
Join a team
</DialogTrigger>
<DialogPortal>
<DialogOverlay class="dialog-overlay" />
<DialogContent>
<DialogTitle>Join a team</DialogTitle>
<DialogDescription>
<p>
Enter the invite key to join a team. Don't have an invite key? Ask
your team leader to send you one.
</p>
</DialogDescription>
<div class="form-group margin">
<h3>Invite key</h3>
<input type="text" placeholder="Invite key or URL" v-model="key" />
</div>
<div class="form-group">
<div class="action-buttons">
<DialogClose class="accent" aria-label="Close" @click="submit">
<i class="bi bi-check" />
Join
</DialogClose>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
[role="dialog"] {
padding: 2rem;
border-radius: 0.5rem;
}
</style>

View File

@ -28,7 +28,7 @@ function disableIntegration() {
<template> <template>
<h2>logs.tf Auto-Tracking</h2> <h2>logs.tf Auto-Tracking</h2>
<p>Automatically fetch and track match history from logs.tf.</p> <p>Automatically fetch and track match history from logs.tf. (CURRENTLY NOT IMPLEMENTED)</p>
<div v-if="model"> <div v-if="model">
<div class="form-group margin"> <div class="form-group margin">
<h3>logs.tf API key (optional)</h3> <h3>logs.tf API key (optional)</h3>

View File

@ -76,13 +76,15 @@ const isUnavailable = computed(() => {
}); });
const nextHour = computed(() => { const nextHour = computed(() => {
const now = moment().utc(); const now = moment();
const time = now.clone().tz(props.team.tzTimezone); const time = now.clone().tz(props.team.tzTimezone);
if (time.minute() >= props.team.minuteOffset) { let minute = time.minute();
let minuteOffset = props.team.minuteOffset;
if (minute >= minuteOffset) {
time.add(1, "hour"); time.add(1, "hour");
time.minute(props.team.minuteOffset);
} }
time.minute(minuteOffset);
const diff = time.utc().diff(now, "minutes", false); const diff = time.utc().diff(now, "minutes", false);
@ -215,7 +217,7 @@ const rightIndicator = computed(() => {
} }
.player-card > td { .player-card > td {
padding: 1em 2em; padding: 0.5em 1em;
} }
.player-card h3 { .player-card h3 {
@ -314,4 +316,10 @@ a.player-name:hover {
.edit-group > button.editing { .edit-group > button.editing {
opacity: 1; opacity: 1;
} }
@media (max-width: 1024px) {
.player-card > td {
padding: 0.5em 0em;
}
}
</style> </style>

View File

@ -33,10 +33,10 @@ function logout() {
</RouterLink> </RouterLink>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>
<RouterLink class="button" to="/teams"> <RouterLink class="button" to="/schedule">
<button> <button>
<i class="bi bi-people margin" /> <i class="bi bi-calendar-fill margin" />
Teams Schedule
</button> </button>
</RouterLink> </RouterLink>
</DropdownMenuItem> </DropdownMenuItem>

View File

@ -15,11 +15,20 @@ const isTeamTzLocal = computed(() => {
return selectedTimeTz.value.utcOffset() == props.selectedTime.utcOffset(); return selectedTimeTz.value.utcOffset() == props.selectedTime.utcOffset();
}); });
const props = defineProps({ //const props = defineProps({
selectedTime: Object // selectedTime: Object
}); //});
const props = defineProps<{
selectedTime?: moment.Moment;
selectedIndex?: number;
}>();
function scheduleRoster() { function scheduleRoster() {
if (!props.selectedTime) {
return;
}
router.push({ router.push({
name: "roster-builder", name: "roster-builder",
params: { params: {
@ -39,19 +48,20 @@ function scheduleRoster() {
:player="record" :player="record"
/> />
</div> </div>
<h4> <h4 v-if="selectedTime">
<template v-if="selectedTime">
<div> <div>
{{ selectedTime.format("L LT z") }} {{ selectedTime.format("L LT z") }}
</div> </div>
<div v-if="!isTeamTzLocal"> <div v-if="!isTeamTzLocal">
{{ selectedTimeTz.format("L LT z") }} {{ selectedTimeTz.format("L LT z") }}
</div> </div>
</template>
</h4> </h4>
<button @click="scheduleRoster" v-if="selectedTime"> <button @click="scheduleRoster" v-if="selectedTime">
Schedule for {{ selectedTime.format("L LT") }} Schedule for {{ selectedTime.format("L LT") }}
</button> </button>
<div v-else class="subtext">
<em>Select a time to schedule</em>
</div>
</div> </div>
</template> </template>

View File

@ -2,22 +2,41 @@
import { useScheduleStore } from "../stores/schedule"; import { useScheduleStore } from "../stores/schedule";
import { computed, type PropType } from "vue"; import { computed, type PropType } from "vue";
import { type AvailabilitySchema } from "@/client"; import { type AvailabilitySchema } from "@/client";
import { CheckboxIndicator, CheckboxRoot } from "radix-vue";
const scheduleStore = useScheduleStore(); const scheduleStore = useScheduleStore();
const hoveredIndex = computed(() => scheduleStore.hoveredIndex); const hoveredIndex = computed(() => scheduleStore.hoveredIndex);
const selectedIndex = computed(() => scheduleStore.selectedIndex);
const availabilityAtHoveredIndex = computed(() => { const availabilityAtHoveredIndex = computed(() => {
if (hoveredIndex.value && props.player?.availability) { if (props.player?.availability) {
if (hoveredIndex.value) {
return props.player.availability[hoveredIndex.value] ?? 0; return props.player.availability[hoveredIndex.value] ?? 0;
} }
if (scheduleStore.selectedIndexAvailability[props.player.steamId] != undefined) {
return scheduleStore.selectedIndexAvailability[props.player.steamId] ?? 0;
}
}
return undefined; return undefined;
}); });
const props = defineProps({ const selectedMember = computed({
player: Object as PropType<AvailabilitySchema>, get: () => scheduleStore.selectedMembers[props.player.steamId] ?? false,
set: (value: boolean) => {
console.log("set", value);
scheduleStore.selectedMembers[props.player.steamId] = value;
},
}); });
const props = defineProps<{
player: AvailabilitySchema;
}>();
//const props = defineProps({
// player: Object as PropType<AvailabilitySchema>,
//});
function onMouseOver() { function onMouseOver() {
if (props.player) { if (props.player) {
scheduleStore.hoveredMember = props.player; scheduleStore.hoveredMember = props.player;
@ -38,16 +57,25 @@ function onMouseLeave() {
@mouseover="onMouseOver" @mouseover="onMouseOver"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
> >
<input <!--input
class="player-checkbox" class="player-checkbox"
type="checkbox" type="checkbox"
v-model="scheduleStore.selectedMembers[player.steamId]" v-model="scheduleStore.selectedMembers[player.steamId]"
:value="player" :value="player"
:id="player.steamId" :id="player.steamId"
/> /-->
<label <label
:for="player.steamId" :for="player.steamId"
> >
<!-- checkbox for pinning players -->
<CheckboxRoot
v-model:checked="selectedMember"
class="checkbox icon"
>
<CheckboxIndicator>
<i class="bi bi-pin-fill"></i>
</CheckboxIndicator>
</CheckboxRoot>
<span v-if="availabilityAtHoveredIndex ?? 0 > 0"> <span v-if="availabilityAtHoveredIndex ?? 0 > 0">
<span v-if="availabilityAtHoveredIndex == 1" class="can-be-available"> <span v-if="availabilityAtHoveredIndex == 1" class="can-be-available">
{{ player.username }} {{ player.username }}
@ -98,4 +126,15 @@ input {
.player s { .player s {
color: var(--overlay-0); color: var(--overlay-0);
} }
label {
display: flex;
align-items: center;
gap: 0.5rem;
}
button.player-checkbox.icon i.bi {
/* dont take up space */
position: relative;
}
</style> </style>

View File

@ -2,9 +2,13 @@
import { onMounted } from "vue"; import { onMounted } from "vue";
import { useTeamsStore } from "../stores/teams"; import { useTeamsStore } from "../stores/teams";
import { RouterLink } from "vue-router"; import { RouterLink } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import InviteKeyDialog from "./InviteKeyDialog.vue";
const teams = useTeamsStore(); const teams = useTeamsStore();
const authStore = useAuthStore();
onMounted(() => { onMounted(() => {
teams.fetchTeams(); teams.fetchTeams();
}); });
@ -17,11 +21,8 @@ onMounted(() => {
<i class="bi bi-people-fill margin"></i> <i class="bi bi-people-fill margin"></i>
Your Teams Your Teams
</h2> </h2>
<div class="button-group"> <div class="button-group" v-if="authStore.isLoggedIn">
<button class="small"> <InviteKeyDialog />
<i class="bi bi-person-plus-fill margin" />
Join a team
</button>
<RouterLink class="button" to="/team/register"> <RouterLink class="button" to="/team/register">
<button class="small accent"> <button class="small accent">
<i class="bi bi-plus-circle-fill margin"></i> <i class="bi bi-plus-circle-fill margin"></i>
@ -30,6 +31,9 @@ onMounted(() => {
</RouterLink> </RouterLink>
</div> </div>
</div> </div>
<div v-if="!authStore.isLoggedIn">
Log in to view your teams.
</div>
<div <div
v-if="teams.teamsWithRole" v-if="teams.teamsWithRole"
v-for="(team, _, i) in teams.teamsWithRole" v-for="(team, _, i) in teams.teamsWithRole"

View File

@ -8,7 +8,7 @@ export function useTeamDetails() {
const teamsStore = useTeamsStore(); const teamsStore = useTeamsStore();
const invitesStore = useInvitesStore(); const invitesStore = useInvitesStore();
const teamId = computed(() => Number(route.params.id)); const teamId = computed(() => Number(route.params.id ?? route.params.teamId));
const team = computed(() => { const team = computed(() => {
return teamsStore.teams[teamId.value]; return teamsStore.teams[teamId.value];

View File

@ -60,7 +60,16 @@ export const useScheduleStore = defineStore("schedule", () => {
const selectedMembers = reactive<{ [id: string]: boolean }>({ }); const selectedMembers = reactive<{ [id: string]: boolean }>({ });
const hoveredIndex: Ref<number | undefined> = ref(); const hoveredIndex = ref<number | undefined>();
const selectedIndexAvailability = ref<{ [id: string]: number; }>({ });
function selectIndex(index: number) {
playerAvailability.value.forEach((value) => {
if (value.availability) {
selectedIndexAvailability.value[value.steamId] = value.availability[index] ?? 0;
}
});
}
const team = ref(); const team = ref();
@ -83,10 +92,12 @@ export const useScheduleStore = defineStore("schedule", () => {
} }
watch(dateStart, () => { watch(dateStart, () => {
selectedIndexAvailability.value = { };
fetchTeamSchedule(); fetchTeamSchedule();
}); });
watch(team, () => { watch(team, () => {
selectedIndexAvailability.value = { };
dateStart.value = getWindowStart(team.value); dateStart.value = getWindowStart(team.value);
console.log(dateStart.value); console.log(dateStart.value);
}); });
@ -145,6 +156,8 @@ export const useScheduleStore = defineStore("schedule", () => {
hoveredMember, hoveredMember,
selectedMembers, selectedMembers,
hoveredIndex, hoveredIndex,
selectedIndexAvailability,
selectIndex,
fetchSchedule, fetchSchedule,
fetchTeamSchedule, fetchTeamSchedule,
saveSchedule, saveSchedule,

View File

@ -26,9 +26,13 @@ export const useInvitesStore = defineStore("invites", () => {
return response; return response;
} }
async function consumeInvite(teamId: number, key: string) { async function consumeInvite(key: string) {
const response = await client.default.consumeInvite(teamId.toString(), key); const response = await client.default.consumeInvite(key);
teamInvites[teamId] = teamInvites[teamId].filter((invite) => invite.key != key); const teamId = response.teamId;
if (teamInvites[teamId]) {
teamInvites[teamId] = teamInvites[teamId]
.filter((invite) => invite.key != key);
}
return response; return response;
} }

View File

@ -2,7 +2,7 @@
import PlayerCard from "../components/PlayerCard.vue"; import PlayerCard from "../components/PlayerCard.vue";
import { computed, reactive, onMounted, ref } from "vue"; import { computed, reactive, onMounted, ref } from "vue";
import { useRosterStore } from "../stores/roster"; import { useRosterStore } from "../stores/roster";
import { useRoute } from "vue-router"; import { RouterLink, useRoute } from "vue-router";
import moment from "moment"; import moment from "moment";
import { useEventsStore } from "@/stores/events"; import { useEventsStore } from "@/stores/events";
import EventSchedulerForm from "@/components/EventSchedulerForm.vue"; import EventSchedulerForm from "@/components/EventSchedulerForm.vue";
@ -60,10 +60,6 @@ onMounted(async () => {
</main> </main>
<main v-else> <main v-else>
<div class="top"> <div class="top">
<a>
<i class="bi bi-arrow-left" />
Back
</a>
</div> </div>
<div class="columns"> <div class="columns">
<div class="form-group margin column"> <div class="form-group margin column">

View File

@ -27,6 +27,7 @@ const availability = schedule.availability;
const selectionMode = ref(1); const selectionMode = ref(1);
const selectedTime = ref(undefined); const selectedTime = ref(undefined);
const selectedIndex = ref(undefined);
const availabilityOverlay = computed(() => schedule.overlay); const availabilityOverlay = computed(() => schedule.overlay);
@ -137,6 +138,12 @@ onMounted(() => {
</button> </button>
</template> </template>
</div> </div>
<div class="button-group" v-if="isEditing">
<span class="subtext">
CTRL + Click to fill entire visible column,
Shift + Click to fill entire row
</span>
</div>
</div> </div>
</div> </div>
<div v-else> <div v-else>

View File

@ -36,7 +36,7 @@ onMounted(() => {
}; };
if (key.value) { if (key.value) {
invitesStore.consumeInvite(teamId.value, key.value.toString()) invitesStore.consumeInvite(key.value.toString())
.finally(doFetchTeam); .finally(doFetchTeam);
} else { } else {
doFetchTeam(); doFetchTeam();
@ -73,7 +73,7 @@ onMounted(() => {
</div> </div>
<div class="right"> <div class="right">
<h2>Upcoming Events</h2> <h2>Upcoming Events</h2>
<EventList :events="events" /> <EventList :events="events" :team-context="team" />
<h2 id="recent-matches-header"> <h2 id="recent-matches-header">
Recent Matches Recent Matches
<RouterLink class="button" to="/"> <RouterLink class="button" to="/">
@ -82,7 +82,7 @@ onMounted(() => {
</button> </button>
</RouterLink> </RouterLink>
</h2> </h2>
<em class="subtext" v-if="false">No recent matches.</em> <em class="subtext" v-if="true">No recent matches.</em>
<MatchCard v-else /> <MatchCard v-else />
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
# Use an official Python runtime as a parent image # Use an official Python runtime as a parent image
FROM python:3.11-slim FROM python:3.12-slim
COPY requirements.txt / COPY requirements.txt /

View File

@ -1,14 +1,16 @@
from flask import Blueprint, make_response, request from flask import Blueprint, make_response, request
from app_db import app, connect_db_with_app from app_db import app, connect_celery_with_app, connect_db_with_app
import login import login
import schedule import schedule
import team import team
from spec import spec from spec import spec
import user import user
import events import events
import match
connect_db_with_app() connect_db_with_app()
connect_celery_with_app()
api = Blueprint("api", __name__, url_prefix="/api") api = Blueprint("api", __name__, url_prefix="/api")
api.register_blueprint(login.api_login) api.register_blueprint(login.api_login)
@ -16,6 +18,7 @@ api.register_blueprint(schedule.api_schedule)
api.register_blueprint(team.api_team) api.register_blueprint(team.api_team)
api.register_blueprint(user.api_user) api.register_blueprint(user.api_user)
api.register_blueprint(events.api_events) api.register_blueprint(events.api_events)
api.register_blueprint(match.api_match)
@api.get("/debug/set-cookie") @api.get("/debug/set-cookie")
@api.post("/debug/set-cookie") @api.post("/debug/set-cookie")

View File

@ -1,3 +1,4 @@
from os import environ
from flask import Flask from flask import Flask
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
@ -15,12 +16,46 @@ convention = {
"pk": "pk_%(table_name)s" "pk": "pk_%(table_name)s"
} }
def connect_db_with_app(): def connect_db_with_app(database_uri = "sqlite:///db.sqlite3", include_migrate=True):
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///db.sqlite3" print("Connecting to database: " + database_uri)
app.config["SQLALCHEMY_DATABASE_URI"] = database_uri
db.init_app(app) db.init_app(app)
if include_migrate:
migrate.init_app(app, db) migrate.init_app(app, db)
with app.app_context():
print("Running dialect: " + db.engine.dialect.name)
import models.match
import models.team_match
import models.player_match
def connect_celery_with_app():
def celery_init_app(app):
from celery import Celery, Task
class FlaskTask(Task):
def __call__(self, *args: object, **kwargs: object) -> object:
with app.app_context():
return self.run(*args, **kwargs)
celery_app = Celery(app.name, task_cls=FlaskTask, broker=app.config["CELERY"]["broker_url"])
celery_app.config_from_object(app.config["CELERY"])
celery_app.set_default()
app.extensions["celery"] = celery_app
return celery_app
app.config.from_mapping(
CELERY=dict(
broker_url=environ.get("CELERY_BROKER_URL", "redis://redis:6379/0"),
result_backend=environ.get("CELERY_RESULT_BACKEND", "redis://redis:6379/0"),
task_ignore_result=True,
)
)
app.config.from_prefixed_env()
celery_init_app(app)
def create_app() -> Flask:
return Flask(__name__)
metadata = MetaData(naming_convention=convention) metadata = MetaData(naming_convention=convention)
app = Flask(__name__) app = create_app()
db = SQLAlchemy(model_class=BaseModel, metadata=metadata) db = SQLAlchemy(model_class=BaseModel, metadata=metadata)
migrate = Migrate(app, db, render_as_batch=True) migrate = Migrate(app, db, render_as_batch=True)

View File

@ -0,0 +1,53 @@
from models.player import Player
from models.team import Team
from models.player_team import PlayerTeam
from models.team_integration import TeamDiscordIntegration, TeamLogsTfIntegration
from flask_testing import TestCase
from app_db import app, db, connect_db_with_app
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
connect_db_with_app(SQLALCHEMY_DATABASE_URI, False)
class BaseTestCase(TestCase):
TESTING = True
def create_app(self):
return app
def setUp(self):
db.create_all()
self.populate_db()
return app
def tearDown(self):
from app_db import db
db.session.remove()
db.drop_all()
def populate_db(self):
print(list(map(lambda x: x.username, db.session.query(Player).all())))
player = Player(steam_id=76561198248436608, username="pyro from csgo")
team = Team(team_name="Team Pepeja", tz_timezone="America/New_York", minute_offset=30)
db.session.add(player)
db.session.add(team)
db.session.flush()
player_team = PlayerTeam(
player_id=player.steam_id,
team_id=team.id,
team_role=PlayerTeam.TeamRole.Player,
is_team_leader=True,
)
logs_tf_integration = TeamLogsTfIntegration(
team_id=team.id,
min_team_member_count=2,
)
db.session.add(player_team)
db.session.add(logs_tf_integration)
db.session.commit()

View File

@ -0,0 +1,227 @@
from collections.abc import Generator
from datetime import timedelta, datetime
from time import sleep
import requests
from sqlalchemy.sql import func, update
from sqlalchemy.types import DATETIME, Interval
import app_db
import models.match
from models.match import Match
from models.team_match import TeamMatch
from models.player import Player
from models.player_match import PlayerMatch
from models.player_team import PlayerTeam
from models.team_integration import TeamLogsTfIntegration
from celery import shared_task
FETCH_URL = "https://logs.tf/api/v1/log/{}"
SEARCH_URL = "https://logs.tf/api/v1/log?limit=25?offset={}"
def get_log_ids(last_log_id: int):
current: int = 2147483647
while current > last_log_id:
response = requests.get(SEARCH_URL.format(current))
for summary in response.json()["logs"]:
id: int = summary["id"]
if id == last_log_id:
break
# yield models.match.RawLogSummary.from_response(summary)
yield id
current = id
def extract(log_id: int) -> models.match.RawLogDetails:
response = requests.get(FETCH_URL.format(log_id))
return response.json()
def steam3_to_steam64(steam3_id: str) -> int:
if steam3_id.startswith("[U:1:") and steam3_id.endswith("]"):
numeric_id = int(steam3_id[5:-1])
steam64_id = numeric_id + 76561197960265728
return steam64_id
else:
raise ValueError("Invalid Steam3 ID format")
def steam64_to_steam3(steam64_id: int) -> str:
if steam64_id >= 76561197960265728:
numeric_id = steam64_id - 76561197960265728
steam3_id = f"[U:1:{numeric_id}]"
return steam3_id
else:
raise ValueError("Invalid Steam64 ID format")
def extract_steam_ids(players: dict[str, models.match.LogPlayer]):
blue_steam_ids: list[int] = []
red_steam_ids: list[int] = []
steam_ids: list[int] = []
for steam_id, player in players.items():
steam64_id = steam3_to_steam64(steam_id)
steam_ids.append(steam64_id)
if player["team"] == "Red":
red_steam_ids.append(steam64_id)
elif player["team"] == "Blue":
blue_steam_ids.append(steam64_id)
return steam_ids, blue_steam_ids, red_steam_ids
@shared_task
def update_playtime(steam_ids: list[int]):
# update players with playtime (recalculate through aggregation)
subquery = (
app_db.db.session.query(
PlayerTeam.id,
#func.datetime(func.sum(Match.duration), "unixepoch").label("total_playtime")
func.sum(Match.duration).label("total_playtime")
)
.join(PlayerMatch, PlayerMatch.player_id == PlayerTeam.player_id)
.join(TeamMatch, TeamMatch.team_id == PlayerTeam.team_id)
.join(Match, Match.logs_tf_id == TeamMatch.match_id)
.where(PlayerTeam.player_id.in_(steam_ids))
.group_by(PlayerTeam.id)
.subquery()
)
update_query = app_db.db.session.execute(
update(PlayerTeam)
.where(PlayerTeam.id == subquery.c.id)
.values(playtime=subquery.c.total_playtime)
)
def get_common_teams(steam_ids: list[int]):
#aggregate_func = None
#with app_db.app.app_context():
# if app_db.db.engine.name == "postgresql":
# aggregate_func = func.array_agg(PlayerTeam.player_id)
# else:
# aggregate_func = func.group_concat(PlayerTeam.player_id, ",")
#if aggregate_func is None:
# raise NotImplementedError("Unsupported database engine")
return (
app_db.db.session.query(
PlayerTeam.team_id,
func.count(PlayerTeam.team_id),
TeamLogsTfIntegration.min_team_member_count,
#aggregate_func
)
.outerjoin(
TeamLogsTfIntegration,
TeamLogsTfIntegration.team_id == PlayerTeam.team_id
)
.where(PlayerTeam.player_id.in_(steam_ids))
.group_by(PlayerTeam.team_id)
.order_by(func.count(PlayerTeam.team_id).desc())
.all()
)
def transform(
log_id: int,
details: models.match.RawLogDetails,
existing_match: Match | None = None,
invoked_by_team_id: int | None = None
):
steam_ids, blue_steam_ids, red_steam_ids = extract_steam_ids(details["players"])
# fetch players in steam_ids if they exist
players = (
app_db.db.session.query(Player)
.where(Player.steam_id.in_(steam_ids))
.all()
)
if len(players) == 0:
return
if not existing_match:
match = Match()
match.logs_tf_id = log_id
match.logs_tf_title = details["info"]["title"]
match.blue_score = details["teams"]["Blue"]["score"]
match.red_score = details["teams"]["Red"]["score"]
match.duration = details["length"]
match.match_time = datetime.fromtimestamp(details["info"]["date"])
yield match
else:
match = existing_match
#app_db.db.session.add(match)
for player in players:
player_data = details["players"][steam64_to_steam3(player.steam_id)]
if not player_data:
print(f"Player {player.steam_id} not found in log {log_id}")
continue
player_match = PlayerMatch()
player_match.player_id = player.steam_id
player_match.match_id = match.logs_tf_id
player_match.kills = player_data["kills"]
player_match.deaths = player_data["deaths"]
player_match.assists = player_data["assists"]
player_match.damage = player_data["dmg"]
player_match.damage_taken = player_data["dt"]
yield player_match
# get common teams
# if common teams exist, automatically create a TeamMatch for the match
for team, ids in { "Blue": blue_steam_ids, "Red": red_steam_ids }.items():
for row in get_common_teams(ids):
row_tuple = tuple(row)
team_id = row_tuple[0]
player_count = row_tuple[1]
log_min_player_count = row_tuple[2] or 100
should_create_team_match = False
if invoked_by_team_id and team_id == invoked_by_team_id:
# if manually uploading a log, then add TeamMatch for the team
# that uploaded the log
should_create_team_match = True
elif not invoked_by_team_id and player_count >= log_min_player_count:
# if automatically fetching logs, then add TeamMatch for teams
# with player count >= log_min_player_count
should_create_team_match = True
if should_create_team_match:
team_match = TeamMatch()
team_match.team_id = team_id
team_match.match_id = match.logs_tf_id
team_match.team_color = team
yield team_match
#app_db.db.session.flush()
update_playtime.delay(list(map(lambda x: x.steam_id, players)))
@shared_task
def load_specific_match(id: int, team_id: int | None):
match = (
app_db.db.session.query(Match)
.where(Match.logs_tf_id == id)
.first()
)
raw_match = extract(id)
app_db.db.session.bulk_save_objects(transform(id, raw_match, match, team_id))
app_db.db.session.commit()
sleep(3) # avoid rate limiting if multiple tasks are queued
def main():
last: int = (
app_db.db.session.query(
func.max(models.match.Match.logs_tf_id)
).scalar()
) or 3767233
for summary in get_log_ids(last):
print(summary)
sleep(3)

View File

@ -0,0 +1,8 @@
from app_db import connect_celery_with_app, app, connect_db_with_app
connect_db_with_app("sqlite:///db.sqlite3", False)
connect_celery_with_app()
celery_app = app.extensions["celery"]
import jobs.fetch_logstf

View File

@ -0,0 +1,108 @@
from flask import Blueprint, abort
from pydantic.v1 import validator
#from pydantic.functional_validators import field_validator
from spectree import Response
from sqlalchemy.orm import joinedload
from jobs.fetch_logstf import load_specific_match
from models.player_team import PlayerTeam
from models.team import Team
from models.team_match import TeamMatch, TeamMatchSchema
from spec import BaseModel, spec
from middleware import requires_authentication, requires_team_membership
from models.match import Match, MatchSchema
from app_db import db
from models.player import Player
from models.player_match import PlayerMatch
api_match = Blueprint("match", __name__, url_prefix="/match")
@api_match.get("/id/<int:match_id>")
@spec.validate(
resp=Response(
HTTP_200=MatchSchema,
),
)
@requires_authentication
def get_match(player: Player, match_id: int, **_):
match = (
db.session.query(Match)
.join(PlayerMatch)
.where(Match.logs_tf_id == match_id)
.where(PlayerMatch.player_id == player.steam_id)
.first()
)
if not match:
abort(404)
return MatchSchema.from_model(match).dict(by_alias=True), 200
class SubmitMatchJson(BaseModel):
match_ids: list[int]
@validator("match_ids")
@classmethod
def validate_match_ids(cls, match_ids):
if len(match_ids) < 1:
raise ValueError("match_ids must contain at least one match id")
if len(match_ids) > 10:
raise ValueError("match_ids must contain at most 10 match ids")
@api_match.put("/")
@spec.validate(
resp=Response(
HTTP_204=None,
),
operation_id="submit_match",
)
@requires_authentication
def submit_match(json: SubmitMatchJson, **_):
import sys
print(json, file=sys.stderr)
if json.match_ids is None:
print("json.match_ids is None", file=sys.stderr)
for id in json.match_ids:
load_specific_match.delay(id, None)
return { }, 204
@api_match.get("/team/<int:team_id>")
@spec.validate(
resp=Response(
HTTP_200=list[TeamMatchSchema],
),
operation_id="get_matches_for_team",
)
@requires_authentication
@requires_team_membership()
def get_matches_for_team(team_id: Team, **_):
matches = (
db.session.query(TeamMatch)
.where(TeamMatch.team_id == team_id)
.options(joinedload(TeamMatch.match))
.all()
)
return [TeamMatchSchema.from_model(match).dict(by_alias=True) for match in matches], 200
@api_match.get("/player")
@spec.validate(
resp=Response(
HTTP_200=list[TeamMatchSchema],
),
operation_id="get_matches_for_player_teams",
)
@requires_authentication
def get_matches_for_player_teams(player: Player, **_):
matches = (
db.session.query(TeamMatch)
.join(PlayerTeam, PlayerTeam.team_id == TeamMatch.team_id)
.join(Match)
.join(PlayerMatch)
.where(PlayerMatch.player_id == player.steam_id)
.options(joinedload(TeamMatch.match))
.all()
)
return [TeamMatchSchema.from_model(match).dict(by_alias=True) for match in matches], 200

View File

@ -34,8 +34,8 @@ def get_engine_url():
# add your model's MetaData object here # add your model's MetaData object here
# for 'autogenerate' support # for 'autogenerate' support
# from myapp import mymodel from app_db import BaseModel
# target_metadata = mymodel.Base.metadata target_metadata = BaseModel.metadata
config.set_main_option('sqlalchemy.url', get_engine_url()) config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db target_db = current_app.extensions['migrate'].db

View File

@ -0,0 +1,56 @@
"""Add the rest of the match tables
Revision ID: 7995474ef2cc
Revises: fda727438444
Create Date: 2024-12-09 16:17:25.518959
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7995474ef2cc'
down_revision = 'fda727438444'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('players_matches',
sa.Column('player_id', sa.BigInteger(), nullable=False),
sa.Column('match_id', sa.Integer(), nullable=False),
sa.Column('kills', sa.Integer(), nullable=False),
sa.Column('deaths', sa.Integer(), nullable=False),
sa.Column('assists', sa.Integer(), nullable=False),
sa.Column('damage', sa.BigInteger(), nullable=False),
sa.Column('damage_taken', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['match_id'], ['matches.logs_tf_id'], ),
sa.ForeignKeyConstraint(['player_id'], ['players.steam_id'], ),
sa.PrimaryKeyConstraint('player_id', 'match_id')
)
op.create_table('teams_matches',
sa.Column('team_id', sa.Integer(), nullable=False),
sa.Column('match_id', sa.Integer(), nullable=False),
sa.Column('team_color', sa.String(length=4), nullable=False),
sa.ForeignKeyConstraint(['match_id'], ['matches.logs_tf_id'], ),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
sa.PrimaryKeyConstraint('team_id', 'match_id')
)
with op.batch_alter_table('matches', schema=None) as batch_op:
batch_op.add_column(sa.Column('blue_score', sa.Integer(), nullable=False))
batch_op.add_column(sa.Column('red_score', sa.Integer(), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('matches', schema=None) as batch_op:
batch_op.drop_column('red_score')
batch_op.drop_column('blue_score')
op.drop_table('teams_matches')
op.drop_table('players_matches')
# ### end Alembic commands ###

View File

@ -0,0 +1,50 @@
"""Change intervals to integers
Revision ID: d15570037f47
Revises: 7995474ef2cc
Create Date: 2024-12-09 20:16:18.385467
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd15570037f47'
down_revision = '7995474ef2cc'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('matches', schema=None) as batch_op:
batch_op.alter_column('duration',
existing_type=sa.DATETIME(),
type_=sa.Integer(),
existing_nullable=False)
with op.batch_alter_table('players_teams', schema=None) as batch_op:
batch_op.alter_column('playtime',
existing_type=sa.DATETIME(),
type_=sa.Integer(),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('players_teams', schema=None) as batch_op:
batch_op.alter_column('playtime',
existing_type=sa.Integer(),
type_=sa.DATETIME(),
existing_nullable=False)
with op.batch_alter_table('matches', schema=None) as batch_op:
batch_op.alter_column('duration',
existing_type=sa.Integer(),
type_=sa.DATETIME(),
existing_nullable=False)
# ### end Alembic commands ###

View File

@ -0,0 +1,35 @@
"""Add match table
Revision ID: fda727438444
Revises: c242e3f99c64
Create Date: 2024-12-09 12:45:16.974122
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fda727438444'
down_revision = 'c242e3f99c64'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('matches',
sa.Column('logs_tf_id', sa.Integer(), nullable=False),
sa.Column('logs_tf_title', sa.String(length=255), nullable=False),
sa.Column('duration', sa.Interval(), nullable=False),
sa.Column('match_time', sa.TIMESTAMP(), nullable=False),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('logs_tf_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('matches')
# ### end Alembic commands ###

View File

@ -0,0 +1,99 @@
from datetime import datetime, timedelta
from typing import TypedDict
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from sqlalchemy.types import TIMESTAMP, Integer, Interval, String
import app_db
import spec
class Match(app_db.BaseModel):
__tablename__ = "matches"
logs_tf_id: Mapped[int] = mapped_column(Integer, primary_key=True)
logs_tf_title: Mapped[str] = mapped_column(String(255))
duration: Mapped[int] = mapped_column(Integer)
match_time: Mapped[datetime] = mapped_column(TIMESTAMP)
blue_score: Mapped[int] = mapped_column(Integer)
red_score: Mapped[int] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
teams: Mapped["TeamMatch"] = relationship("TeamMatch", back_populates="match")
players: Mapped["PlayerMatch"] = relationship("PlayerMatch", back_populates="match")
class MatchSchema(spec.BaseModel):
logs_tf_id: int
logs_tf_title: str
duration: int
match_time: datetime
blue_score: int
red_score: int
created_at: datetime
@classmethod
def from_model(cls, model: Match):
return cls(
logs_tf_id=model.logs_tf_id,
logs_tf_title=model.logs_tf_title,
duration=model.duration,
match_time=model.match_time,
blue_score=model.blue_score,
red_score=model.red_score,
created_at=model.created_at
)
class RawLogSummary:
id: int
title: str
map: str
date: int
players: int
views: int
@classmethod
def from_response(cls, response: dict):
object = cls()
object.id = response["id"]
object.title = response["title"]
object.map = response["map"]
object.date = response["date"]
object.players = response["players"]
object.views = response["views"]
return object
class LogTeam(TypedDict):
score: int
#kills: int
#deaths: int
#dmg: int
#charges: int
#drops: int
#firstcaps: int
#caps: int
class LogPlayer(TypedDict):
team: str
kills: int
deaths: int
assists: int
dmg: int
dt: int
class LogInfo(TypedDict):
title: str
map: str
date: int
class LogRound(TypedDict):
length: int
class RawLogDetails(TypedDict):
teams: dict[str, LogTeam]
players: dict[str, LogPlayer]
#rounds: list[LogRound]
info: LogInfo
length: int
from models.team_match import TeamMatch
from models.player_match import PlayerMatch

View File

@ -16,6 +16,7 @@ class Player(app_db.BaseModel):
teams: Mapped[list["PlayerTeam"]] = relationship(back_populates="player") teams: Mapped[list["PlayerTeam"]] = relationship(back_populates="player")
auth_sessions: Mapped[list["AuthSession"]] = relationship(back_populates="player") auth_sessions: Mapped[list["AuthSession"]] = relationship(back_populates="player")
events: Mapped[list["PlayerEvent"]] = relationship(back_populates="player") events: Mapped[list["PlayerEvent"]] = relationship(back_populates="player")
matches: Mapped[list["PlayerMatch"]] = relationship(back_populates="player")
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
@ -31,3 +32,4 @@ class PlayerSchema(spec.BaseModel):
from models.auth_session import AuthSession from models.auth_session import AuthSession
from models.player_event import PlayerEvent from models.player_event import PlayerEvent
from models.player_team import PlayerTeam from models.player_team import PlayerTeam
from models.player_match import PlayerMatch

View File

@ -58,7 +58,7 @@ class PlayerEventRolesSchema(spec.BaseModel):
role=RoleSchema.from_model(player_event.role) if player_event.role else None, 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], roles=[RoleSchema.from_model(role) for role in player_team.player_roles],
has_confirmed=player_event.has_confirmed, has_confirmed=player_event.has_confirmed,
playtime=int(player_team.playtime.total_seconds()), playtime=player_team.playtime,
) )

View File

@ -0,0 +1,24 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.schema import ForeignKey
from sqlalchemy.types import BigInteger, Integer
import app_db
class PlayerMatch(app_db.BaseModel):
__tablename__ = "players_matches"
player_id: Mapped[int] = mapped_column(ForeignKey("players.steam_id"), primary_key=True)
match_id: Mapped[int] = mapped_column(ForeignKey("matches.logs_tf_id"), primary_key=True)
kills: Mapped[int] = mapped_column(Integer)
deaths: Mapped[int] = mapped_column(Integer)
assists: Mapped[int] = mapped_column(Integer)
damage: Mapped[int] = mapped_column(BigInteger)
damage_taken: Mapped[int] = mapped_column(BigInteger)
player: Mapped["Player"] = relationship("Player", back_populates="matches")
match: Mapped["Match"] = relationship("Match", back_populates="players")
from models.match import Match
from models.player import Player

View File

@ -34,7 +34,7 @@ class PlayerTeam(app_db.BaseModel):
availability: Mapped[list["PlayerTeamAvailability"]] = relationship(back_populates="player_team") availability: Mapped[list["PlayerTeamAvailability"]] = relationship(back_populates="player_team")
team_role: Mapped[TeamRole] = mapped_column(Enum(TeamRole), default=TeamRole.Player) team_role: Mapped[TeamRole] = mapped_column(Enum(TeamRole), default=TeamRole.Player)
playtime: Mapped[timedelta] = mapped_column(Interval, default=timedelta(0)) playtime: Mapped[int] = mapped_column(Integer, default=0)
is_team_leader: Mapped[bool] = mapped_column(Boolean, default=False) is_team_leader: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())

View File

@ -34,6 +34,8 @@ class Team(app_db.BaseModel):
lazy="raise", lazy="raise",
) )
matches: Mapped[list["TeamMatch"]] = relationship(back_populates="team")
def update_integrations(self, integrations: "TeamIntegrationSchema"): def update_integrations(self, integrations: "TeamIntegrationSchema"):
if integrations.discord_integration: if integrations.discord_integration:
discord_integration = self.discord_integration \ discord_integration = self.discord_integration \
@ -130,3 +132,4 @@ from models.team_integration import (
TeamLogsTfIntegrationSchema, TeamLogsTfIntegrationSchema,
) )
from models.event import Event from models.event import Event
from models.team_match import TeamMatch

View File

@ -0,0 +1,36 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.schema import ForeignKey
from sqlalchemy.types import Integer, String
import app_db
import spec
class TeamMatch(app_db.BaseModel):
__tablename__ = "teams_matches"
team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), primary_key=True)
match_id: Mapped[int] = mapped_column(ForeignKey("matches.logs_tf_id"), primary_key=True)
team_color: Mapped[str] = mapped_column(String(4))
team: Mapped["Team"] = relationship("Team", back_populates="matches")
match: Mapped["Match"] = relationship("Match", back_populates="teams")
class TeamMatchSchema(spec.BaseModel):
match: "MatchSchema"
our_score: int
their_score: int
@classmethod
def from_model(cls, model: "TeamMatch"):
our_score = model.match.blue_score if model.team_color == "Blue" else model.match.red_score
their_score = model.match.red_score if model.team_color == "Blue" else model.match.blue_score
return cls(
match=MatchSchema.from_model(model.match),
our_score=our_score,
their_score=their_score,
)
from models.match import Match, MatchSchema
from models.team import Team

View File

@ -9,9 +9,9 @@ Flask-SQLAlchemy
SQLAlchemy-Utc SQLAlchemy-Utc
# form/data validation # form/data validation
pydantic pydantic==2.9.2
spectree # generates OpenAPI documents for us to make TypeScript API clients spectree==1.4.1 # generates OpenAPI documents for us to make TypeScript API
# based on our pydantic models # clients based on our pydantic models
# DB migrations # DB migrations
alembic alembic
@ -22,3 +22,7 @@ requests
pytz # timezone handling pytz # timezone handling
discord-webhook # for sending messages to Discord webhooks discord-webhook # for sending messages to Discord webhooks
celery[redis]
Flask-Testing

View File

@ -283,7 +283,7 @@ def view_available_at_time(query: ViewAvailablePlayersQuery, player: Player, **k
return PlayerTeamAvailabilityRoleSchema( return PlayerTeamAvailabilityRoleSchema(
player=PlayerSchema.from_model(player), player=PlayerSchema.from_model(player),
playtime=int(player_team.playtime.total_seconds()), playtime=player_team.playtime,
availability=player_avail.availability, availability=player_avail.availability,
roles=list(map(RoleSchema.from_model, player_roles)), roles=list(map(RoleSchema.from_model, player_roles)),
) )

View File

@ -9,7 +9,7 @@ from models.player import Player, PlayerSchema
from models.player_team import PlayerTeam from models.player_team import PlayerTeam
from models.player_team_availability import PlayerTeamAvailability from models.player_team_availability import PlayerTeamAvailability
from models.player_team_role import PlayerTeamRole, RoleSchema from models.player_team_role import PlayerTeamRole, RoleSchema
from models.team import Team, TeamSchema from models.team import Team, TeamSchema, TeamWithRoleSchema
from middleware import assert_team_authority, requires_authentication, requires_team_membership from middleware import assert_team_authority, requires_authentication, requires_team_membership
from spec import spec, BaseModel from spec import spec, BaseModel
from team_invite import api_team_invite from team_invite import api_team_invite
@ -51,7 +51,7 @@ class ViewTeamResponse(BaseModel):
team: TeamSchema team: TeamSchema
class ViewTeamsResponse(BaseModel): class ViewTeamsResponse(BaseModel):
teams: list[TeamSchema] teams: list[TeamWithRoleSchema]
@api_team.post("/") @api_team.post("/")
@spec.validate( @spec.validate(
@ -290,7 +290,7 @@ def view_team(team_id: int, **kwargs):
def fetch_teams_for_player(player: Player, team_id: int | None): def fetch_teams_for_player(player: Player, team_id: int | None):
q = db.session.query( q = db.session.query(
Team Team, PlayerTeam
).join( ).join(
PlayerTeam PlayerTeam
).join( ).join(
@ -303,15 +303,15 @@ def fetch_teams_for_player(player: Player, team_id: int | None):
q = q.where(PlayerTeam.team_id == team_id) q = q.where(PlayerTeam.team_id == team_id)
if team_id is None: if team_id is None:
teams = q.all() players_teams = list(map(lambda x: x.tuple()[1], q.all()))
return ViewTeamsResponse( return ViewTeamsResponse(
teams=list(map(TeamSchema.from_model, teams)) teams=list(map(TeamWithRoleSchema.from_player_team, players_teams))
) )
else: else:
team = q.one_or_none() team = q.one_or_none()
if team: if team:
return ViewTeamResponse( return ViewTeamResponse(
team=TeamSchema.from_model(team) team=TeamSchema.from_model(team.tuple()[0])
) )
class ViewTeamMembersResponse(PlayerSchema): class ViewTeamMembersResponse(PlayerSchema):
@ -377,7 +377,7 @@ def view_team_members(player: Player, team_id: int, **kwargs):
steam_id=str(player.steam_id), steam_id=str(player.steam_id),
roles=list(map(map_role_to_schema, roles)), roles=list(map(map_role_to_schema, roles)),
availability=availability, availability=availability,
playtime=player_team.playtime.total_seconds() / 3600, playtime=player_team.playtime / 3600,
created_at=player_team.created_at, created_at=player_team.created_at,
is_team_leader=player_team.is_team_leader, is_team_leader=player_team.is_team_leader,
).dict(by_alias=True) ).dict(by_alias=True)

View File

@ -8,7 +8,7 @@ from middleware import requires_authentication, requires_team_membership
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_invite import TeamInvite, TeamInviteSchema from models.team_invite import TeamInvite, TeamInviteSchema
from spec import spec from spec import BaseModel, spec
api_team_invite = Blueprint("team_invite", __name__) api_team_invite = Blueprint("team_invite", __name__)
@ -75,27 +75,31 @@ def create_invite(team_id: int, **_):
return response.dict(by_alias=True), 200 return response.dict(by_alias=True), 200
@api_team_invite.post("/id/<team_id>/consume-invite/<key>") class ConsumeInviteResponse(BaseModel):
team_id: int
@api_team_invite.post("/consume-invite/<key>")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_204=None, HTTP_200=ConsumeInviteResponse,
HTTP_404=None, HTTP_404=None,
), ),
operation_id="consume_invite" operation_id="consume_invite"
) )
@requires_authentication @requires_authentication
def consume_invite(player: Player, team_id: int, key: str, **_): def consume_invite(player: Player, key: str, **_):
invite = db.session.query( invite = db.session.query(
TeamInvite TeamInvite
).where(
TeamInvite.team_id == team_id
).where( ).where(
TeamInvite.key == key TeamInvite.key == key
).one_or_none() ).one_or_none()
if not invite: if not invite:
abort(404) abort(404)
team_id = invite.team_id
player_team = db.session.query( player_team = db.session.query(
PlayerTeam PlayerTeam
).where( ).where(
@ -118,7 +122,7 @@ def consume_invite(player: Player, team_id: int, key: str, **_):
db.session.commit() db.session.commit()
return make_response({ }, 204) return ConsumeInviteResponse(team_id=team_id).dict(by_alias=True), 200
@api_team_invite.delete("/id/<team_id>/invite/<key>") @api_team_invite.delete("/id/<team_id>/invite/<key>")
@spec.validate( @spec.validate(

View File

@ -0,0 +1,7 @@
import unittest
import flask_testing
if __name__ == "__main__":
suite = unittest.TestLoader().discover("tests")
unittest.TextTestRunner(verbosity=1).run(suite)

View File

@ -0,0 +1,104 @@
from datetime import timedelta
from base_test_case import BaseTestCase
from app_db import db
from models.match import Match, RawLogDetails
from models.player import Player
from models.player_match import PlayerMatch
from models.player_team import PlayerTeam
from models.team_match import TeamMatch
class TestLogstfJob(BaseTestCase):
def populate_db(self):
from app_db import db
super().populate_db()
wesker_u = Player(steam_id=76561198024482308, username="Wesker U")
wesker_u_pt = PlayerTeam(
player_id=wesker_u.steam_id,
team_id=1,
team_role=PlayerTeam.TeamRole.Player,
is_team_leader=True,
)
db.session.add(wesker_u)
db.session.add(wesker_u_pt)
db.session.commit()
def test_get_common_teams(self):
from jobs.fetch_logstf import get_common_teams
rows = get_common_teams([76561198248436608, 76561198024482308])
assert len(rows) == 1
assert rows == [(1, 2, 2)]
def test_transform(self):
from jobs.fetch_logstf import transform
details: RawLogDetails = {
"players": {
"[U:1:288170880]": {
"team": "Red",
"kills": 1,
"deaths": 2,
"assists": 3,
"dmg": 4,
"dt": 5,
},
"[U:1:64216580]": {
"team": "Red",
"kills": 6,
"deaths": 7,
"assists": 8,
"dmg": 9,
"dt": 10,
},
"[U:1:64216581]": {
"team": "Blue",
"kills": 6,
"deaths": 7,
"assists": 8,
"dmg": 9,
"dt": 10,
},
},
"info": {
"title": "I LOVE DUSTBOWL",
"map": "cp_dustbowl",
"date": 1614547200,
},
"teams": {
"Blue": {
"score": 1
},
"Red": {
"score": 2
}
},
"length": 3025,
}
for instance in transform(1, details):
db.session.add(instance)
db.session.commit()
assert db.session.query(Player).count() == 2
assert db.session.query(PlayerMatch).count() == 2
assert db.session.query(TeamMatch).count() == 1
assert db.session.query(Match).count() == 1
assert db.session.query(PlayerTeam).count() == 2
player_team = db.session.query(PlayerTeam).first()
assert player_team is not None
print(player_team.playtime)
assert player_team.playtime == 3025
def test_steam3_to_steam64(self):
from jobs.fetch_logstf import steam3_to_steam64
assert steam3_to_steam64("[U:1:123456]") == 76561197960265728 + 123456
def test_steam64_to_steam3(self):
from jobs.fetch_logstf import steam64_to_steam3
assert steam64_to_steam3(76561197960265728 + 123456) == "[U:1:123456]"

View File

@ -0,0 +1,83 @@
from collections import deque, defaultdict
from models.player_team import PlayerTeam
from models.player_team_role import PlayerTeamRole
from collections import deque
class BipartiteGraph:
graph: dict[int, list[PlayerTeamRole.Role]] = {}
pair_u: dict[int, PlayerTeamRole.Role | None] = {}
pair_v: dict[PlayerTeamRole.Role, int | None] = {}
dist: dict[int | None, float] = {}
U: set[int] = set()
V: set[PlayerTeamRole.Role] = set()
def __init__(
self,
ids_to_roles: dict[int, list[PlayerTeamRole.Role]],
required_roles: list[PlayerTeamRole.Role],
):
self.graph = self.build_graph(ids_to_roles, required_roles)
self.pair_u = {}
self.pair_v = {}
self.dist = {}
self.U = set(ids_to_roles.keys())
self.V = set(
role
for roles in ids_to_roles.values()
for role in roles
if role in required_roles
)
def build_graph(
self,
ids_to_roles: dict[int, list[PlayerTeamRole.Role]],
required_roles: list[PlayerTeamRole.Role],
):
graph = {}
for u, roles in ids_to_roles.items():
graph[u] = [v for v in roles if v in required_roles]
return graph
def bfs(self):
queue = deque()
for u in self.U:
if u not in self.pair_u:
self.dist[u] = 0
queue.append(u)
else:
self.dist[u] = float("inf")
self.dist[None] = float("inf")
while queue:
u = queue.popleft()
if self.dist[u] < self.dist[None]:
for v in self.graph[u]:
if self.dist[self.pair_v.get(v, None)] == float("inf"):
self.dist[self.pair_v.get(v, None)] = self.dist[u] + 1
queue.append(self.pair_v.get(v, None))
return self.dist[None] != float("inf")
def dfs(self, u):
if u is not None:
for v in self.graph[u]:
if self.dist[self.pair_v.get(v, None)] == self.dist[u] + 1:
if self.dfs(self.pair_v.get(v, None)):
self.pair_u[u] = v
self.pair_v[v] = u
return True
self.dist[u] = float("inf")
return False
return True
def hopcroft_karp(self):
matching = 0
while self.bfs():
for u in self.U:
if u not in self.pair_u:
if self.dfs(u):
matching += 1
return matching

View File

@ -4,23 +4,49 @@ services:
# Flask service # Flask service
backend: backend:
container_name: backend container_name: backend
image: backend-flask
build: build:
context: ./backend-flask context: ./backend-flask
#image: jazzdd/alpine-flask:python3
ports:
- ":5000"
volumes: volumes:
- ./backend-flask:/app - ./backend-flask:/app
networks: networks:
- dev-network - dev-network
environment:
- FLASK_DEBUG=1
- FLASK_CELERY_BROKER_URL=redis://redis:6379/0
- FLASK_CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
- redis
# ETL job (runs with the same source as the backend)
celery-worker:
container_name: worker
command: celery -A make_celery.celery_app worker --loglevel=info --concurrency=1
environment:
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
image: backend-flask
volumes:
- ./backend-flask:/app
networks:
- dev-network
depends_on:
- redis
# message broker
redis:
image: redis:alpine
container_name: redis
networks:
- dev-network
ports:
- 6379:6379
# Vue + Vite service # Vue + Vite service
frontend: frontend:
container_name: frontend container_name: frontend
build: build:
context: ./availabili.tf context: ./availabili.tf
ports:
- ":5173"
environment: environment:
VITE_API_URL: http://localhost:8000 # API endpoint VITE_API_URL: http://localhost:8000 # API endpoint
volumes: volumes: