Prepare for production

master
John Montagu, the 4th Earl of Sandvich 2024-12-10 18:18:40 -08:00
parent 64b2d129eb
commit 52d8ea5988
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
34 changed files with 366 additions and 386 deletions

View File

@ -13,17 +13,44 @@ Scheduling for TF2
OpenAPI documentation OpenAPI documentation
- [Flask-Migrate](https://flask-migrate.readthedocs.io/en/latest/) - [Flask-Migrate](https://flask-migrate.readthedocs.io/en/latest/)
(Alembic) for database migrations (Alembic) for database migrations
- [Celery](https://docs.celeryproject.org/en/stable/) for async tasks
- [Redis](https://redis.io/) for Celery broker
- **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 (dev)
```sh ```sh
docker compose build
docker compose up docker compose up
DATABASE_URI=sqlite:///db.sqlite3 flask db upgrade
``` ```
App will run at port 8000. App will run at port 8000.
## Setup (production)
Build the frontend app:
```sh
cd availabili.tf
npm run build
```
Build the rest of the containers:
```sh
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml up
```
Perform initial database migration:
```sh
docker exec -it backend bash
flask db upgrade
```
## OpenAPI ## OpenAPI
The backend will automatically serve its OpenAPI-compliant spec at The backend will automatically serve its OpenAPI-compliant spec at

View File

@ -0,0 +1,7 @@
FROM steebchen/nginx-spa:stable
COPY dist/ /app
EXPOSE 80
CMD ["nginx"]

View File

@ -1,103 +0,0 @@
<script setup lang="ts">
import { computed, defineModel, defineProps, ref } from "vue";
const model = defineModel();
const props = defineProps({
options: Array<String>,
isDisabled: Boolean,
});
const isOpen = ref(false);
const selectedOption = computed(() => props.options[model.value]);
function selectOption(index) {
model.value = index;
isOpen.value = false;
}
</script>
<template>
<div :class="{ 'dropdown-container': true, 'is-open': isOpen }">
<button @click="isOpen = !isOpen" :disabled="isDisabled">
{{ selectedOption }}
<i class="bi bi-caret-down-fill"></i>
</button>
<ul class="dropdown" v-if="isOpen" @blur="isOpen = false">
<li v-for="(option, i) in options" :key="i" @click="selectOption(i)">
<option :class="{ 'is-selected': i == model, 'option': true }">
{{ option }}
</option>
</li>
</ul>
</div>
</template>
<style scoped>
.dropdown-container {
display: inline-block;
border-radius: 8px;
}
.dropdown-container .option {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
text-align: left;
font-weight: 700;
font-size: 16px;
padding: 4px;
transition-duration: 200ms;
background-color: transparent;
cursor: pointer;
}
.dropdown-container .option {
border-radius: 0;
}
.dropdown-container .option:first-child {
border-radius: 8px 8px 0 0;
}
.dropdown-container .option:last-child {
border-radius: 0 0 8px 8px;
}
.dropdown-container .option:hover {
background-color: var(--crust);
}
.dropdown-container.is-open ul.dropdown {
box-shadow: 1px 1px 8px var(--shadow);
}
ul.dropdown {
display: block;
background-color: var(--base);
position: absolute;
margin-top: 8px;
padding: 0;
z-index: 2;
border-radius: 8px;
overflow: none;
}
ul.dropdown > li {
list-style-type: none;
}
.dropdown li > .option {
padding: 8px 16px;
font-weight: 500;
font-size: 14px;
border-radius: 0;
}
.dropdown li > .option.is-selected {
background-color: var(--accent-transparent);
color: var(--accent);
}
</style>

View File

@ -1,37 +1,37 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineModel, defineProps, reactive, ref, onMounted, onUnmounted, type PropType } from "vue"; import { computed, defineModel, defineProps, reactive, ref, onMounted, onUnmounted } from "vue";
import moment, { type Moment } from "moment"; import { type Moment } from "moment";
import { useScheduleStore } from "../stores/schedule"; import { useScheduleStore } from "../stores/schedule";
const scheduleStore = useScheduleStore(); const scheduleStore = useScheduleStore();
const model = defineModel(); const model = defineModel<number[]>({ required: true });
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 = withDefaults(defineProps<{
selectionMode: Number, selectionMode: number,
isDisabled: Boolean, isDisabled: boolean,
overlay: Array, overlay: number[] | undefined,
dateStart: Object as PropType<Moment>, dateStart: Moment,
firstHour: { firstHour: number,
type: Number, lastHour: number
default: 14 }>(), {
}, firstHour: 14,
lastHour: { lastHour: 22
type: Number,
default: 22,
},
}); });
const isEditing = computed(() => !props.isDisabled); const isEditing = computed(() => !props.isDisabled);
const selectionStart = reactive({ x: undefined, y: undefined }); type Coordinate = {
const selectionEnd = reactive({ x: undefined, y: undefined }); x?: number,
y?: number
};
const selectionStart = reactive<Coordinate>({ x: undefined, y: undefined });
const selectionEnd = reactive<Coordinate>({ x: undefined, y: undefined });
const isCtrlDown = ref(false); const isCtrlDown = ref(false);
const isShiftDown = ref(false); const isShiftDown = ref(false);
@ -77,30 +77,20 @@ const hours = computed(() => {
.map(x => x + props.firstHour); .map(x => x + props.firstHour);
}); });
const daysOfWeek = [
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat"
];
function getTimeAtCell(dayIndex: number, hour: number) { function getTimeAtCell(dayIndex: number, hour: number) {
return props.dateStart.clone() return props.dateStart.clone()
.add(dayIndex, "days") .add(dayIndex, "days")
.add(hour, "hours"); .add(hour, "hours");
} }
function onSlotMouseOver($event, x, y) { function onSlotMouseOver($event: MouseEvent, x: number, y: number) {
hoveredIndex.value = 24 * x + y; hoveredIndex.value = 24 * x + y;
if (!isEditing.value) { if (!isEditing.value) {
return; return;
} }
if ($event.buttons & 1 == 1) { if (($event.buttons & 1) == 1) {
isShiftDown.value = $event.shiftKey; isShiftDown.value = $event.shiftKey;
isCtrlDown.value = $event.ctrlKey; isCtrlDown.value = $event.ctrlKey;
@ -109,17 +99,16 @@ function onSlotMouseOver($event, x, y) {
} }
} }
function onSlotMouseLeave($event, x, y) { function onSlotMouseLeave(_: MouseEvent, x: number, y: number) {
let index = 24 * x + y; let index = 24 * x + y;
if (hoveredIndex.value == index) { if (hoveredIndex.value == index) {
hoveredIndex.value = undefined; hoveredIndex.value = undefined;
} }
} }
const isMouseDown = ref(false);
const selectionValue = ref(0); const selectionValue = ref(0);
function onSlotMouseDown($event, x, y) { function onSlotMouseDown($event: MouseEvent, x: number, y: number) {
if (!isEditing.value) { if (!isEditing.value) {
return; return;
} }
@ -138,7 +127,7 @@ function onSlotMouseDown($event, x, y) {
console.log("selected " + x + " " + y); console.log("selected " + x + " " + y);
} }
function onSlotMouseUp($event) { function onSlotMouseUp(_: MouseEvent) {
if (!isEditing.value || selectionStart.x == undefined) { if (!isEditing.value || selectionStart.x == undefined) {
return; return;
} }
@ -152,7 +141,7 @@ function onSlotMouseUp($event) {
selectionStart.x = undefined; selectionStart.x = undefined;
} }
function onSlotClick(dayIndex, hour) { function onSlotClick(dayIndex: number, hour: number) {
if (isEditing.value) { if (isEditing.value) {
return; return;
} }
@ -161,7 +150,7 @@ function onSlotClick(dayIndex, hour) {
scheduleStore.selectIndex(24 * dayIndex + hour); scheduleStore.selectIndex(24 * dayIndex + hour);
} }
function onKeyUp($event) { function onKeyUp($event: KeyboardEvent) {
switch ($event.key) { switch ($event.key) {
case "Shift": case "Shift":
isShiftDown.value = false; isShiftDown.value = false;
@ -172,7 +161,7 @@ function onKeyUp($event) {
} }
} }
function onKeyDown($event) { function onKeyDown($event: KeyboardEvent) {
switch ($event.key) { switch ($event.key) {
case "Shift": case "Shift":
isShiftDown.value = true; isShiftDown.value = true;
@ -206,7 +195,7 @@ function getAvailabilityCell(day: number, hour: number) {
const currentTimezone = computed(() => const currentTimezone = computed(() =>
Intl.DateTimeFormat().resolvedOptions().timeZone); Intl.DateTimeFormat().resolvedOptions().timeZone);
function getHour(offset, tz?) { function getHour(offset: number, tz?: string) {
let time = props.dateStart.clone() let time = props.dateStart.clone()
if (tz) { if (tz) {
time = time.tz(tz); time = time.tz(tz);

View File

@ -1,6 +1,7 @@
<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 { useTeamDetails } from "@/composables/team-details";
import { useEventsStore } from "@/stores/events";
import { useRosterStore } from "@/stores/roster"; import { useRosterStore } from "@/stores/roster";
import { useTeamsStore } from "@/stores/teams"; import { useTeamsStore } from "@/stores/teams";
import moment from "moment"; import moment from "moment";
@ -12,6 +13,7 @@ const router = useRouter();
const rosterStore = useRosterStore(); const rosterStore = useRosterStore();
const teamsStore = useTeamsStore(); const teamsStore = useTeamsStore();
const eventsStore = useEventsStore();
const { eventId } = useEventForm(); const { eventId } = useEventForm();
@ -24,7 +26,7 @@ const startTime = computed(() => {
}); });
const startTimeTeamTz = computed(() => { const startTimeTeamTz = computed(() => {
if (rosterStore.startTime) { if (rosterStore.startTime && team.value) {
// if team timezone is the same as ours, then do nothing // if team timezone is the same as ours, then do nothing
if (team.value?.tzTimezone === moment.tz.guess()) { if (team.value?.tzTimezone === moment.tz.guess()) {
return undefined; return undefined;
@ -52,8 +54,15 @@ function saveRoster() {
} }
onMounted(() => { onMounted(() => {
if (!team.value) { //if (!team.value) {
teamsStore.fetchTeam(teamId.value); // teamsStore.fetchTeam(teamId.value);
//}
if (eventId.value) {
eventsStore.fetchEvent(eventId.value)
.then((response) => {
teamsStore.fetchTeam(response.teamId);
});
} }
}); });
</script> </script>

View File

@ -1,76 +0,0 @@
<script setup lang="ts">
import type { TeamIntegrationSchema, TeamDiscordIntegrationSchema } from "@/client";
import { useTeamDetails } from "@/composables/team-details";
import { useTeamsStore } from "@/stores/teams";
import { computed } from "vue";
const props = defineProps<{
integration: TeamIntegrationSchema,
}>();
const teamsStore = useTeamsStore();
const { teamId } = useTeamDetails();
/*
const isDiscord = (x: TeamIntegrationSchema): x is TeamDiscordIntegrationSchema => x.integrationType === "team_discord_integrations";
const isDiscordIntegration = computed(() => {
return isDiscord(props.integration);
});
*/
const discordIntegration = computed(() => props.integration as TeamDiscordIntegrationSchema);
function deleteIntegration() {
teamsStore.deleteIntegration(teamId.value, props.integration.id);
}
function saveIntegration() {
teamsStore.updateIntegration(teamId.value, props.integration);
}
</script>
<template>
<details class="accordion">
<summary>
<span class="title">
<h2 v-if="discordIntegration">
Discord Integration
</h2>
<span class="aside">(id: {{ props.integration.id }})</span>
</span>
</summary>
<div class="form-group margin">
<h3>Webhook URL</h3>
<input v-model="discordIntegration.webhookUrl" />
</div>
<div class="button-group">
<button class="destructive-on-hover" @click="deleteIntegration">
<i class="bi bi-trash margin" />
Delete
</button>
<button @click="saveIntegration">Save</button>
</div>
</details>
</template>
<style scoped>
.button-group {
display: flex;
gap: 4px;
justify-content: end;
}
summary > .title {
display: flex;
align-items: center;
gap: 0.5em;
}
summary .aside {
font-size: 1rem;
}
</style>

View File

@ -3,6 +3,7 @@ import { computed, type PropType, ref, watch } from "vue";
import { useTeamsStore } from "../stores/teams"; import { useTeamsStore } from "../stores/teams";
import { useRosterStore } from "../stores/roster"; import { useRosterStore } from "../stores/roster";
import { type ViewTeamMembersResponse, type TeamSchema, type RoleSchema } from "@/client"; import { type ViewTeamMembersResponse, type TeamSchema, type RoleSchema } from "@/client";
// @ts-expect-error
import SvgIcon from "@jamescoyle/vue-icon"; import SvgIcon from "@jamescoyle/vue-icon";
import { mdiCrown } from "@mdi/js"; import { mdiCrown } from "@mdi/js";
import RoleTag from "../components/RoleTag.vue"; import RoleTag from "../components/RoleTag.vue";

View File

@ -1,17 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { type ViewTeamMembersResponse } from "@/client"; import { type RoleSchema, type ViewTeamMembersResponse } from "@/client";
import { useRosterStore } from "../stores/roster"; import { useRosterStore } from "../stores/roster";
const rosterStore = useRosterStore(); const rosterStore = useRosterStore();
const props = defineProps({ const props = defineProps<{
role: String, role: string,
player: Object as PropType<ViewTeamMembersResponse>, player: ViewTeamMembersResponse
}); }>();
const roleObject = defineModel(); const roleObject = defineModel<RoleSchema>();
function toggle(isMain) { function toggle(isMain: boolean) {
if (isMain == roleObject.value?.isMain) { if (isMain == roleObject.value?.isMain) {
roleObject.value = undefined; roleObject.value = undefined;
} else { } else {

View File

@ -9,10 +9,10 @@ const scheduleStore = useScheduleStore();
const router = useRouter(); const router = useRouter();
const selectedTimeTz = computed(() => const selectedTimeTz = computed(() =>
props.selectedTime.clone().tz(scheduleStore.team?.tzTimezone)); props.selectedTime?.clone().tz(scheduleStore.team?.tzTimezone));
const isTeamTzLocal = computed(() => { const isTeamTzLocal = computed(() => {
return selectedTimeTz.value.utcOffset() == props.selectedTime.utcOffset(); return selectedTimeTz.value?.utcOffset() == props.selectedTime?.utcOffset();
}); });
//const props = defineProps({ //const props = defineProps({
@ -53,7 +53,7 @@ function scheduleRoster() {
{{ 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>
</h4> </h4>
<button @click="scheduleRoster" v-if="selectedTime"> <button @click="scheduleRoster" v-if="selectedTime">

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useScheduleStore } from "../stores/schedule"; import { useScheduleStore } from "../stores/schedule";
import { computed, type PropType } from "vue"; import { computed } from "vue";
import { type AvailabilitySchema } from "@/client"; import { type AvailabilitySchema } from "@/client";
import { CheckboxIndicator, CheckboxRoot } from "radix-vue"; import { CheckboxIndicator, CheckboxRoot } from "radix-vue";
@ -8,8 +8,6 @@ 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 (props.player?.availability) { if (props.player?.availability) {
if (hoveredIndex.value) { if (hoveredIndex.value) {

View File

@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Moment } from "moment";
import { computed, defineModel } from "vue"; import { computed, defineModel } from "vue";
const model = defineModel(); const model = defineModel<Moment>({ required: true });
const props = defineProps({ defineProps({
isDisabled: Boolean, isDisabled: Boolean,
}); });

View File

@ -3,6 +3,7 @@ import "vue-select/dist/vue-select.css";
import { createApp } from "vue"; import { createApp } from "vue";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
// @ts-expect-error
import VueSelect from "vue-select"; import VueSelect from "vue-select";
import { TooltipDirective } from "vue3-tooltip"; import { TooltipDirective } from "vue3-tooltip";
import "vue3-tooltip/tooltip.css"; import "vue3-tooltip/tooltip.css";

View File

@ -30,7 +30,8 @@ export const useAuthStore = defineStore("auth", () => {
} }
async function login(queryParams: LocationQuery) { async function login(queryParams: LocationQuery) {
return fetch(import.meta.env.VITE_API_BASE_URL + "/login/authenticate", { // TODO: replace with client call once it's implemented
return fetch("/api/login/authenticate", {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },

View File

@ -107,7 +107,11 @@ export const useRosterStore = defineStore("roster", () => {
} }
} }
selectedPlayers[role] = player; if (player) {
selectedPlayers[role] = player;
} else {
delete selectedPlayers[role];
}
} }
function fetchAvailablePlayers(startTime: number, teamId: number) { function fetchAvailablePlayers(startTime: number, teamId: number) {

View File

@ -24,15 +24,15 @@ export const useIntegrationsStore = defineStore("integrations", () => {
} }
function setIntegrations(schema: TeamIntegrationSchema) { function setIntegrations(schema: TeamIntegrationSchema) {
discordIntegration.value = schema.discordIntegration; discordIntegration.value = schema.discordIntegration ?? undefined;
logsTfIntegration.value = schema.logsTfIntegration; logsTfIntegration.value = schema.logsTfIntegration ?? undefined;
hasLoaded.value = true; hasLoaded.value = true;
} }
async function updateIntegrations(teamId: number) { async function updateIntegrations(teamId: number) {
const body: TeamIntegrationSchema = { const body: TeamIntegrationSchema = {
discordIntegration: discordIntegration.value, discordIntegration: discordIntegration.value ?? null,
logsTfIntegration: logsTfIntegration.value, logsTfIntegration: logsTfIntegration.value ?? null,
}; };
const response = await client.default.updateIntegrations(teamId.toString(), body); const response = await client.default.updateIntegrations(teamId.toString(), body);
setIntegrations(response); setIntegrations(response);

View File

@ -1,13 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import PlayerCard from "../components/PlayerCard.vue"; import PlayerCard from "../components/PlayerCard.vue";
import { computed, reactive, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRosterStore } from "../stores/roster"; import { useRosterStore } from "../stores/roster";
import { RouterLink, useRoute } from "vue-router"; import { 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";
import { useEventForm } from "@/composables/event-form"; import { useEventForm } from "@/composables/event-form";
import Loader from "@/components/Loader.vue";
import LoaderContainer from "@/components/LoaderContainer.vue"; import LoaderContainer from "@/components/LoaderContainer.vue";
const rosterStore = useRosterStore(); const rosterStore = useRosterStore();
@ -69,16 +68,22 @@ onMounted(async () => {
is-roster /> is-roster />
</div> </div>
<div class="form-group margin column" v-if="rosterStore.selectedRole"> <div class="form-group margin column" v-if="rosterStore.selectedRole">
<PlayerCard v-for="player in rosterStore.mainRoles" <PlayerCard
:player="player" v-for="player in rosterStore.mainRoles"
:role-title="player.role" /> :player="player"
:role-title="player.role"
:is-roster="false"
/>
<span v-if="!hasAvailablePlayers && rosterStore.selectedRole"> <span v-if="!hasAvailablePlayers && rosterStore.selectedRole">
No players are currently available for this role. No players are currently available for this role.
</span> </span>
<h3 v-if="hasAlternates">Alternates</h3> <h3 v-if="hasAlternates">Alternates</h3>
<PlayerCard v-for="player in rosterStore.alternateRoles" <PlayerCard
:player="player" v-for="player in rosterStore.alternateRoles"
:role-title="player.role" /> :player="player"
:role-title="player.role"
:is-roster="false"
/>
<div class="action-buttons"> <div class="action-buttons">
<button class="accent" @click="closeSelection"> <button class="accent" @click="closeSelection">
<i class="bi bi-check" /> <i class="bi bi-check" />

View File

@ -1,85 +0,0 @@
<script setup lang="ts">
import PlayerCard from "../components/PlayerCard.vue";
import RoleSlot from "../components/RoleSlot.vue";
import PlayerTeamRole from "../player.ts";
import { computed, reactive } from "vue";
import { useRosterStore } from "../stores/roster";
const rosterStore = useRosterStore();
const hasAvailablePlayers = computed(() => {
return rosterStore.availablePlayerRoles.length > 0;
});
</script>
<template>
<main>
<h1 class="roster-title">
Roster for Snus Brotherhood
<emph class="aside date">Aug. 13, 2036 @ 11:30 PM EST</emph>
</h1>
<div class="columns">
<div class="column">
<PlayerCard v-for="role in rosterStore.neededRoles"
:player="rosterStore.selectedPlayers[role]"
:role-title="role"
is-roster />
</div>
<div class="column">
<h3 v-if="hasAvailablePlayers">Available</h3>
<PlayerCard
v-for="player in rosterStore.definitelyAvailable"
:player="player"
:role-title="player.role"
:is-roster="false"
/>
<span v-if="!hasAvailablePlayers">
No players are currently available for this role.
</span>
</div>
<div class="column">
<h3 v-if="hasAvailablePlayers">Available if needed</h3>
<PlayerCard
v-for="player in rosterStore.canBeAvailable"
:player="player"
:role-title="player.role"
:is-roster="false"
/>
</div>
</div>
</main>
</template>
<style scoped>
.columns {
display: flex;
flex-direction: row;
}
.column {
display: flex;
flex-grow: 1;
margin-left: 4em;
margin-right: 4em;
flex-direction: column;
row-gap: 8px;
width: 100%;
}
.column h3 {
font-weight: 700;
font-size: 14px;
text-transform: uppercase;
color: var(--overlay-0);
}
.roster-title {
display: flex;
gap: 0.5em;
}
emph.aside.date {
font-size: 14px;
vertical-align: middle;
}
</style>

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import AvailabilityGrid from "../components/AvailabilityGrid.vue"; import AvailabilityGrid from "../components/AvailabilityGrid.vue";
import AvailabilityComboBox from "../components/AvailabilityComboBox.vue";
import WeekSelectionBox from "../components/WeekSelectionBox.vue"; import WeekSelectionBox from "../components/WeekSelectionBox.vue";
import SchedulePlayerList from "../components/SchedulePlayerList.vue"; import SchedulePlayerList from "../components/SchedulePlayerList.vue";
import { computed, onMounted, reactive, ref, watch } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
@ -62,7 +61,7 @@ onMounted(() => {
options.value = Object.values(teamsList.teams); options.value = Object.values(teamsList.teams);
// select team with id in query parameter if exists // select team with id in query parameter if exists
const queryTeam = teamsList.teams.find(x => x.id == route.query.teamId); const queryTeam = teamsList.teams.find(x => x.id == Number(route.query.teamId));
if (queryTeam) { if (queryTeam) {
selectedTeam.value = queryTeam; selectedTeam.value = queryTeam;
schedule.team = queryTeam; schedule.team = queryTeam;

View File

@ -76,11 +76,11 @@ 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="/">
<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="true">No recent matches.</em>
<MatchCard v-else /> <MatchCard v-else />

View File

@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import DiscordIntegrationForm from "@/components/DiscordIntegrationForm.vue"; import DiscordIntegrationForm from "@/components/DiscordIntegrationForm.vue";
import IntegrationDetails from "@/components/IntegrationDetails.vue";
import LoaderContainer from "@/components/LoaderContainer.vue"; import LoaderContainer from "@/components/LoaderContainer.vue";
import LogsTfIntegrationForm from "@/components/LogsTfIntegrationForm.vue"; import LogsTfIntegrationForm from "@/components/LogsTfIntegrationForm.vue";
import { useTeamDetails } from "@/composables/team-details"; import { useTeamDetails } from "@/composables/team-details";
import { useTeamsStore } from "@/stores/teams"; import { useTeamsStore } from "@/stores/teams";
import { useIntegrationsStore } from "@/stores/teams/integrations"; import { useIntegrationsStore } from "@/stores/teams/integrations";
import { computed, onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
const teamsStore = useTeamsStore(); const teamsStore = useTeamsStore();
const integrationsStore = useIntegrationsStore(); const integrationsStore = useIntegrationsStore();

View File

@ -10,5 +10,8 @@
{ {
"path": "./tsconfig.vitest.json" "path": "./tsconfig.vitest.json"
} }
] ],
"compilerOptions": {
"allowImportingTsExtensions": true
}
} }

View File

@ -0,0 +1,19 @@
# Use an official Python runtime as a parent image
FROM python:3.12-slim
COPY requirements.txt /
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
WORKDIR /app
# Expose the Flask development server port
EXPOSE 5000
# Set the Flask environment to development
ENV FLASK_APP=app.py
ENV FLASK_ENV=development
# Command to run the Flask application
CMD ["flask", "run", "--host=0.0.0.0", "--port=5000", "--debug"]

View File

@ -1,4 +1,5 @@
from flask import Blueprint, make_response, request from flask import Blueprint, make_response, request
import flask_migrate
from app_db import app, connect_celery_with_app, connect_db_with_app from app_db import app, connect_celery_with_app, connect_db_with_app
import login import login
@ -9,7 +10,7 @@ import user
import events import events
import match import match
connect_db_with_app() connect_db_with_app(None)
connect_celery_with_app() connect_celery_with_app()
api = Blueprint("api", __name__, url_prefix="/api") api = Blueprint("api", __name__, url_prefix="/api")

View File

@ -16,7 +16,10 @@ convention = {
"pk": "pk_%(table_name)s" "pk": "pk_%(table_name)s"
} }
def connect_db_with_app(database_uri = "sqlite:///db.sqlite3", include_migrate=True): def connect_db_with_app(database_uri: str | None, include_migrate=True):
database_uri = database_uri or environ.get("DATABASE_URI")
if not database_uri:
raise ValueError("Database URI is not provided")
print("Connecting to database: " + database_uri) print("Connecting to database: " + database_uri)
app.config["SQLALCHEMY_DATABASE_URI"] = database_uri app.config["SQLALCHEMY_DATABASE_URI"] = database_uri
db.init_app(app) db.init_app(app)
@ -24,9 +27,11 @@ def connect_db_with_app(database_uri = "sqlite:///db.sqlite3", include_migrate=T
migrate.init_app(app, db) migrate.init_app(app, db)
with app.app_context(): with app.app_context():
print("Running dialect: " + db.engine.dialect.name) print("Running dialect: " + db.engine.dialect.name)
import models.match
import models.team_match import models as _
import models.player_match if environ.get("FLASK_ENV") == "production":
print("Creating tables if they do not exist")
db.create_all()
def connect_celery_with_app(): def connect_celery_with_app():
def celery_init_app(app): def celery_init_app(app):

View File

@ -2,7 +2,7 @@ from collections.abc import Generator
from datetime import timedelta, datetime from datetime import timedelta, datetime
from time import sleep from time import sleep
import requests import requests
from sqlalchemy.sql import func, update from sqlalchemy.sql import exists, func, select, update
from sqlalchemy.types import DATETIME, Interval from sqlalchemy.types import DATETIME, Interval
import app_db import app_db
import models.match import models.match
@ -69,39 +69,59 @@ 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) # update players with playtime (recalculate through aggregation)
subquery = ( #subquery = (
app_db.db.session.query( # app_db.db.session.query(
PlayerTeam.id, # PlayerTeam.id,
#func.datetime(func.sum(Match.duration), "unixepoch").label("total_playtime") # func.sum(Match.duration).label("total_playtime")
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))
ptp = (
select(
PlayerTeam.id.label("id"),
func.sum(Match.duration).label("playtime")
) )
.select_from(PlayerTeam)
.join(PlayerMatch, PlayerMatch.player_id == PlayerTeam.player_id) .join(PlayerMatch, PlayerMatch.player_id == PlayerTeam.player_id)
.join(TeamMatch, TeamMatch.team_id == PlayerTeam.team_id) .join(Match, PlayerMatch.match_id == Match.logs_tf_id)
.join(Match, Match.logs_tf_id == TeamMatch.match_id) .join(TeamMatch, TeamMatch.match_id == Match.logs_tf_id)
.where(PlayerTeam.player_id.in_(steam_ids)) .where(
PlayerTeam.player_id.in_(steam_ids_int),
PlayerTeam.team_id == TeamMatch.team_id
)
.group_by(PlayerTeam.id) .group_by(PlayerTeam.id)
.subquery() .cte("ptp")
) )
update_query = app_db.db.session.execute( stmt = (
update(PlayerTeam) update(PlayerTeam)
.where(PlayerTeam.id == subquery.c.id) .values(
.values(playtime=subquery.c.total_playtime) playtime=(
select(ptp.c.playtime)
.where(PlayerTeam.id == ptp.c.id)
)
)
.where(
exists(
select(1)
.select_from(ptp)
.where(PlayerTeam.id == ptp.c.id)
)
)
) )
app_db.db.session.execute(stmt)
app_db.db.session.commit()
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")
def get_common_teams(steam_ids: list[str]):
return ( return (
app_db.db.session.query( app_db.db.session.query(
PlayerTeam.team_id, PlayerTeam.team_id,
@ -198,7 +218,7 @@ def transform(
yield team_match yield team_match
#app_db.db.session.flush() #app_db.db.session.flush()
update_playtime.delay(list(map(lambda x: x.steam_id, players))) update_playtime.delay(list(map(lambda x: str(x), steam_ids)))
@shared_task @shared_task

View File

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

View File

@ -0,0 +1,17 @@
from .player import *
from .auth_session import *
from .team import *
from .team_invite import *
from .team_integration import *
from .player_team import *
from .player_team_availability import *
from .player_team_role import *
from .match import *
from .player_match import *
from .team_match import *
from .event import *
from .player_event import *

View File

@ -26,3 +26,7 @@ discord-webhook # for sending messages to Discord webhooks
celery[redis] celery[redis]
Flask-Testing Flask-Testing
# for production
gunicorn
psycopg[binary]

View File

@ -107,7 +107,7 @@ def update_team(player_team: PlayerTeam, team_id: int, json: CreateTeamJson, **k
return TeamSchema.from_model(team).dict(by_alias=True), 200 return TeamSchema.from_model(team).dict(by_alias=True), 200
@api_team.delete("/id/<team_id>/") @api_team.delete("/id/<int:team_id>/")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=None, HTTP_200=None,
@ -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/<team_id>/player/<target_player_id>/") @api_team.delete("/id/<int:team_id>/player/<int:target_player_id>/")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=None, HTTP_200=None,
@ -202,7 +202,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/<team_id>/player/<player_id>/") @api_team.put("/id/<int:team_id>/player/<int:player_id>/")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=None, HTTP_200=None,
@ -271,7 +271,7 @@ def view_teams(**kwargs):
return response.dict(by_alias=True) return response.dict(by_alias=True)
abort(404) abort(404)
@api_team.get("/id/<team_id>/") @api_team.get("/id/<int:team_id>/")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=ViewTeamResponse, HTTP_200=ViewTeamResponse,
@ -321,7 +321,7 @@ class ViewTeamMembersResponse(PlayerSchema):
created_at: datetime created_at: datetime
is_team_leader: bool = False is_team_leader: bool = False
@api_team.get("/id/<team_id>/players") @api_team.get("/id/<int:team_id>/players")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=list[ViewTeamMembersResponse], HTTP_200=list[ViewTeamMembersResponse],
@ -387,7 +387,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/<team_id>/edit-player/<target_player_id>") @api_team.patch("/id/<int:team_id>/edit-player/<int:target_player_id>")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_204=None, HTTP_204=None,

View File

@ -11,7 +11,7 @@ from app_db import db
api_team_integration = Blueprint("team_integration", __name__) api_team_integration = Blueprint("team_integration", __name__)
@api_team_integration.get("/id/<team_id>/integrations") @api_team_integration.get("/id/<int:team_id>/integrations")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=TeamIntegrationSchema, HTTP_200=TeamIntegrationSchema,
@ -32,7 +32,7 @@ def get_integrations(player_team: PlayerTeam, **_):
return team.get_integrations().dict(by_alias=True) return team.get_integrations().dict(by_alias=True)
@api_team_integration.put("/id/<team_id>/integrations") @api_team_integration.put("/id/<int:team_id>/integrations")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=TeamIntegrationSchema, HTTP_200=TeamIntegrationSchema,

View File

@ -13,7 +13,7 @@ from spec import BaseModel, spec
api_team_invite = Blueprint("team_invite", __name__) api_team_invite = Blueprint("team_invite", __name__)
@api_team_invite.get("/id/<team_id>/invite") @api_team_invite.get("/id/<int:team_id>/invite")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=list[TeamInviteSchema], HTTP_200=list[TeamInviteSchema],
@ -39,7 +39,7 @@ def get_invites(team_id: int, **_):
return list(map(map_invite_to_schema, invites)), 200 return list(map(map_invite_to_schema, invites)), 200
@api_team_invite.post("/id/<team_id>/invite") @api_team_invite.post("/id/<int:team_id>/invite")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_200=TeamInviteSchema, HTTP_200=TeamInviteSchema,
@ -124,7 +124,7 @@ def consume_invite(player: Player, key: str, **_):
return ConsumeInviteResponse(team_id=team_id).dict(by_alias=True), 200 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/<int:team_id>/invite/<key>")
@spec.validate( @spec.validate(
resp=Response( resp=Response(
HTTP_204=None, HTTP_204=None,

View File

@ -0,0 +1,102 @@
version: '3.9'
services:
db:
container_name: db
image: postgres
ports:
- 5432:5432
networks:
- prod-network
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: availabilitf
volumes:
- db-data:/var/lib/postgres/data
- /var/log/postgres/logs:/var/lib/postgres/logs
restart: unless-stopped
command: ["postgres", "-c", "logging_collector=on", "-c", "log_directory=/var/lib/postgresql/logs", "-c", "log_filename=postgresql.log", "-c", "log_statement=all"]
# Flask service
backend:
container_name: backend
command: ["gunicorn", "-w", "4", "app:app", "-b", "0.0.0.0:5000"]
image: backend-flask-production
ports:
- 5000:5000
build:
context: ./backend-flask
volumes:
- ./backend-flask:/app
networks:
- prod-network
environment:
- FLASK_DEBUG=0
- FLASK_CELERY_BROKER_URL=redis://redis:6379/0
- FLASK_CELERY_RESULT_BACKEND=redis://redis:6379/0
- DATABASE_URI=postgresql+psycopg://postgres:password@db:5432/availabilitf
depends_on:
- redis
- db
# 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
- DATABASE_URI=postgresql+psycopg://db:5432
image: backend-flask-production
volumes:
- ./backend-flask:/app
networks:
- prod-network
depends_on:
- redis
- db
# message broker
redis:
image: redis:alpine
container_name: redis
networks:
- prod-network
ports:
- 6379:6379
# Vue + Vite service
frontend:
container_name: frontend
build:
context: ./availabili.tf
dockerfile: Dockerfile.prod
ports:
- 8001:8000
#environment:
# VITE_API_URL: http://localhost:8000 # API endpoint
#volumes:
# - ./availabili.tf:/app
networks:
- prod-network
# NGINX service
nginx:
image: nginx:latest
ports:
- "8000:80"
volumes:
- ./nginx/production.conf:/etc/nginx/nginx.conf
depends_on:
- backend
- frontend
networks:
- prod-network
networks:
prod-network:
driver: bridge
volumes:
db-data:

View File

@ -15,6 +15,7 @@ services:
- FLASK_DEBUG=1 - FLASK_DEBUG=1
- FLASK_CELERY_BROKER_URL=redis://redis:6379/0 - FLASK_CELERY_BROKER_URL=redis://redis:6379/0
- FLASK_CELERY_RESULT_BACKEND=redis://redis:6379/0 - FLASK_CELERY_RESULT_BACKEND=redis://redis:6379/0
- DATABASE_URI=sqlite:///db.sqlite3
depends_on: depends_on:
- redis - redis
@ -25,6 +26,7 @@ services:
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=sqlite:///db.sqlite3
image: backend-flask image: backend-flask
volumes: volumes:
- ./backend-flask:/app - ./backend-flask:/app

View File

@ -0,0 +1,30 @@
events {
worker_connections 1024;
}
http {
server {
listen 80;
# Proxy for the Vite frontend
location / {
proxy_pass http://frontend:80;
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;
}
}
}