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", () => {
 | 
			
		|||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    selectedPlayers[role] = player;
 | 
			
		||||
    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"
 | 
			
		||||
                    :player="player"
 | 
			
		||||
                    :role-title="player.role" />
 | 
			
		||||
        <PlayerCard
 | 
			
		||||
          v-for="player in rosterStore.mainRoles"
 | 
			
		||||
          :player="player"
 | 
			
		||||
          :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"
 | 
			
		||||
                    :player="player"
 | 
			
		||||
                    :role-title="player.role" />
 | 
			
		||||
        <PlayerCard
 | 
			
		||||
          v-for="player in rosterStore.alternateRoles"
 | 
			
		||||
          :player="player"
 | 
			
		||||
          :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