Compare commits

...

5 Commits

27 changed files with 285 additions and 136 deletions

View File

@ -18,7 +18,7 @@ Scheduling for TF2
- **Database:** [PostgreSQL 17.1](https://www.postgresql.org/docs/17/index.html) - **Database:** [PostgreSQL 17.1](https://www.postgresql.org/docs/17/index.html)
(production) / SQLite (development) (production) / SQLite (development)
## Setup (dev) ## Setup (development, SQLite3)
```sh ```sh
docker compose build docker compose build
@ -28,7 +28,7 @@ DATABASE_URI=sqlite:///db.sqlite3 flask db upgrade
App will run at port 8000. App will run at port 8000.
## Setup (production) ## Setup (production, Postgres)
Build the frontend app: Build the frontend app:

View File

@ -21,6 +21,7 @@ export type { EventSchema } from './models/EventSchema';
export type { EventWithPlayerSchema } from './models/EventWithPlayerSchema'; export type { EventWithPlayerSchema } from './models/EventWithPlayerSchema';
export type { EventWithPlayerSchemaList } from './models/EventWithPlayerSchemaList'; export type { EventWithPlayerSchemaList } from './models/EventWithPlayerSchemaList';
export type { GetEventPlayersResponse } from './models/GetEventPlayersResponse'; export type { GetEventPlayersResponse } from './models/GetEventPlayersResponse';
export type { GetMatchQuery } from './models/GetMatchQuery';
export type { MatchSchema } from './models/MatchSchema'; export type { MatchSchema } from './models/MatchSchema';
export type { PlayerEventRolesSchema } from './models/PlayerEventRolesSchema'; export type { PlayerEventRolesSchema } from './models/PlayerEventRolesSchema';
export type { PlayerRoleSchema } from './models/PlayerRoleSchema'; export type { PlayerRoleSchema } from './models/PlayerRoleSchema';

View File

@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type GetMatchQuery = {
limit: (number | null);
};

View File

@ -5,7 +5,7 @@
export type MatchSchema = { export type MatchSchema = {
blueScore: number; blueScore: number;
createdAt: string; createdAt: string;
duration: string; duration: number;
logsTfId: number; logsTfId: number;
logsTfTitle: string; logsTfTitle: string;
matchTime: string; matchTime: string;

View File

@ -4,5 +4,6 @@
/* eslint-disable */ /* eslint-disable */
export type SubmitMatchJson = { export type SubmitMatchJson = {
matchIds: Array<number>; matchIds: Array<number>;
teamId: number;
}; };

View File

@ -6,6 +6,7 @@ import type { MatchSchema } from './MatchSchema';
export type TeamMatchSchema = { export type TeamMatchSchema = {
match: MatchSchema; match: MatchSchema;
ourScore: number; ourScore: number;
teamColor: string;
theirScore: number; theirScore: number;
}; };

View File

@ -348,11 +348,13 @@ export class DefaultService {
/** /**
* get_matches_for_team <GET> * get_matches_for_team <GET>
* @param teamId * @param teamId
* @param limit
* @returns TeamMatchSchemaList OK * @returns TeamMatchSchemaList OK
* @throws ApiError * @throws ApiError
*/ */
public getMatchesForTeam( public getMatchesForTeam(
teamId: number, teamId: number,
limit: (number | null),
): CancelablePromise<TeamMatchSchemaList> { ): CancelablePromise<TeamMatchSchemaList> {
return this.httpRequest.request({ return this.httpRequest.request({
method: 'GET', method: 'GET',
@ -360,6 +362,9 @@ export class DefaultService {
path: { path: {
'team_id': teamId, 'team_id': teamId,
}, },
query: {
'limit': limit,
},
errors: { errors: {
422: `Unprocessable Content`, 422: `Unprocessable Content`,
}, },
@ -520,7 +525,7 @@ export class DefaultService {
* @throws ApiError * @throws ApiError
*/ */
public deleteTeam( public deleteTeam(
teamId: string, teamId: number,
): CancelablePromise<any> { ): CancelablePromise<any> {
return this.httpRequest.request({ return this.httpRequest.request({
method: 'DELETE', method: 'DELETE',
@ -542,7 +547,7 @@ export class DefaultService {
* @throws ApiError * @throws ApiError
*/ */
public getTeam( public getTeam(
teamId: string, teamId: number,
): CancelablePromise<ViewTeamResponse> { ): CancelablePromise<ViewTeamResponse> {
return this.httpRequest.request({ return this.httpRequest.request({
method: 'GET', method: 'GET',
@ -590,7 +595,7 @@ export class DefaultService {
* @throws ApiError * @throws ApiError
*/ */
public editMemberRoles( public editMemberRoles(
teamId: string, teamId: number,
targetPlayerId: string, targetPlayerId: string,
requestBody?: EditMemberRolesJson, requestBody?: EditMemberRolesJson,
): CancelablePromise<void> { ): CancelablePromise<void> {
@ -617,7 +622,7 @@ export class DefaultService {
* @throws ApiError * @throws ApiError
*/ */
public getIntegrations( public getIntegrations(
teamId: string, teamId: number,
): CancelablePromise<TeamIntegrationSchema> { ): CancelablePromise<TeamIntegrationSchema> {
return this.httpRequest.request({ return this.httpRequest.request({
method: 'GET', method: 'GET',
@ -638,7 +643,7 @@ export class DefaultService {
* @throws ApiError * @throws ApiError
*/ */
public updateIntegrations( public updateIntegrations(
teamId: string, teamId: number,
requestBody?: TeamIntegrationSchema, requestBody?: TeamIntegrationSchema,
): CancelablePromise<TeamIntegrationSchema> { ): CancelablePromise<TeamIntegrationSchema> {
return this.httpRequest.request({ return this.httpRequest.request({
@ -661,7 +666,7 @@ export class DefaultService {
* @throws ApiError * @throws ApiError
*/ */
public getInvites( public getInvites(
teamId: string, teamId: number,
): CancelablePromise<TeamInviteSchemaList> { ): CancelablePromise<TeamInviteSchemaList> {
return this.httpRequest.request({ return this.httpRequest.request({
method: 'GET', method: 'GET',
@ -682,7 +687,7 @@ export class DefaultService {
* @throws ApiError * @throws ApiError
*/ */
public createInvite( public createInvite(
teamId: string, teamId: number,
): CancelablePromise<TeamInviteSchema> { ): CancelablePromise<TeamInviteSchema> {
return this.httpRequest.request({ return this.httpRequest.request({
method: 'POST', method: 'POST',
@ -704,7 +709,7 @@ export class DefaultService {
* @throws ApiError * @throws ApiError
*/ */
public revokeInvite( public revokeInvite(
teamId: string, teamId: number,
key: string, key: string,
): CancelablePromise<void> { ): CancelablePromise<void> {
return this.httpRequest.request({ return this.httpRequest.request({
@ -729,7 +734,7 @@ export class DefaultService {
* @throws ApiError * @throws ApiError
*/ */
public createOrUpdatePlayer( public createOrUpdatePlayer(
teamId: string, teamId: number,
playerId: string, playerId: string,
requestBody?: AddPlayerJson, requestBody?: AddPlayerJson,
): CancelablePromise<any> { ): CancelablePromise<any> {
@ -757,7 +762,7 @@ export class DefaultService {
* @throws ApiError * @throws ApiError
*/ */
public removePlayerFromTeam( public removePlayerFromTeam(
teamId: string, teamId: number,
targetPlayerId: string, targetPlayerId: string,
): CancelablePromise<any> { ): CancelablePromise<any> {
return this.httpRequest.request({ return this.httpRequest.request({
@ -781,7 +786,7 @@ export class DefaultService {
* @throws ApiError * @throws ApiError
*/ */
public getTeamMembers( public getTeamMembers(
teamId: string, teamId: number,
): CancelablePromise<ViewTeamMembersResponseList> { ): CancelablePromise<ViewTeamMembersResponseList> {
return this.httpRequest.request({ return this.httpRequest.request({
method: 'GET', method: 'GET',

View File

@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTeamDetails } from "@/composables/team-details";
import { useMatchesStore } from "@/stores/matches"; import { useMatchesStore } from "@/stores/matches";
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger } from "radix-vue"; import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger } from "radix-vue";
import { ref } from "vue"; import { ref } from "vue";
const matchesStore = useMatchesStore(); const matchesStore = useMatchesStore();
const { teamId } = useTeamDetails();
const urlsText = ref(""); const urlsText = ref("");
function submit() { function submit() {
@ -15,7 +18,10 @@ function submit() {
}) })
.filter((id) => !isNaN(id)); .filter((id) => !isNaN(id));
matchesStore.submitMatches(ids); matchesStore.submitMatches(ids, teamId.value)
.then(() => {
urlsText.value = "";
});
} }
</script> </script>

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. (CURRENTLY NOT IMPLEMENTED)</p> <p>Automatically fetch and track match history from logs.tf.</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

@ -1,37 +1,91 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TeamMatchSchema, TeamSchema } from '@/client';
import moment from 'moment';
import { computed } from 'vue';
const matchTime = computed(() => moment(props.teamMatch.match.matchTime)
.format("LL LT"));
const props = defineProps<{
team: TeamSchema,
teamMatch: TeamMatchSchema,
}>();
</script> </script>
<template> <template>
<div class="match-card"> <div class="match-card">
<div class="match-title">
<h3>
{{ teamMatch.match.logsTfTitle }}
</h3>
</div>
<div class="match-scores"> <div class="match-scores">
<div class="team-and-score"> <div class="team-and-score">
<span class="team-name"> <span class="team-name">
NVBLU <span v-if="teamMatch.teamColor == 'Blue'">
BLU
</span>
<span v-else>
RED
</span>
</span> </span>
<span class="score"> <span class="score">
3 {{ teamMatch.ourScore }}
</span> </span>
</div> </div>
<span class="score">-</span>
<div class="team-and-score"> <div class="team-and-score">
<span class="score"> <span class="score">
2 {{ teamMatch.theirScore }}
</span> </span>
<span class="team-name"> <span class="team-name">
RED <span v-if="teamMatch.teamColor == 'Blue'">
RED
</span>
<span v-else>
BLU
</span>
</span> </span>
</div> </div>
</div> </div>
<div class="bottom-row">
<div class="subtext">
{{ matchTime }}
</div>
<div>
<a :href="'https://logs.tf/' + teamMatch.match.logsTfId" target="_blank" class="button">
#{{ teamMatch.match.logsTfId }}
</a>
</div>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.match-card { .match-card {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
padding: 1rem; padding: 1rem;
border: 1px solid var(--text); border: 1px solid var(--text);
border-radius: 8px; border-radius: 8px;
gap: 0.5rem;
}
.match-title {
display: flex;
gap: 0.5rem;
align-items: center;
}
.bottom-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.bottom-row > div {
display: flex;
align-items: center;
gap: 1rem;
} }
.match-scores { .match-scores {

View File

@ -11,8 +11,8 @@ import TeamSettingsView from "@/views/TeamSettingsView.vue";
import TeamSettingsGeneralView from "@/views/TeamSettings/GeneralView.vue"; import TeamSettingsGeneralView from "@/views/TeamSettings/GeneralView.vue";
import TeamSettingsIntegrationsView from "@/views/TeamSettings/IntegrationsView.vue"; import TeamSettingsIntegrationsView from "@/views/TeamSettings/IntegrationsView.vue";
import TeamSettingsInvitesView from "@/views/TeamSettings/InvitesView.vue"; import TeamSettingsInvitesView from "@/views/TeamSettings/InvitesView.vue";
import TeamSettingsMatchesView from "@/views/TeamSettings/MatchesView.vue";
import UserSettingsView from "@/views/UserSettingsView.vue"; import UserSettingsView from "@/views/UserSettingsView.vue";
import MatchesView from "@/views/MatchesView.vue";
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -72,6 +72,11 @@ const router = createRouter({
name: "team-settings/invites", name: "team-settings/invites",
component: TeamSettingsInvitesView, component: TeamSettingsInvitesView,
}, },
{
path: "matches",
name: "team-settings/matches",
component: TeamSettingsMatchesView,
},
], ],
}, },
{ {
@ -79,11 +84,6 @@ const router = createRouter({
name: "user-settings", name: "user-settings",
component: UserSettingsView, component: UserSettingsView,
}, },
{
path: "/matches",
name: "matches",
component: MatchesView,
},
] ]
}); });

View File

@ -9,7 +9,9 @@ export const useMatchesStore = defineStore("matches", () => {
const matches = ref<{ [id: number]: MatchSchema }>({ }); const matches = ref<{ [id: number]: MatchSchema }>({ });
const teamMatches = ref<{ [id: number]: TeamMatchSchema }>({ }); const teamMatches = ref<{ [teamId: number]: TeamMatchSchema[] }>({ });
const recentMatches = ref<TeamMatchSchema[]>([]);
function fetchMatches() { function fetchMatches() {
return clientStore.call( return clientStore.call(
@ -18,21 +20,52 @@ export const useMatchesStore = defineStore("matches", () => {
(response) => { (response) => {
response.forEach((match) => { response.forEach((match) => {
matches.value[match.match.logsTfId] = match.match; matches.value[match.match.logsTfId] = match.match;
teamMatches.value[match.match.logsTfId] = match; //teamMatches.value[match.match.logsTfId] = match;
}); });
return response; return response;
} }
) )
} }
function submitMatches(logsTfIds: number[]) { function fetchMatchesForTeam(teamId: number) {
return client.default.submitMatch({ matchIds: logsTfIds }); return clientStore.call(
fetchMatchesForTeam.name,
() => client.default.getMatchesForTeam(teamId, 1024),
(response) => {
teamMatches.value[teamId] = [];
response.forEach((match) => {
matches.value[match.match.logsTfId] = match.match;
teamMatches.value[teamId].push(match);
});
return response;
});
}
function fetchRecentMatchesForTeam(teamId: number, limit: number) {
return clientStore.call(
fetchMatchesForTeam.name,
() => client.default.getMatchesForTeam(teamId, limit),
(response) => {
recentMatches.value = [];
response.forEach((match) => {
matches.value[match.match.logsTfId] = match.match;
recentMatches.value.push(match);
});
return response;
});
}
function submitMatches(logsTfIds: number[], teamId: number) {
return client.default.submitMatch({ matchIds: logsTfIds, teamId });
} }
return { return {
matches, matches,
teamMatches, teamMatches,
recentMatches,
fetchMatches, fetchMatches,
fetchMatchesForTeam,
fetchRecentMatchesForTeam,
submitMatches, submitMatches,
} }
}); });

View File

@ -35,7 +35,7 @@ export const useTeamsStore = defineStore("teams", () => {
async function fetchTeam(id: number) { async function fetchTeam(id: number) {
const response = await clientStore.call( const response = await clientStore.call(
fetchTeam.name, fetchTeam.name,
() => client.default.getTeam(id.toString()) () => client.default.getTeam(id)
); );
teams[response.team.id] = response.team; teams[response.team.id] = response.team;
return response; return response;
@ -44,7 +44,7 @@ export const useTeamsStore = defineStore("teams", () => {
async function fetchTeamMembers(id: number) { async function fetchTeamMembers(id: number) {
const response = await clientStore.call( const response = await clientStore.call(
fetchTeamMembers.name, fetchTeamMembers.name,
() => client.default.getTeamMembers(id.toString()) () => client.default.getTeamMembers(id)
); );
teamMembers[id] = response.map((member): ViewTeamMembersResponse => { teamMembers[id] = response.map((member): ViewTeamMembersResponse => {
member.roles = member.roles.sort((a, b) => (a.isMain === b.isMain ? 0 : a.isMain ? -1 : 1)); member.roles = member.roles.sort((a, b) => (a.isMain === b.isMain ? 0 : a.isMain ? -1 : 1));
@ -68,11 +68,11 @@ export const useTeamsStore = defineStore("teams", () => {
} }
async function updateRoles(teamId: number, playerId: string, roles: RoleSchema[]) { async function updateRoles(teamId: number, playerId: string, roles: RoleSchema[]) {
return await client.default.editMemberRoles(teamId.toString(), playerId, { roles }); return await client.default.editMemberRoles(teamId, playerId, { roles });
} }
async function leaveTeam(teamId: number) { async function leaveTeam(teamId: number) {
return client.default.removePlayerFromTeam(teamId.toString(), authStore.steamId); return client.default.removePlayerFromTeam(teamId, authStore.steamId);
} }
return { return {

View File

@ -18,7 +18,7 @@ export const useIntegrationsStore = defineStore("integrations", () => {
async function getIntegrations(teamId: number) { async function getIntegrations(teamId: number) {
hasLoaded.value = false; hasLoaded.value = false;
const response = await client.default.getIntegrations(teamId.toString()); const response = await client.default.getIntegrations(teamId);
setIntegrations(response); setIntegrations(response);
return response; return response;
} }
@ -34,7 +34,7 @@ export const useIntegrationsStore = defineStore("integrations", () => {
discordIntegration: discordIntegration.value ?? null, discordIntegration: discordIntegration.value ?? null,
logsTfIntegration: logsTfIntegration.value ?? null, logsTfIntegration: logsTfIntegration.value ?? null,
}; };
const response = await client.default.updateIntegrations(teamId.toString(), body); const response = await client.default.updateIntegrations(teamId, body);
setIntegrations(response); setIntegrations(response);
return response; return response;
} }

View File

@ -12,7 +12,7 @@ export const useInvitesStore = defineStore("invites", () => {
async function getInvites(teamId: number) { async function getInvites(teamId: number) {
return clientStore.call( return clientStore.call(
getInvites.name, getInvites.name,
() => client.default.getInvites(teamId.toString()), () => client.default.getInvites(teamId),
(response) => { (response) => {
teamInvites[teamId] = response; teamInvites[teamId] = response;
return response; return response;
@ -21,7 +21,7 @@ export const useInvitesStore = defineStore("invites", () => {
} }
async function createInvite(teamId: number) { async function createInvite(teamId: number) {
const response = await client.default.createInvite(teamId.toString()); const response = await client.default.createInvite(teamId);
teamInvites[teamId].push(response); teamInvites[teamId].push(response);
return response; return response;
} }
@ -37,7 +37,7 @@ export const useInvitesStore = defineStore("invites", () => {
} }
async function revokeInvite(teamId: number, key: string) { async function revokeInvite(teamId: number, key: string) {
const response = await client.default.revokeInvite(teamId.toString(), key); const response = await client.default.revokeInvite(teamId, key);
teamInvites[teamId] = teamInvites[teamId].filter((invite) => invite.key != key); teamInvites[teamId] = teamInvites[teamId].filter((invite) => invite.key != key);
return response; return response;
} }

View File

@ -9,10 +9,12 @@ import moment from "moment";
import EventList from "@/components/EventList.vue"; import EventList from "@/components/EventList.vue";
import { useTeamsEventsStore } from "@/stores/teams/events"; import { useTeamsEventsStore } from "@/stores/teams/events";
import MatchCard from "@/components/MatchCard.vue"; import MatchCard from "@/components/MatchCard.vue";
import { useMatchesStore } from "@/stores/matches";
const route = useRoute(); const route = useRoute();
const teamsStore = useTeamsStore(); const teamsStore = useTeamsStore();
const invitesStore = useInvitesStore(); const invitesStore = useInvitesStore();
const matchesStore = useMatchesStore();
const { team, teamId } = useTeamDetails(); const { team, teamId } = useTeamDetails();
const creationDate = computed(() => { const creationDate = computed(() => {
@ -25,6 +27,7 @@ const key = computed(() => route.query.key);
const teamsEventsStore = useTeamsEventsStore(); const teamsEventsStore = useTeamsEventsStore();
const events = computed(() => teamsEventsStore.teamEvents[teamId.value]); const events = computed(() => teamsEventsStore.teamEvents[teamId.value]);
const matches = computed(() => matchesStore.recentMatches);
onMounted(() => { onMounted(() => {
let doFetchTeam = () => { let doFetchTeam = () => {
@ -32,6 +35,7 @@ onMounted(() => {
.then(() => { .then(() => {
teamsStore.fetchTeamMembers(teamId.value); teamsStore.fetchTeamMembers(teamId.value);
teamsEventsStore.fetchTeamEvents(teamId.value); teamsEventsStore.fetchTeamEvents(teamId.value);
matchesStore.fetchRecentMatchesForTeam(teamId.value, 5);
}); });
}; };
@ -76,14 +80,21 @@ onMounted(() => {
<EventList :events="events" :team-context="team" /> <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="{ name: 'team-settings/matches' }">
<button class="icon" v-tooltip="'View all'"> <button class="icon" v-tooltip="'View all'">
<i class="bi bi-arrow-right-circle-fill"></i> <i class="bi bi-arrow-right-circle-fill"></i>
</button> </button>
</RouterLink--> </RouterLink>
</h2> </h2>
<em class="subtext" v-if="true">No recent matches.</em> <em class="subtext" v-if="!matches">
<MatchCard v-else /> No recent matches.
</em>
<MatchCard
v-else
v-for="match in matches"
:team-match="match"
:team="team"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -1,12 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import AddMatchDialog from "@/components/AddMatchDialog.vue"; import AddMatchDialog from "@/components/AddMatchDialog.vue";
import { useTeamDetails } from "@/composables/team-details";
import { useMatchesStore } from "@/stores/matches"; import { useMatchesStore } from "@/stores/matches";
import { onMounted } from "vue"; import { useTeamsStore } from "@/stores/teams";
import moment from "moment";
import { computed, onMounted } from "vue";
const matchesStore = useMatchesStore(); const matchesStore = useMatchesStore();
const teamsStore = useTeamsStore();
const { team, teamId } = useTeamDetails();
const matches = computed(() => matchesStore.teamMatches[teamId.value]);
onMounted(() => { onMounted(() => {
matchesStore.fetchMatches(); teamsStore.fetchTeam(teamId.value)
.then(() => matchesStore.fetchMatchesForTeam(teamId.value));
}); });
</script> </script>
@ -15,7 +24,7 @@ onMounted(() => {
<div class="header"> <div class="header">
<h1> <h1>
<i class="bi bi-trophy-fill margin"></i> <i class="bi bi-trophy-fill margin"></i>
Matches you've played Matches
</h1> </h1>
<div class="button-group"> <div class="button-group">
<AddMatchDialog /> <AddMatchDialog />
@ -26,18 +35,20 @@ onMounted(() => {
<tr> <tr>
<th>RED</th> <th>RED</th>
<th>BLU</th> <th>BLU</th>
<th>Team</th>
<th>Match Date</th> <th>Match Date</th>
<th>logs.tf URL</th> <th>logs.tf URL</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="match in matchesStore.matches" :key="match.logsTfId"> <tr v-for="teamMatch in matches">
<td>{{ match.redScore }}</td> <td>{{ teamMatch.match.redScore }}</td>
<td>{{ match.blueScore }}</td> <td>{{ teamMatch.match.blueScore }}</td>
<td>{{ match.matchTime }}</td> <td>{{ teamMatch.teamColor == 'Blue' ? 'BLU' : 'RED' }}</td>
<td>{{ moment(teamMatch.match.matchTime).format("LL LT") }}</td>
<td> <td>
<a :href="`https://logs.tf/${match.logsTfId}`" target="_blank"> <a :href="`https://logs.tf/${teamMatch.match.logsTfId}`" target="_blank">
#{{ match.logsTfId }} #{{ teamMatch.match.logsTfId }}
</a> </a>
</td> </td>
</tr> </tr>

View File

@ -42,6 +42,9 @@ onMounted(() => {
<RouterLink class="tab" :to="{ name: 'team-settings/invites' }"> <RouterLink class="tab" :to="{ name: 'team-settings/invites' }">
Invites Invites
</RouterLink> </RouterLink>
<RouterLink class="tab" :to="{ name: 'team-settings/matches' }">
Matches
</RouterLink>
<hr> <hr>
<button class="destructive-on-hover icon-end" @click="leaveTeam"> <button class="destructive-on-hover icon-end" @click="leaveTeam">
Leave team Leave team

View File

@ -4,6 +4,7 @@ from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import MetaData from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from celery import Celery, Task
class BaseModel(DeclarativeBase): class BaseModel(DeclarativeBase):
pass pass
@ -33,9 +34,11 @@ def connect_db_with_app(database_uri: str | None, include_migrate=True):
print("Creating tables if they do not exist") print("Creating tables if they do not exist")
db.create_all() db.create_all()
def connect_celery_with_app(): def connect_celery_with_app() -> Celery:
if "celery" in app.extensions:
return app.extensions["celery"]
def celery_init_app(app): def celery_init_app(app):
from celery import Celery, Task
class FlaskTask(Task): class FlaskTask(Task):
def __call__(self, *args: object, **kwargs: object) -> object: def __call__(self, *args: object, **kwargs: object) -> object:
with app.app_context(): with app.app_context():
@ -55,7 +58,7 @@ def connect_celery_with_app():
) )
) )
app.config.from_prefixed_env() app.config.from_prefixed_env()
celery_init_app(app) return celery_init_app(app)
def create_app() -> Flask: def create_app() -> Flask:
return Flask(__name__) return Flask(__name__)

View File

@ -14,6 +14,11 @@ from models.player_team import PlayerTeam
from models.team_integration import TeamLogsTfIntegration from models.team_integration import TeamLogsTfIntegration
from celery import shared_task from celery import shared_task
celery = app_db.connect_celery_with_app()
@celery.on_after_configure.connect
def setup_periodic_task(sender, **kwargs):
sender.add_periodic_task(300.0, etl_periodic.s(), name="Fetch logs every 5 minutes")
FETCH_URL = "https://logs.tf/api/v1/log/{}" FETCH_URL = "https://logs.tf/api/v1/log/{}"
SEARCH_URL = "https://logs.tf/api/v1/log?limit=25?offset={}" SEARCH_URL = "https://logs.tf/api/v1/log?limit=25?offset={}"
@ -26,10 +31,13 @@ def get_log_ids(last_log_id: int):
for summary in response.json()["logs"]: for summary in response.json()["logs"]:
id: int = summary["id"] id: int = summary["id"]
if id == last_log_id: if id == last_log_id:
break print("Reached last log ID", id)
return
# yield models.match.RawLogSummary.from_response(summary) # yield models.match.RawLogSummary.from_response(summary)
yield id yield id
current = id current = id
sleep(5)
break
def extract(log_id: int) -> models.match.RawLogDetails: def extract(log_id: int) -> models.match.RawLogDetails:
response = requests.get(FETCH_URL.format(log_id)) response = requests.get(FETCH_URL.format(log_id))
@ -68,20 +76,6 @@ def extract_steam_ids(players: dict[str, models.match.LogPlayer]):
@shared_task @shared_task
def update_playtime(steam_ids: list[int]): def update_playtime(steam_ids: list[int]):
# update players with playtime (recalculate through aggregation)
#subquery = (
# app_db.db.session.query(
# PlayerTeam.id,
# func.sum(Match.duration).label("total_playtime")
# )
# .join(PlayerMatch, PlayerMatch.player_id == PlayerTeam.player_id)
# .join(Match, PlayerMatch.match_id == Match.logs_tf_id)
# .join(TeamMatch, TeamMatch.match_id == Match.logs_tf_id)
# .where(PlayerTeam.player_id.in_(steam_ids))
# .where(PlayerTeam.team_id == TeamMatch.team_id)
# .group_by(PlayerTeam.id)
# .subquery()
#)
steam_ids_int = list(map(lambda x: int(x), steam_ids)) steam_ids_int = list(map(lambda x: int(x), steam_ids))
ptp = ( ptp = (
select( select(
@ -121,18 +115,13 @@ def update_playtime(steam_ids: list[int]):
app_db.db.session.commit() app_db.db.session.commit()
def get_common_teams(steam_ids: list[str]): def get_common_teams(steam_ids: list[int]):
return ( return (
app_db.db.session.query( app_db.db.session.query(
PlayerTeam.team_id, PlayerTeam.team_id,
func.count(PlayerTeam.team_id), func.count(PlayerTeam.team_id),
TeamLogsTfIntegration.min_team_member_count,
#aggregate_func #aggregate_func
) )
.outerjoin(
TeamLogsTfIntegration,
TeamLogsTfIntegration.team_id == PlayerTeam.team_id
)
.where(PlayerTeam.player_id.in_(steam_ids)) .where(PlayerTeam.player_id.in_(steam_ids))
.group_by(PlayerTeam.team_id) .group_by(PlayerTeam.team_id)
.order_by(func.count(PlayerTeam.team_id).desc()) .order_by(func.count(PlayerTeam.team_id).desc())
@ -154,9 +143,6 @@ def transform(
.all() .all()
) )
if len(players) == 0:
return
if not existing_match: if not existing_match:
match = Match() match = Match()
match.logs_tf_id = log_id match.logs_tf_id = log_id
@ -169,7 +155,8 @@ def transform(
else: else:
match = existing_match match = existing_match
#app_db.db.session.add(match) if len(players) == 0:
return
for player in players: for player in players:
player_data = details["players"][steam64_to_steam3(player.steam_id)] player_data = details["players"][steam64_to_steam3(player.steam_id)]
@ -196,7 +183,9 @@ def transform(
row_tuple = tuple(row) row_tuple = tuple(row)
team_id = row_tuple[0] team_id = row_tuple[0]
player_count = row_tuple[1] player_count = row_tuple[1]
log_min_player_count = row_tuple[2] or 100 log_min_player_count = app_db.db.session.query(
TeamLogsTfIntegration.min_team_member_count
).where(TeamLogsTfIntegration.team_id == team_id).one_or_none() or 100
should_create_team_match = False should_create_team_match = False
@ -230,18 +219,22 @@ def load_specific_match(id: int, team_id: int | None):
) )
raw_match = extract(id) raw_match = extract(id)
print("Loading match: " + str(id))
app_db.db.session.bulk_save_objects(transform(id, raw_match, match, team_id)) app_db.db.session.bulk_save_objects(transform(id, raw_match, match, team_id))
app_db.db.session.commit() app_db.db.session.commit()
sleep(3) # avoid rate limiting if multiple tasks are queued sleep(3) # avoid rate limiting if multiple tasks are queued
def main(): @celery.task
def etl_periodic():
last: int = ( last: int = (
app_db.db.session.query( app_db.db.session.query(
func.max(models.match.Match.logs_tf_id) func.max(models.match.Match.logs_tf_id)
).scalar() ).scalar()
) or 3767233 ) or 3768715
for summary in get_log_ids(last): print("Last log ID: " + str(last))
print(summary)
sleep(3) for id in get_log_ids(last):
print("Found log: " + str(id))
load_specific_match.delay(id, None)

View File

@ -1,3 +1,4 @@
from typing import Optional
from flask import Blueprint, abort from flask import Blueprint, abort
from pydantic.v1 import validator from pydantic.v1 import validator
#from pydantic.functional_validators import field_validator #from pydantic.functional_validators import field_validator
@ -41,6 +42,7 @@ def get_match(player: Player, match_id: int, **_):
class SubmitMatchJson(BaseModel): class SubmitMatchJson(BaseModel):
match_ids: list[int] match_ids: list[int]
team_id: int
@validator("match_ids") @validator("match_ids")
@classmethod @classmethod
@ -59,15 +61,13 @@ class SubmitMatchJson(BaseModel):
) )
@requires_authentication @requires_authentication
def submit_match(json: SubmitMatchJson, **_): 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: for id in json.match_ids:
load_specific_match.delay(id, None) load_specific_match.delay(id, json.team_id)
return { }, 204 return { }, 204
class GetMatchQuery(BaseModel):
limit: Optional[int]
@api_match.get("/team/<int:team_id>") @api_match.get("/team/<int:team_id>")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
@ -77,14 +77,19 @@ def submit_match(json: SubmitMatchJson, **_):
) )
@requires_authentication @requires_authentication
@requires_team_membership() @requires_team_membership()
def get_matches_for_team(team_id: Team, **_): def get_matches_for_team(team_id: Team, query: GetMatchQuery, **_):
matches = ( q = (
db.session.query(TeamMatch) db.session.query(TeamMatch)
.where(TeamMatch.team_id == team_id) .where(TeamMatch.team_id == team_id)
.options(joinedload(TeamMatch.match)) .options(joinedload(TeamMatch.match))
.all() .order_by(TeamMatch.match_id.desc())
) )
if query and query.limit:
q = q.limit(query.limit)
matches = q.all()
return [TeamMatchSchema.from_model(match).dict(by_alias=True) for match in matches], 200 return [TeamMatchSchema.from_model(match).dict(by_alias=True) for match in matches], 200
@api_match.get("/player") @api_match.get("/player")

View File

@ -19,6 +19,7 @@ class TeamMatchSchema(spec.BaseModel):
match: "MatchSchema" match: "MatchSchema"
our_score: int our_score: int
their_score: int their_score: int
team_color: str
@classmethod @classmethod
def from_model(cls, model: "TeamMatch"): def from_model(cls, model: "TeamMatch"):
@ -29,6 +30,7 @@ class TeamMatchSchema(spec.BaseModel):
match=MatchSchema.from_model(model.match), match=MatchSchema.from_model(model.match),
our_score=our_score, our_score=our_score,
their_score=their_score, their_score=their_score,
team_color=model.team_color,
) )

View File

@ -135,7 +135,7 @@ def delete_team(player: Player, team_id: int):
db.session.commit() db.session.commit()
return make_response(200) return make_response(200)
@api_team.delete("/id/<int:team_id>/player/<int:target_player_id>/") @api_team.delete("/id/<int:team_id>/player/<target_player_id>/")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=None, HTTP_200=None,
@ -145,7 +145,8 @@ def delete_team(player: Player, team_id: int):
operation_id="remove_player_from_team" operation_id="remove_player_from_team"
) )
@requires_authentication @requires_authentication
def remove_player_from_team(player: Player, team_id: int, target_player_id: int, **kwargs): def remove_player_from_team(player: Player, team_id: int, target_player_id: str, **kwargs):
target_player_id: int = int(target_player_id)
player_team = db.session.query( player_team = db.session.query(
PlayerTeam PlayerTeam
).where( ).where(
@ -202,7 +203,7 @@ class AddPlayerJson(BaseModel):
team_role: PlayerTeam.TeamRole = PlayerTeam.TeamRole.Player team_role: PlayerTeam.TeamRole = PlayerTeam.TeamRole.Player
is_team_leader: bool = False is_team_leader: bool = False
@api_team.put("/id/<int:team_id>/player/<int:player_id>/") @api_team.put("/id/<int:team_id>/player/<player_id>/")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=None, HTTP_200=None,
@ -211,7 +212,8 @@ class AddPlayerJson(BaseModel):
), ),
operation_id="create_or_update_player" operation_id="create_or_update_player"
) )
def add_player(player: Player, team_id: int, player_id: int, json: AddPlayerJson): def add_player(player: Player, team_id: int, player_id: str, json: AddPlayerJson):
player_id: int = int(player_id)
player_team = db.session.query( player_team = db.session.query(
PlayerTeam PlayerTeam
).where( ).where(
@ -387,7 +389,7 @@ def view_team_members(player: Player, team_id: int, **kwargs):
class EditMemberRolesJson(BaseModel): class EditMemberRolesJson(BaseModel):
roles: list[RoleSchema] roles: list[RoleSchema]
@api_team.patch("/id/<int:team_id>/edit-player/<int:target_player_id>") @api_team.patch("/id/<int:team_id>/edit-player/<target_player_id>")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_204=None, HTTP_204=None,
@ -401,9 +403,10 @@ def edit_member_roles(
json: EditMemberRolesJson, json: EditMemberRolesJson,
player: Player, player: Player,
team_id: int, team_id: int,
target_player_id: int, target_player_id: str,
**kwargs, **kwargs,
): ):
target_player_id: int = int(target_player_id)
target_player = db.session.query( target_player = db.session.query(
PlayerTeam PlayerTeam
).where( ).where(

View File

@ -20,7 +20,7 @@ services:
# Flask service # Flask service
backend: backend:
container_name: backend container_name: backend-production
command: ["gunicorn", "-w", "4", "app:app", "-b", "0.0.0.0:5000"] command: ["gunicorn", "-w", "4", "app:app", "-b", "0.0.0.0:5000"]
image: backend-flask-production image: backend-flask-production
ports: ports:
@ -42,13 +42,13 @@ services:
# ETL job (runs with the same source as the backend) # ETL job (runs with the same source as the backend)
celery-worker: celery-worker:
container_name: worker container_name: worker-production
command: celery -A make_celery.celery_app worker --loglevel=info --concurrency=1 command: celery -A make_celery.celery_app worker --loglevel=info --concurrency=1 -B
image: backend-flask-production
environment: environment:
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0
- DATABASE_URI=postgresql+psycopg://db:5432 - DATABASE_URI=postgresql+psycopg://postgres:password@db:5432/availabilitf
image: backend-flask-production
volumes: volumes:
- ./backend-flask:/app - ./backend-flask:/app
networks: networks:
@ -68,7 +68,8 @@ services:
# Vue + Vite service # Vue + Vite service
frontend: frontend:
container_name: frontend container_name: frontend-production
image: frontend-production
build: build:
context: ./availabili.tf context: ./availabili.tf
dockerfile: Dockerfile.prod dockerfile: Dockerfile.prod

View File

@ -22,7 +22,7 @@ services:
# ETL job (runs with the same source as the backend) # ETL job (runs with the same source as the backend)
celery-worker: celery-worker:
container_name: worker container_name: worker
command: celery -A make_celery.celery_app worker --loglevel=info --concurrency=1 command: celery -A make_celery.celery_app worker --loglevel=info --concurrency=1 -B
environment: environment:
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0
@ -51,6 +51,8 @@ services:
context: ./availabili.tf context: ./availabili.tf
environment: environment:
VITE_API_URL: http://localhost:8000 # API endpoint VITE_API_URL: http://localhost:8000 # API endpoint
ports:
- 5173:5173
volumes: volumes:
- ./availabili.tf:/app - ./availabili.tf:/app
networks: networks:
@ -62,7 +64,7 @@ services:
ports: ports:
- "8000:80" - "8000:80"
volumes: volumes:
- ./nginx:/etc/nginx/conf.d - ./nginx/development.conf:/etc/nginx/nginx.conf
depends_on: depends_on:
- backend - backend
- frontend - frontend

View File

@ -1,24 +0,0 @@
server {
listen 80;
# Proxy for the Vite frontend
location / {
proxy_pass http://frontend:5173;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Proxy for the Flask backend API
location /api/ {
proxy_pass http://backend:5000;
proxy_set_header Host $host;
}
location /apidoc/ {
proxy_pass http://backend:5000;
proxy_set_header Host $host;
}
}

View File

@ -0,0 +1,30 @@
events {
worker_connections 1024;
}
http {
server {
listen 80;
# Proxy for the Vite frontend
location / {
proxy_pass http://frontend:5173;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Proxy for the Flask backend API
location /api/ {
proxy_pass http://backend:5000;
proxy_set_header Host $host;
}
location /apidoc/ {
proxy_pass http://backend:5000;
proxy_set_header Host $host;
}
}
}