Prepare for production
parent
64b2d129eb
commit
52d8ea5988
27
README.md
27
README.md
|
@ -13,17 +13,44 @@ Scheduling for TF2
|
|||
OpenAPI documentation
|
||||
- [Flask-Migrate](https://flask-migrate.readthedocs.io/en/latest/)
|
||||
(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)
|
||||
(production) / SQLite (development)
|
||||
|
||||
## Setup (dev)
|
||||
|
||||
```sh
|
||||
docker compose build
|
||||
docker compose up
|
||||
DATABASE_URI=sqlite:///db.sqlite3 flask db upgrade
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
The backend will automatically serve its OpenAPI-compliant spec at
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
FROM steebchen/nginx-spa:stable
|
||||
|
||||
COPY dist/ /app
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx"]
|
|
@ -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>
|
|
@ -1,37 +1,37 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, defineModel, defineProps, reactive, ref, onMounted, onUnmounted, type PropType } from "vue";
|
||||
import moment, { type Moment } from "moment";
|
||||
import { computed, defineModel, defineProps, reactive, ref, onMounted, onUnmounted } from "vue";
|
||||
import { type Moment } from "moment";
|
||||
import { useScheduleStore } from "../stores/schedule";
|
||||
|
||||
const scheduleStore = useScheduleStore();
|
||||
|
||||
const model = defineModel();
|
||||
const model = defineModel<number[]>({ required: true });
|
||||
|
||||
const selectedTime = defineModel("selectedTime");
|
||||
|
||||
const selectedIndex = defineModel("selectedIndex");
|
||||
|
||||
const hoveredIndex = defineModel("hoveredIndex");
|
||||
|
||||
const props = defineProps({
|
||||
selectionMode: Number,
|
||||
isDisabled: Boolean,
|
||||
overlay: Array,
|
||||
dateStart: Object as PropType<Moment>,
|
||||
firstHour: {
|
||||
type: Number,
|
||||
default: 14
|
||||
},
|
||||
lastHour: {
|
||||
type: Number,
|
||||
default: 22,
|
||||
},
|
||||
const props = withDefaults(defineProps<{
|
||||
selectionMode: number,
|
||||
isDisabled: boolean,
|
||||
overlay: number[] | undefined,
|
||||
dateStart: Moment,
|
||||
firstHour: number,
|
||||
lastHour: number
|
||||
}>(), {
|
||||
firstHour: 14,
|
||||
lastHour: 22
|
||||
});
|
||||
|
||||
const isEditing = computed(() => !props.isDisabled);
|
||||
|
||||
const selectionStart = reactive({ x: undefined, y: undefined });
|
||||
const selectionEnd = reactive({ x: undefined, y: undefined });
|
||||
type Coordinate = {
|
||||
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 isShiftDown = ref(false);
|
||||
|
||||
|
@ -77,30 +77,20 @@ const hours = computed(() => {
|
|||
.map(x => x + props.firstHour);
|
||||
});
|
||||
|
||||
const daysOfWeek = [
|
||||
"Sun",
|
||||
"Mon",
|
||||
"Tue",
|
||||
"Wed",
|
||||
"Thu",
|
||||
"Fri",
|
||||
"Sat"
|
||||
];
|
||||
|
||||
function getTimeAtCell(dayIndex: number, hour: number) {
|
||||
return props.dateStart.clone()
|
||||
.add(dayIndex, "days")
|
||||
.add(hour, "hours");
|
||||
}
|
||||
|
||||
function onSlotMouseOver($event, x, y) {
|
||||
function onSlotMouseOver($event: MouseEvent, x: number, y: number) {
|
||||
hoveredIndex.value = 24 * x + y;
|
||||
|
||||
if (!isEditing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event.buttons & 1 == 1) {
|
||||
if (($event.buttons & 1) == 1) {
|
||||
isShiftDown.value = $event.shiftKey;
|
||||
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;
|
||||
if (hoveredIndex.value == index) {
|
||||
hoveredIndex.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const isMouseDown = ref(false);
|
||||
const selectionValue = ref(0);
|
||||
|
||||
function onSlotMouseDown($event, x, y) {
|
||||
function onSlotMouseDown($event: MouseEvent, x: number, y: number) {
|
||||
if (!isEditing.value) {
|
||||
return;
|
||||
}
|
||||
|
@ -138,7 +127,7 @@ function onSlotMouseDown($event, x, y) {
|
|||
console.log("selected " + x + " " + y);
|
||||
}
|
||||
|
||||
function onSlotMouseUp($event) {
|
||||
function onSlotMouseUp(_: MouseEvent) {
|
||||
if (!isEditing.value || selectionStart.x == undefined) {
|
||||
return;
|
||||
}
|
||||
|
@ -152,7 +141,7 @@ function onSlotMouseUp($event) {
|
|||
selectionStart.x = undefined;
|
||||
}
|
||||
|
||||
function onSlotClick(dayIndex, hour) {
|
||||
function onSlotClick(dayIndex: number, hour: number) {
|
||||
if (isEditing.value) {
|
||||
return;
|
||||
}
|
||||
|
@ -161,7 +150,7 @@ function onSlotClick(dayIndex, hour) {
|
|||
scheduleStore.selectIndex(24 * dayIndex + hour);
|
||||
}
|
||||
|
||||
function onKeyUp($event) {
|
||||
function onKeyUp($event: KeyboardEvent) {
|
||||
switch ($event.key) {
|
||||
case "Shift":
|
||||
isShiftDown.value = false;
|
||||
|
@ -172,7 +161,7 @@ function onKeyUp($event) {
|
|||
}
|
||||
}
|
||||
|
||||
function onKeyDown($event) {
|
||||
function onKeyDown($event: KeyboardEvent) {
|
||||
switch ($event.key) {
|
||||
case "Shift":
|
||||
isShiftDown.value = true;
|
||||
|
@ -206,7 +195,7 @@ function getAvailabilityCell(day: number, hour: number) {
|
|||
const currentTimezone = computed(() =>
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
|
||||
function getHour(offset, tz?) {
|
||||
function getHour(offset: number, tz?: string) {
|
||||
let time = props.dateStart.clone()
|
||||
if (tz) {
|
||||
time = time.tz(tz);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { useEventForm } from "@/composables/event-form";
|
||||
import { useTeamDetails } from "@/composables/team-details";
|
||||
import { useEventsStore } from "@/stores/events";
|
||||
import { useRosterStore } from "@/stores/roster";
|
||||
import { useTeamsStore } from "@/stores/teams";
|
||||
import moment from "moment";
|
||||
|
@ -12,6 +13,7 @@ const router = useRouter();
|
|||
|
||||
const rosterStore = useRosterStore();
|
||||
const teamsStore = useTeamsStore();
|
||||
const eventsStore = useEventsStore();
|
||||
|
||||
const { eventId } = useEventForm();
|
||||
|
||||
|
@ -24,7 +26,7 @@ const startTime = 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.value?.tzTimezone === moment.tz.guess()) {
|
||||
return undefined;
|
||||
|
@ -52,8 +54,15 @@ function saveRoster() {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!team.value) {
|
||||
teamsStore.fetchTeam(teamId.value);
|
||||
//if (!team.value) {
|
||||
// teamsStore.fetchTeam(teamId.value);
|
||||
//}
|
||||
|
||||
if (eventId.value) {
|
||||
eventsStore.fetchEvent(eventId.value)
|
||||
.then((response) => {
|
||||
teamsStore.fetchTeam(response.teamId);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -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>
|
|
@ -3,6 +3,7 @@ import { computed, type PropType, ref, watch } from "vue";
|
|||
import { useTeamsStore } from "../stores/teams";
|
||||
import { useRosterStore } from "../stores/roster";
|
||||
import { type ViewTeamMembersResponse, type TeamSchema, type RoleSchema } from "@/client";
|
||||
// @ts-expect-error
|
||||
import SvgIcon from "@jamescoyle/vue-icon";
|
||||
import { mdiCrown } from "@mdi/js";
|
||||
import RoleTag from "../components/RoleTag.vue";
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import { type ViewTeamMembersResponse } from "@/client";
|
||||
import { type RoleSchema, type ViewTeamMembersResponse } from "@/client";
|
||||
import { useRosterStore } from "../stores/roster";
|
||||
|
||||
const rosterStore = useRosterStore();
|
||||
|
||||
const props = defineProps({
|
||||
role: String,
|
||||
player: Object as PropType<ViewTeamMembersResponse>,
|
||||
});
|
||||
const props = defineProps<{
|
||||
role: string,
|
||||
player: ViewTeamMembersResponse
|
||||
}>();
|
||||
|
||||
const roleObject = defineModel();
|
||||
const roleObject = defineModel<RoleSchema>();
|
||||
|
||||
function toggle(isMain) {
|
||||
function toggle(isMain: boolean) {
|
||||
if (isMain == roleObject.value?.isMain) {
|
||||
roleObject.value = undefined;
|
||||
} else {
|
||||
|
|
|
@ -9,10 +9,10 @@ const scheduleStore = useScheduleStore();
|
|||
const router = useRouter();
|
||||
|
||||
const selectedTimeTz = computed(() =>
|
||||
props.selectedTime.clone().tz(scheduleStore.team?.tzTimezone));
|
||||
props.selectedTime?.clone().tz(scheduleStore.team?.tzTimezone));
|
||||
|
||||
const isTeamTzLocal = computed(() => {
|
||||
return selectedTimeTz.value.utcOffset() == props.selectedTime.utcOffset();
|
||||
return selectedTimeTz.value?.utcOffset() == props.selectedTime?.utcOffset();
|
||||
});
|
||||
|
||||
//const props = defineProps({
|
||||
|
@ -53,7 +53,7 @@ function scheduleRoster() {
|
|||
{{ selectedTime.format("L LT z") }}
|
||||
</div>
|
||||
<div v-if="!isTeamTzLocal">
|
||||
{{ selectedTimeTz.format("L LT z") }}
|
||||
{{ selectedTimeTz?.format("L LT z") }}
|
||||
</div>
|
||||
</h4>
|
||||
<button @click="scheduleRoster" v-if="selectedTime">
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { useScheduleStore } from "../stores/schedule";
|
||||
import { computed, type PropType } from "vue";
|
||||
import { computed } from "vue";
|
||||
import { type AvailabilitySchema } from "@/client";
|
||||
import { CheckboxIndicator, CheckboxRoot } from "radix-vue";
|
||||
|
||||
|
@ -8,8 +8,6 @@ const scheduleStore = useScheduleStore();
|
|||
|
||||
const hoveredIndex = computed(() => scheduleStore.hoveredIndex);
|
||||
|
||||
const selectedIndex = computed(() => scheduleStore.selectedIndex);
|
||||
|
||||
const availabilityAtHoveredIndex = computed(() => {
|
||||
if (props.player?.availability) {
|
||||
if (hoveredIndex.value) {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { type Moment } from "moment";
|
||||
import { computed, defineModel } from "vue";
|
||||
|
||||
const model = defineModel();
|
||||
const model = defineModel<Moment>({ required: true });
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
isDisabled: Boolean,
|
||||
});
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import "vue-select/dist/vue-select.css";
|
|||
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
// @ts-expect-error
|
||||
import VueSelect from "vue-select";
|
||||
import { TooltipDirective } from "vue3-tooltip";
|
||||
import "vue3-tooltip/tooltip.css";
|
||||
|
|
|
@ -30,7 +30,8 @@ export const useAuthStore = defineStore("auth", () => {
|
|||
}
|
||||
|
||||
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: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
|
|
@ -107,7 +107,11 @@ export const useRosterStore = defineStore("roster", () => {
|
|||
}
|
||||
}
|
||||
|
||||
if (player) {
|
||||
selectedPlayers[role] = player;
|
||||
} else {
|
||||
delete selectedPlayers[role];
|
||||
}
|
||||
}
|
||||
|
||||
function fetchAvailablePlayers(startTime: number, teamId: number) {
|
||||
|
|
|
@ -24,15 +24,15 @@ export const useIntegrationsStore = defineStore("integrations", () => {
|
|||
}
|
||||
|
||||
function setIntegrations(schema: TeamIntegrationSchema) {
|
||||
discordIntegration.value = schema.discordIntegration;
|
||||
logsTfIntegration.value = schema.logsTfIntegration;
|
||||
discordIntegration.value = schema.discordIntegration ?? undefined;
|
||||
logsTfIntegration.value = schema.logsTfIntegration ?? undefined;
|
||||
hasLoaded.value = true;
|
||||
}
|
||||
|
||||
async function updateIntegrations(teamId: number) {
|
||||
const body: TeamIntegrationSchema = {
|
||||
discordIntegration: discordIntegration.value,
|
||||
logsTfIntegration: logsTfIntegration.value,
|
||||
discordIntegration: discordIntegration.value ?? null,
|
||||
logsTfIntegration: logsTfIntegration.value ?? null,
|
||||
};
|
||||
const response = await client.default.updateIntegrations(teamId.toString(), body);
|
||||
setIntegrations(response);
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
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 { RouterLink, useRoute } from "vue-router";
|
||||
import { useRoute } from "vue-router";
|
||||
import moment from "moment";
|
||||
import { useEventsStore } from "@/stores/events";
|
||||
import EventSchedulerForm from "@/components/EventSchedulerForm.vue";
|
||||
import { useEventForm } from "@/composables/event-form";
|
||||
import Loader from "@/components/Loader.vue";
|
||||
import LoaderContainer from "@/components/LoaderContainer.vue";
|
||||
|
||||
const rosterStore = useRosterStore();
|
||||
|
@ -69,16 +68,22 @@ onMounted(async () => {
|
|||
is-roster />
|
||||
</div>
|
||||
<div class="form-group margin column" v-if="rosterStore.selectedRole">
|
||||
<PlayerCard v-for="player in rosterStore.mainRoles"
|
||||
<PlayerCard
|
||||
v-for="player in rosterStore.mainRoles"
|
||||
:player="player"
|
||||
:role-title="player.role" />
|
||||
:role-title="player.role"
|
||||
:is-roster="false"
|
||||
/>
|
||||
<span v-if="!hasAvailablePlayers && rosterStore.selectedRole">
|
||||
No players are currently available for this role.
|
||||
</span>
|
||||
<h3 v-if="hasAlternates">Alternates</h3>
|
||||
<PlayerCard v-for="player in rosterStore.alternateRoles"
|
||||
<PlayerCard
|
||||
v-for="player in rosterStore.alternateRoles"
|
||||
:player="player"
|
||||
:role-title="player.role" />
|
||||
:role-title="player.role"
|
||||
:is-roster="false"
|
||||
/>
|
||||
<div class="action-buttons">
|
||||
<button class="accent" @click="closeSelection">
|
||||
<i class="bi bi-check" />
|
||||
|
|
|
@ -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>
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import AvailabilityGrid from "../components/AvailabilityGrid.vue";
|
||||
import AvailabilityComboBox from "../components/AvailabilityComboBox.vue";
|
||||
import WeekSelectionBox from "../components/WeekSelectionBox.vue";
|
||||
import SchedulePlayerList from "../components/SchedulePlayerList.vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
|
@ -62,7 +61,7 @@ onMounted(() => {
|
|||
options.value = Object.values(teamsList.teams);
|
||||
|
||||
// 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) {
|
||||
selectedTeam.value = queryTeam;
|
||||
schedule.team = queryTeam;
|
||||
|
|
|
@ -76,11 +76,11 @@ onMounted(() => {
|
|||
<EventList :events="events" :team-context="team" />
|
||||
<h2 id="recent-matches-header">
|
||||
Recent Matches
|
||||
<RouterLink class="button" to="/">
|
||||
<!--RouterLink class="button" to="/">
|
||||
<button class="icon" v-tooltip="'View all'">
|
||||
<i class="bi bi-arrow-right-circle-fill"></i>
|
||||
</button>
|
||||
</RouterLink>
|
||||
</RouterLink-->
|
||||
</h2>
|
||||
<em class="subtext" v-if="true">No recent matches.</em>
|
||||
<MatchCard v-else />
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import DiscordIntegrationForm from "@/components/DiscordIntegrationForm.vue";
|
||||
import IntegrationDetails from "@/components/IntegrationDetails.vue";
|
||||
import LoaderContainer from "@/components/LoaderContainer.vue";
|
||||
import LogsTfIntegrationForm from "@/components/LogsTfIntegrationForm.vue";
|
||||
import { useTeamDetails } from "@/composables/team-details";
|
||||
import { useTeamsStore } from "@/stores/teams";
|
||||
import { useIntegrationsStore } from "@/stores/teams/integrations";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
const teamsStore = useTeamsStore();
|
||||
const integrationsStore = useIntegrationsStore();
|
||||
|
|
|
@ -10,5 +10,8 @@
|
|||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
],
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"]
|
|
@ -1,4 +1,5 @@
|
|||
from flask import Blueprint, make_response, request
|
||||
import flask_migrate
|
||||
|
||||
from app_db import app, connect_celery_with_app, connect_db_with_app
|
||||
import login
|
||||
|
@ -9,7 +10,7 @@ import user
|
|||
import events
|
||||
import match
|
||||
|
||||
connect_db_with_app()
|
||||
connect_db_with_app(None)
|
||||
connect_celery_with_app()
|
||||
|
||||
api = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
|
|
@ -16,7 +16,10 @@ convention = {
|
|||
"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)
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = database_uri
|
||||
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)
|
||||
with app.app_context():
|
||||
print("Running dialect: " + db.engine.dialect.name)
|
||||
import models.match
|
||||
import models.team_match
|
||||
import models.player_match
|
||||
|
||||
import models as _
|
||||
if environ.get("FLASK_ENV") == "production":
|
||||
print("Creating tables if they do not exist")
|
||||
db.create_all()
|
||||
|
||||
def connect_celery_with_app():
|
||||
def celery_init_app(app):
|
||||
|
|
|
@ -2,7 +2,7 @@ from collections.abc import Generator
|
|||
from datetime import timedelta, datetime
|
||||
from time import sleep
|
||||
import requests
|
||||
from sqlalchemy.sql import func, update
|
||||
from sqlalchemy.sql import exists, func, select, update
|
||||
from sqlalchemy.types import DATETIME, Interval
|
||||
import app_db
|
||||
import models.match
|
||||
|
@ -69,39 +69,59 @@ def extract_steam_ids(players: dict[str, models.match.LogPlayer]):
|
|||
@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")
|
||||
#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))
|
||||
ptp = (
|
||||
select(
|
||||
PlayerTeam.id.label("id"),
|
||||
func.sum(Match.duration).label("playtime")
|
||||
)
|
||||
.select_from(PlayerTeam)
|
||||
.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))
|
||||
.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_int),
|
||||
PlayerTeam.team_id == TeamMatch.team_id
|
||||
)
|
||||
.group_by(PlayerTeam.id)
|
||||
.subquery()
|
||||
.cte("ptp")
|
||||
)
|
||||
|
||||
update_query = app_db.db.session.execute(
|
||||
stmt = (
|
||||
update(PlayerTeam)
|
||||
.where(PlayerTeam.id == subquery.c.id)
|
||||
.values(playtime=subquery.c.total_playtime)
|
||||
.values(
|
||||
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 (
|
||||
app_db.db.session.query(
|
||||
PlayerTeam.team_id,
|
||||
|
@ -198,7 +218,7 @@ def transform(
|
|||
yield team_match
|
||||
|
||||
#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
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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()
|
||||
|
||||
celery_app = app.extensions["celery"]
|
||||
|
|
|
@ -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 *
|
|
@ -26,3 +26,7 @@ discord-webhook # for sending messages to Discord webhooks
|
|||
celery[redis]
|
||||
|
||||
Flask-Testing
|
||||
|
||||
# for production
|
||||
gunicorn
|
||||
psycopg[binary]
|
||||
|
|
|
@ -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
|
||||
|
||||
@api_team.delete("/id/<team_id>/")
|
||||
@api_team.delete("/id/<int:team_id>/")
|
||||
@spec.validate(
|
||||
resp=Response(
|
||||
HTTP_200=None,
|
||||
|
@ -135,7 +135,7 @@ def delete_team(player: Player, team_id: int):
|
|||
db.session.commit()
|
||||
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(
|
||||
resp=Response(
|
||||
HTTP_200=None,
|
||||
|
@ -202,7 +202,7 @@ class AddPlayerJson(BaseModel):
|
|||
team_role: PlayerTeam.TeamRole = PlayerTeam.TeamRole.Player
|
||||
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(
|
||||
resp=Response(
|
||||
HTTP_200=None,
|
||||
|
@ -271,7 +271,7 @@ def view_teams(**kwargs):
|
|||
return response.dict(by_alias=True)
|
||||
abort(404)
|
||||
|
||||
@api_team.get("/id/<team_id>/")
|
||||
@api_team.get("/id/<int:team_id>/")
|
||||
@spec.validate(
|
||||
resp=Response(
|
||||
HTTP_200=ViewTeamResponse,
|
||||
|
@ -321,7 +321,7 @@ class ViewTeamMembersResponse(PlayerSchema):
|
|||
created_at: datetime
|
||||
is_team_leader: bool = False
|
||||
|
||||
@api_team.get("/id/<team_id>/players")
|
||||
@api_team.get("/id/<int:team_id>/players")
|
||||
@spec.validate(
|
||||
resp=Response(
|
||||
HTTP_200=list[ViewTeamMembersResponse],
|
||||
|
@ -387,7 +387,7 @@ def view_team_members(player: Player, team_id: int, **kwargs):
|
|||
class EditMemberRolesJson(BaseModel):
|
||||
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(
|
||||
resp=Response(
|
||||
HTTP_204=None,
|
||||
|
|
|
@ -11,7 +11,7 @@ from app_db import db
|
|||
|
||||
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(
|
||||
resp=Response(
|
||||
HTTP_200=TeamIntegrationSchema,
|
||||
|
@ -32,7 +32,7 @@ def get_integrations(player_team: PlayerTeam, **_):
|
|||
|
||||
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(
|
||||
resp=Response(
|
||||
HTTP_200=TeamIntegrationSchema,
|
||||
|
|
|
@ -13,7 +13,7 @@ from spec import BaseModel, spec
|
|||
|
||||
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(
|
||||
resp=Response(
|
||||
HTTP_200=list[TeamInviteSchema],
|
||||
|
@ -39,7 +39,7 @@ def get_invites(team_id: int, **_):
|
|||
|
||||
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(
|
||||
resp=Response(
|
||||
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
|
||||
|
||||
@api_team_invite.delete("/id/<team_id>/invite/<key>")
|
||||
@api_team_invite.delete("/id/<int:team_id>/invite/<key>")
|
||||
@spec.validate(
|
||||
resp=Response(
|
||||
HTTP_204=None,
|
||||
|
|
|
@ -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:
|
|
@ -15,6 +15,7 @@ services:
|
|||
- FLASK_DEBUG=1
|
||||
- FLASK_CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- FLASK_CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
- DATABASE_URI=sqlite:///db.sqlite3
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
|
@ -25,6 +26,7 @@ services:
|
|||
environment:
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
- DATABASE_URI=sqlite:///db.sqlite3
|
||||
image: backend-flask
|
||||
volumes:
|
||||
- ./backend-flask:/app
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue