Implement some basic features
parent
06ef477d65
commit
3fab130ae0
|
@ -1,3 +1,4 @@
|
||||||
__pycache__/
|
__pycache__/
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
sqlite3/
|
sqlite3/
|
||||||
|
venv/
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_API_BASE_URL=/api
|
|
@ -1,5 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink, RouterView } from "vue-router";
|
import { RouterLink, RouterView } from "vue-router";
|
||||||
|
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -9,6 +11,16 @@ import { RouterLink, RouterView } from "vue-router";
|
||||||
<RouterLink to="/">Home</RouterLink>
|
<RouterLink to="/">Home</RouterLink>
|
||||||
<RouterLink to="/schedule">Schedule</RouterLink>
|
<RouterLink to="/schedule">Schedule</RouterLink>
|
||||||
<RouterLink to="/schedule/roster">Roster Builder</RouterLink>
|
<RouterLink to="/schedule/roster">Roster Builder</RouterLink>
|
||||||
|
<form action="https://steamcommunity.com/openid/login" method="get">
|
||||||
|
<input type="hidden" name="openid.identity"
|
||||||
|
value="http://specs.openid.net/auth/2.0/identifier_select" />
|
||||||
|
<input type="hidden" name="openid.claimed_id"
|
||||||
|
value="http://specs.openid.net/auth/2.0/identifier_select" />
|
||||||
|
<input type="hidden" name="openid.ns" value="http://specs.openid.net/auth/2.0" />
|
||||||
|
<input type="hidden" name="openid.mode" value="checkid_setup" />
|
||||||
|
<input type="hidden" name="openid.return_to" :value="baseUrl + '/login'" />
|
||||||
|
<button type="submit">Log in through Steam</button>
|
||||||
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
@ -49,10 +49,13 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
button > i.bi {
|
button > i.bi {
|
||||||
margin-right: 4px;
|
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button > i.bi.margin {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
background-color: var(--surface-0);
|
background-color: var(--surface-0);
|
||||||
}
|
}
|
||||||
|
@ -85,6 +88,16 @@ button.transparent:hover {
|
||||||
background-color: var(--surface-0);
|
background-color: var(--surface-0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button[disabled] {
|
||||||
|
color: var(--overlay-0);
|
||||||
|
cursor: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[disabled]:hover {
|
||||||
|
color: var(--overlay-0);
|
||||||
|
background-color: unset;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 200%;
|
font-size: 200%;
|
||||||
|
@ -104,5 +117,5 @@ select {
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtext {
|
.subtext {
|
||||||
color: var(--subtext-0);
|
color: var(--overlay-0);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
export default class Cacheable<T> {
|
||||||
|
value: T;
|
||||||
|
cacheTime: number;
|
||||||
|
timeToLive: number;
|
||||||
|
|
||||||
|
public constructor(value: T, cacheTime?: number, timeToLive?: number) {
|
||||||
|
this.value = value;
|
||||||
|
this.cacheTime = cacheTime ?? new Date().getTime();
|
||||||
|
this.timeToLive = timeToLive ?? 300000;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isStale(): boolean {
|
||||||
|
return new Date().getTime() > this.cacheTime + this.timeToLive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public refresh(): void {
|
||||||
|
this.cacheTime = new Date().getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
public kill(): void {
|
||||||
|
this.cacheTime = 0;
|
||||||
|
}
|
||||||
|
};
|
|
@ -2,13 +2,19 @@
|
||||||
import { computed, defineModel, defineProps, reactive, ref, onMounted, onUnmounted } from "vue";
|
import { computed, defineModel, defineProps, reactive, ref, onMounted, onUnmounted } from "vue";
|
||||||
|
|
||||||
const model = defineModel();
|
const model = defineModel();
|
||||||
const firstHour = 14;
|
|
||||||
const lastHour = 22;
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selectionMode: Number,
|
selectionMode: Number,
|
||||||
isDisabled: Boolean,
|
isDisabled: Boolean,
|
||||||
dateStart: Date,
|
dateStart: Date,
|
||||||
|
firstHour: {
|
||||||
|
type: Number,
|
||||||
|
default: 14
|
||||||
|
},
|
||||||
|
lastHour: {
|
||||||
|
type: Number,
|
||||||
|
default: 22,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectionStart = reactive({ x: undefined, y: undefined });
|
const selectionStart = reactive({ x: undefined, y: undefined });
|
||||||
|
@ -21,15 +27,15 @@ const lowerBoundX = computed(() => {
|
||||||
Math.min(selectionStart.x, selectionEnd.x)
|
Math.min(selectionStart.x, selectionEnd.x)
|
||||||
});
|
});
|
||||||
const upperBoundX = computed(() => {
|
const upperBoundX = computed(() => {
|
||||||
return isShiftDown.value ? 7 :
|
return isShiftDown.value ? 6 :
|
||||||
Math.max(selectionStart.x, selectionEnd.x)
|
Math.max(selectionStart.x, selectionEnd.x)
|
||||||
});
|
});
|
||||||
const lowerBoundY = computed(() => {
|
const lowerBoundY = computed(() => {
|
||||||
return isCtrlDown.value ? firstHour :
|
return isCtrlDown.value ? props.firstHour :
|
||||||
Math.min(selectionStart.y, selectionEnd.y)
|
Math.min(selectionStart.y, selectionEnd.y)
|
||||||
});
|
});
|
||||||
const upperBoundY = computed(() => {
|
const upperBoundY = computed(() => {
|
||||||
return isCtrlDown.value ? lastHour :
|
return isCtrlDown.value ? props.lastHour :
|
||||||
Math.max(selectionStart.y, selectionEnd.y)
|
Math.max(selectionStart.y, selectionEnd.y)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -53,8 +59,8 @@ const days = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const hours = computed(() => {
|
const hours = computed(() => {
|
||||||
return Array.from(Array(lastHour - firstHour + 1).keys())
|
return Array.from(Array(props.lastHour - props.firstHour + 1).keys())
|
||||||
.map(x => x + firstHour);
|
.map(x => x + props.firstHour);
|
||||||
});
|
});
|
||||||
|
|
||||||
const daysOfWeek = [
|
const daysOfWeek = [
|
||||||
|
|
|
@ -3,6 +3,10 @@ import { computed, defineModel } from "vue";
|
||||||
|
|
||||||
const model = defineModel();
|
const model = defineModel();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isDisabled: Boolean,
|
||||||
|
});
|
||||||
|
|
||||||
const dateStart = computed(() => model.value.toLocaleDateString());
|
const dateStart = computed(() => model.value.toLocaleDateString());
|
||||||
const dateEnd = computed(() => {
|
const dateEnd = computed(() => {
|
||||||
let dateEndObject = new Date(model.value);
|
let dateEndObject = new Date(model.value);
|
||||||
|
@ -19,11 +23,11 @@ function incrementDate(delta: number) {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="scroll-box">
|
<div class="scroll-box">
|
||||||
<button class="transparent eq" @click="incrementDate(-7)">
|
<button class="transparent eq" @click="incrementDate(-7)" :disabled="isDisabled">
|
||||||
<i class="bi bi-caret-left-fill"></i>
|
<i class="bi bi-caret-left-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
<span class="date-range">{{ dateStart }} – {{ dateEnd }}</span>
|
<span class="date-range">{{ dateStart }} – {{ dateEnd }}</span>
|
||||||
<button class="transparent eq" @click="incrementDate(7)">
|
<button class="transparent eq" @click="incrementDate(7)" :disabled="isDisabled">
|
||||||
<i class="bi bi-caret-right-fill"></i>
|
<i class="bi bi-caret-right-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from "vue-router";
|
||||||
import HomeView from "../views/HomeView.vue";
|
import HomeView from "../views/HomeView.vue";
|
||||||
import ScheduleView from "../views/ScheduleView.vue";
|
import ScheduleView from "../views/ScheduleView.vue";
|
||||||
import RosterBuilderView from "../views/RosterBuilderView.vue";
|
import RosterBuilderView from "../views/RosterBuilderView.vue";
|
||||||
|
import LoginView from "../views/LoginView.vue";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
@ -11,6 +12,11 @@ const router = createRouter({
|
||||||
name: "home",
|
name: "home",
|
||||||
component: HomeView
|
component: HomeView
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
name: "login",
|
||||||
|
component: LoginView
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/schedule",
|
path: "/schedule",
|
||||||
name: "schedule",
|
name: "schedule",
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore("auth", () => {
|
||||||
|
const steamId = ref(NaN);
|
||||||
|
const username = ref("");
|
||||||
|
const isLoggedIn = ref(false);
|
||||||
|
const isRegistering = ref(false);
|
||||||
|
|
||||||
|
async function login(queryParams: { [key: string]: string }) {
|
||||||
|
return fetch(import.meta.env.VITE_API_BASE_URL + "/login/authenticate", {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "same-origin",
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(queryParams),
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((response) => {
|
||||||
|
isRegistering.value = response.isRegistering;
|
||||||
|
if (!isRegistering.value) {
|
||||||
|
steamId.value = response.steamId;
|
||||||
|
username.value = response.username;
|
||||||
|
isLoggedIn.value = true;
|
||||||
|
isRegistering.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
steamId,
|
||||||
|
username,
|
||||||
|
isLoggedIn,
|
||||||
|
isRegistering,
|
||||||
|
login,
|
||||||
|
}
|
||||||
|
});
|
|
@ -1,17 +1,64 @@
|
||||||
|
import { computed } from "@vue/reactivity";
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { reactive, ref, watch } from "vue";
|
import { reactive, ref, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
export const useScheduleStore = defineStore("schedule", () => {
|
export const useScheduleStore = defineStore("schedule", () => {
|
||||||
const dateStart = ref(new Date(2024, 9, 21));
|
const dateStart = ref(new Date(2024, 9, 21, 0, 30));
|
||||||
|
|
||||||
|
const windowStart = computed(() => Math.floor(dateStart.value.getTime() / 1000));
|
||||||
|
|
||||||
const availability = reactive(new Array(168));
|
const availability = reactive(new Array(168));
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const teamId = computed({
|
||||||
|
get: () => route.query.teamId,
|
||||||
|
set: (value) => router.push({ query: { teamId: value } }),
|
||||||
|
});
|
||||||
|
|
||||||
watch(dateStart, () => {
|
watch(dateStart, () => {
|
||||||
availability.fill(0);
|
availability.fill(0);
|
||||||
|
fetchSchedule();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function fetchSchedule() {
|
||||||
|
return fetch(import.meta.env.VITE_API_BASE_URL + "/schedule?" + new URLSearchParams({
|
||||||
|
window_start: windowStart.value.toString(),
|
||||||
|
team_id: "1",
|
||||||
|
}).toString(),{
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((response) => {
|
||||||
|
response.availability.forEach((value: number, i: number) => {
|
||||||
|
availability[i] = value;
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSchedule() {
|
||||||
|
return fetch(import.meta.env.VITE_API_BASE_URL + "/schedule", {
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
window_start: Math.floor(dateStart.value.getTime() / 1000),
|
||||||
|
team_id: 1,
|
||||||
|
availability: availability,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dateStart,
|
dateStart,
|
||||||
|
windowStart,
|
||||||
availability,
|
availability,
|
||||||
|
fetchSchedule,
|
||||||
|
saveSchedule,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
import Cacheable from "@/cacheable";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed, reactive, ref, type Reactive, type Ref } from "vue";
|
||||||
|
|
||||||
|
interface Team {
|
||||||
|
id: number,
|
||||||
|
teamName: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTeamsStore = defineStore("teams", () => {
|
||||||
|
//const teams: Reactive<Cacheable<Team[]>> =
|
||||||
|
// reactive(new Cacheable<Team[]>([], 0));
|
||||||
|
const teams: Ref<{ [id: number]: Team }> = ref({ });
|
||||||
|
|
||||||
|
async function fetchTeams() {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
fetch(import.meta.env.VITE_API_BASE_URL + "/team/view", {
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((response: Array<any>) => {
|
||||||
|
teams.value = response
|
||||||
|
.reduce((acc, team: Team) => {
|
||||||
|
return { ...acc, [team.id]: team }
|
||||||
|
});
|
||||||
|
res(teams.value);
|
||||||
|
})
|
||||||
|
.catch(() => rej());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
teams,
|
||||||
|
fetchTeams,
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,48 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from "../stores/auth";
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryParams = route.query;
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const registerUsername = ref("");
|
||||||
|
|
||||||
|
function register() {
|
||||||
|
const params = {
|
||||||
|
...queryParams,
|
||||||
|
username: registerUsername,
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.login(params)
|
||||||
|
.then(() => router.push("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (Object.keys(queryParams).length == 0) {
|
||||||
|
auth.isRegistering = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.login(queryParams)
|
||||||
|
.then(() => router.push("/"));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<main>
|
||||||
|
<template v-if="auth.isRegistering">
|
||||||
|
<h1>Register</h1>
|
||||||
|
<input v-model="registerUsername" />
|
||||||
|
<button class="accent" type="submit">Register</button>
|
||||||
|
</template>
|
||||||
|
<div v-else>
|
||||||
|
Logging in...
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -2,16 +2,22 @@
|
||||||
import AvailabilityGrid from "../components/AvailabilityGrid.vue";
|
import AvailabilityGrid from "../components/AvailabilityGrid.vue";
|
||||||
import AvailabilityComboBox from "../components/AvailabilityComboBox.vue";
|
import AvailabilityComboBox from "../components/AvailabilityComboBox.vue";
|
||||||
import WeekSelectionBox from "../components/WeekSelectionBox.vue";
|
import WeekSelectionBox from "../components/WeekSelectionBox.vue";
|
||||||
import { reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import { useScheduleStore } from "../stores/schedule.ts";
|
import { useTeamsStore } from "../stores/teams";
|
||||||
|
import { useScheduleStore } from "../stores/schedule";
|
||||||
|
|
||||||
|
const teams = useTeamsStore();
|
||||||
const schedule = useScheduleStore();
|
const schedule = useScheduleStore();
|
||||||
|
|
||||||
const options = reactive([
|
const options = ref([
|
||||||
"TEAM PEPEJA forsenCD",
|
"TEAM PEPEJA forsenCD",
|
||||||
"The Snus Brotherhood",
|
"The Snus Brotherhood",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const firstHour = computed(() => shouldShowAllHours.value ? 0 : 14);
|
||||||
|
const lastHour = computed(() => shouldShowAllHours.value ? 23 : 22);
|
||||||
|
const shouldShowAllHours = ref(false);
|
||||||
|
|
||||||
const comboBoxIndex = ref(0);
|
const comboBoxIndex = ref(0);
|
||||||
|
|
||||||
//const availability = reactive(new Array(168));
|
//const availability = reactive(new Array(168));
|
||||||
|
@ -20,6 +26,24 @@ const availability = schedule.availability;
|
||||||
const selectionMode = ref(1);
|
const selectionMode = ref(1);
|
||||||
|
|
||||||
const isEditing = ref(false);
|
const isEditing = ref(false);
|
||||||
|
|
||||||
|
function saveSchedule() {
|
||||||
|
schedule.saveSchedule()
|
||||||
|
.then(() => {
|
||||||
|
isEditing.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
teams.fetchTeams()
|
||||||
|
.then((teamsList) => {
|
||||||
|
options.value = Object.values(teamsList);
|
||||||
|
schedule.fetchSchedule()
|
||||||
|
.then(() => {
|
||||||
|
|
||||||
|
});
|
||||||
|
})
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -31,16 +55,25 @@ const isEditing = ref(false);
|
||||||
<AvailabilityComboBox :options="options" v-model="comboBoxIndex" />
|
<AvailabilityComboBox :options="options" v-model="comboBoxIndex" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<WeekSelectionBox v-model="schedule.dateStart" />
|
<WeekSelectionBox
|
||||||
|
v-model="schedule.dateStart"
|
||||||
|
:is-disabled="isEditing" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AvailabilityGrid v-model="availability"
|
<AvailabilityGrid v-model="availability"
|
||||||
:selection-mode="selectionMode"
|
:selection-mode="selectionMode"
|
||||||
:is-disabled="!isEditing"
|
:is-disabled="!isEditing"
|
||||||
:date-start="schedule.dateStart"
|
:date-start="schedule.dateStart"
|
||||||
|
:first-hour="firstHour"
|
||||||
|
:last-hour="lastHour"
|
||||||
/>
|
/>
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button>Show all times</button>
|
<button v-if="shouldShowAllHours" @click="shouldShowAllHours = false">
|
||||||
|
Show designated times
|
||||||
|
</button>
|
||||||
|
<button v-else @click="shouldShowAllHours = true">
|
||||||
|
Show all times
|
||||||
|
</button>
|
||||||
<template v-if="isEditing">
|
<template v-if="isEditing">
|
||||||
<div class="radio-group">
|
<div class="radio-group">
|
||||||
<button
|
<button
|
||||||
|
@ -56,14 +89,12 @@ const isEditing = ref(false);
|
||||||
Definitely available
|
Definitely available
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button @click="isEditing = false">
|
<button @click="saveSchedule()">
|
||||||
<i class="bi bi-check-circle-fill"></i>
|
<i class="bi bi-check-circle-fill"></i>
|
||||||
Save
|
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<button v-else class="accent" @click="isEditing = true">
|
<button v-else class="accent" @click="isEditing = true">
|
||||||
<i class="bi bi-pencil-fill"></i>
|
<i class="bi bi-pencil-fill"></i>
|
||||||
Edit
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -12,5 +12,29 @@ export default defineConfig({
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||||
|
const cookie = req.headers.cookie;
|
||||||
|
if (cookie) {
|
||||||
|
proxyReq.setHeader('Cookie', cookie);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proxy.on('proxyRes', (proxyRes, req, res) => {
|
||||||
|
const cookie = proxyRes.headers['set-cookie'];
|
||||||
|
if (cookie) {
|
||||||
|
res.setHeader('Set-Cookie', cookie);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export FLASK_APP=app.py
|
|
@ -0,0 +1,30 @@
|
||||||
|
from flask import Blueprint, Flask, make_response, request
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
import login
|
||||||
|
import schedule
|
||||||
|
import team
|
||||||
|
from models import init_db
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app, origins=["http://localhost:5173"], supports_credentials=True)
|
||||||
|
CORS(login.api_login, origins=["http://localhost:5173"], supports_credentials=True)
|
||||||
|
CORS(schedule.api_schedule, origins=["http://localhost:5173"], supports_credentials=True)
|
||||||
|
|
||||||
|
init_db(app)
|
||||||
|
|
||||||
|
api = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
api.register_blueprint(login.api_login)
|
||||||
|
api.register_blueprint(schedule.api_schedule)
|
||||||
|
api.register_blueprint(team.api_team)
|
||||||
|
|
||||||
|
@api.get("/debug/set-cookie")
|
||||||
|
@api.post("/debug/set-cookie")
|
||||||
|
def debug_set_cookie():
|
||||||
|
res = make_response()
|
||||||
|
for key, value in request.args.items():
|
||||||
|
res.set_cookie(key, value)
|
||||||
|
return res, 200
|
||||||
|
|
||||||
|
app.register_blueprint(api)
|
|
@ -0,0 +1,7 @@
|
||||||
|
import pydantic
|
||||||
|
|
||||||
|
class User(pydantic.BaseModel):
|
||||||
|
steam_id: int = pydantic.Field(2)
|
||||||
|
|
||||||
|
class TestForm(pydantic.BaseModel):
|
||||||
|
value: str = "lol"
|
|
@ -0,0 +1,137 @@
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import urllib.parse
|
||||||
|
from flask import Blueprint, abort, make_response, redirect, request, url_for
|
||||||
|
import requests
|
||||||
|
import models
|
||||||
|
from models import AuthSession, Player, db
|
||||||
|
from middleware import requires_authentication
|
||||||
|
|
||||||
|
api_login = Blueprint("login", __name__, url_prefix="/login")
|
||||||
|
|
||||||
|
STEAM_OPENID_URL = "https://steamcommunity.com/openid/login"
|
||||||
|
|
||||||
|
@api_login.get("/")
|
||||||
|
def index():
|
||||||
|
return "test"
|
||||||
|
|
||||||
|
def get_steam_login_url(return_to):
|
||||||
|
"""Build the Steam OpenID URL for login"""
|
||||||
|
params = {
|
||||||
|
"openid.ns": "http://specs.openid.net/auth/2.0",
|
||||||
|
"openid.mode": "checkid_setup",
|
||||||
|
"openid.return_to": return_to,
|
||||||
|
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
|
||||||
|
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
|
||||||
|
}
|
||||||
|
return f"{STEAM_OPENID_URL}?{urllib.parse.urlencode(params)}"
|
||||||
|
|
||||||
|
#@api_login.get("/steam/")
|
||||||
|
#def steam_login():
|
||||||
|
# return_to = url_for("api.login.steam_login_callback", _external=True)
|
||||||
|
# steam_login_url = get_steam_login_url(return_to)
|
||||||
|
# return redirect(steam_login_url)
|
||||||
|
#
|
||||||
|
#@api_login.get("/steam/callback/")
|
||||||
|
#def steam_login_callback():
|
||||||
|
# params = request.args.to_dict()
|
||||||
|
# params["openid.mode"] = "check_authentication"
|
||||||
|
# response = requests.post(STEAM_OPENID_URL, data=params)
|
||||||
|
#
|
||||||
|
# # Check if authentication was successful
|
||||||
|
# if "is_valid:true" in response.text:
|
||||||
|
# claimed_id = request.args.get("openid.claimed_id")
|
||||||
|
# steam_id = extract_steam_id_from_response(claimed_id)
|
||||||
|
# print("User logged in as", steam_id)
|
||||||
|
#
|
||||||
|
# player = create_or_get_user_from_steam_id(int(steam_id))
|
||||||
|
# auth_session = create_auth_session_for_player(player)
|
||||||
|
#
|
||||||
|
# resp = make_response("Logged in")
|
||||||
|
# resp.set_cookie("auth", auth_session.key, secure=True, httponly=True)
|
||||||
|
# return resp
|
||||||
|
# return "no"
|
||||||
|
|
||||||
|
@api_login.post("/authenticate")
|
||||||
|
def steam_authenticate():
|
||||||
|
params = request.get_json()
|
||||||
|
params["openid.mode"] = "check_authentication"
|
||||||
|
response = requests.post(STEAM_OPENID_URL, data=params)
|
||||||
|
|
||||||
|
# check if authentication was successful
|
||||||
|
if "is_valid:true" in response.text:
|
||||||
|
claimed_id = params["openid.claimed_id"]
|
||||||
|
steam_id = int(extract_steam_id_from_response(claimed_id))
|
||||||
|
print("User logged in as", steam_id)
|
||||||
|
|
||||||
|
#player = create_or_get_user_from_steam_id(int(steam_id))
|
||||||
|
player = db.session.query(
|
||||||
|
Player
|
||||||
|
).where(
|
||||||
|
Player.steam_id == steam_id
|
||||||
|
).one_or_none()
|
||||||
|
|
||||||
|
if not player:
|
||||||
|
if "username" in params:
|
||||||
|
# we are registering, so create user
|
||||||
|
player = Player()
|
||||||
|
player.username = params["username"]
|
||||||
|
player.steam_id = steam_id
|
||||||
|
else:
|
||||||
|
# prompt client to resend with username field
|
||||||
|
return make_response({
|
||||||
|
"message": "Awaiting registration",
|
||||||
|
"hint": "Resend the POST request with a username field",
|
||||||
|
"isRegistering": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
auth_session = create_auth_session_for_player(player)
|
||||||
|
|
||||||
|
resp = make_response({
|
||||||
|
"message": "Logged in",
|
||||||
|
"steamId": player.steam_id,
|
||||||
|
"username": player.username,
|
||||||
|
})
|
||||||
|
|
||||||
|
# TODO: secure=True in production
|
||||||
|
resp.set_cookie("auth", auth_session.key, httponly=True)
|
||||||
|
return resp
|
||||||
|
return abort(401)
|
||||||
|
|
||||||
|
@api_login.delete("/")
|
||||||
|
@requires_authentication
|
||||||
|
def logout(**kwargs):
|
||||||
|
auth_session: AuthSession = kwargs["auth_session"]
|
||||||
|
db.session.delete(auth_session)
|
||||||
|
response = make_response(200)
|
||||||
|
response.delete_cookie("auth")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def create_or_get_user_from_steam_id(steam_id: int, username: str) -> Player:
|
||||||
|
statement = db.select(Player).filter_by(steam_id=steam_id)
|
||||||
|
player = db.session.execute(statement).scalar_one_or_none()
|
||||||
|
if not player:
|
||||||
|
player = Player()
|
||||||
|
player.steam_id = steam_id
|
||||||
|
player.username = username
|
||||||
|
db.session.add(player)
|
||||||
|
db.session.commit()
|
||||||
|
return player
|
||||||
|
|
||||||
|
def generate_base36(length):
|
||||||
|
alphabet = string.digits + string.ascii_uppercase
|
||||||
|
return "".join(random.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
def create_auth_session_for_player(player: models.Player):
|
||||||
|
session = AuthSession()
|
||||||
|
session.player = player
|
||||||
|
|
||||||
|
random_key = generate_base36(31)
|
||||||
|
session.key = random_key
|
||||||
|
|
||||||
|
player.auth_sessions.append(session)
|
||||||
|
db.session.commit()
|
||||||
|
return session
|
||||||
|
|
||||||
|
def extract_steam_id_from_response(claimed_id_url):
|
||||||
|
return claimed_id_url.split("/")[-1]
|
|
@ -0,0 +1,27 @@
|
||||||
|
from functools import wraps
|
||||||
|
from flask import abort, make_response, request
|
||||||
|
from models import db
|
||||||
|
import models
|
||||||
|
|
||||||
|
|
||||||
|
def requires_authentication(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorator(*args, **kwargs):
|
||||||
|
auth = request.cookies.get("auth")
|
||||||
|
|
||||||
|
if not auth:
|
||||||
|
abort(401)
|
||||||
|
|
||||||
|
statement = db.select(models.AuthSession).filter_by(key=auth)
|
||||||
|
auth_session: models.AuthSession | None = \
|
||||||
|
db.session.execute(statement).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not auth_session:
|
||||||
|
abort(make_response({
|
||||||
|
"error": "Invalid auth token"
|
||||||
|
}, 401))
|
||||||
|
player = auth_session.player
|
||||||
|
kwargs["player"] = player
|
||||||
|
kwargs["auth_session"] = auth_session
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorator
|
|
@ -0,0 +1 @@
|
||||||
|
Single-database configuration for Flask.
|
|
@ -0,0 +1,50 @@
|
||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# template used to generate migration files
|
||||||
|
# file_template = %%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic,flask_migrate
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[logger_flask_migrate]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = flask_migrate
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
|
@ -0,0 +1,113 @@
|
||||||
|
import logging
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
logger = logging.getLogger('alembic.env')
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
try:
|
||||||
|
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||||
|
return current_app.extensions['migrate'].db.get_engine()
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
# this works with Flask-SQLAlchemy>=3
|
||||||
|
return current_app.extensions['migrate'].db.engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine_url():
|
||||||
|
try:
|
||||||
|
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||||
|
'%', '%%')
|
||||||
|
except AttributeError:
|
||||||
|
return str(get_engine().url).replace('%', '%%')
|
||||||
|
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||||
|
target_db = current_app.extensions['migrate'].db
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata():
|
||||||
|
if hasattr(target_db, 'metadatas'):
|
||||||
|
return target_db.metadatas[None]
|
||||||
|
return target_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# this callback is used to prevent an auto-migration from being generated
|
||||||
|
# when there are no changes to the schema
|
||||||
|
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||||
|
def process_revision_directives(context, revision, directives):
|
||||||
|
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||||
|
script = directives[0]
|
||||||
|
if script.upgrade_ops.is_empty():
|
||||||
|
directives[:] = []
|
||||||
|
logger.info('No changes in schema detected.')
|
||||||
|
|
||||||
|
conf_args = current_app.extensions['migrate'].configure_args
|
||||||
|
if conf_args.get("process_revision_directives") is None:
|
||||||
|
conf_args["process_revision_directives"] = process_revision_directives
|
||||||
|
|
||||||
|
connectable = get_engine()
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=get_metadata(),
|
||||||
|
**conf_args
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Add column players_teams_availability.availability
|
||||||
|
|
||||||
|
Revision ID: 062a154a0797
|
||||||
|
Revises: 4fb63c11ee8c
|
||||||
|
Create Date: 2024-10-30 23:54:22.877218
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '062a154a0797'
|
||||||
|
down_revision = '4fb63c11ee8c'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('players_teams_availability', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('availability', sa.Integer(), nullable=False, default=2))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('players_teams_availability', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('availability')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 273f73c81783
|
||||||
|
Revises: ce676db8c655
|
||||||
|
Create Date: 2024-10-29 23:12:40.743611
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '273f73c81783'
|
||||||
|
down_revision = 'ce676db8c655'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('auth_session',
|
||||||
|
sa.Column('player_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['player_id'], ['players.steam_id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('player_id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('auth_session')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Make player role primary key
|
||||||
|
|
||||||
|
Revision ID: 2b2f3ae2ec7f
|
||||||
|
Revises: 958df14798d5
|
||||||
|
Create Date: 2024-10-31 19:07:02.960849
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2b2f3ae2ec7f'
|
||||||
|
down_revision = '958df14798d5'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
with op.batch_alter_table('players_teams_roles', schema=None) as batch_op:
|
||||||
|
batch_op.create_primary_key('pk_players_teams_roles', ['player_id', 'team_id', 'role'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table('players_teams_roles', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint('pk_players_teams_roles')
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Rename table players_teams_availability
|
||||||
|
|
||||||
|
Revision ID: 4fb63c11ee8c
|
||||||
|
Revises: 8ea29cf493f5
|
||||||
|
Create Date: 2024-10-30 22:45:51.227298
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '4fb63c11ee8c'
|
||||||
|
down_revision = '8ea29cf493f5'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('players_teams_availability',
|
||||||
|
sa.Column('player_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('team_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('start_time', sa.TIMESTAMP(), nullable=False),
|
||||||
|
sa.Column('end_time', sa.TIMESTAMP(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['player_id', 'team_id'], ['players_teams.player_id', 'players_teams.team_id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('player_id', 'team_id', 'start_time')
|
||||||
|
)
|
||||||
|
op.drop_table('player_team_availability')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('player_team_availability',
|
||||||
|
sa.Column('player_id', sa.INTEGER(), nullable=False),
|
||||||
|
sa.Column('team_id', sa.INTEGER(), nullable=False),
|
||||||
|
sa.Column('start_time', sa.TIMESTAMP(), nullable=False),
|
||||||
|
sa.Column('end_time', sa.TIMESTAMP(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['player_id', 'team_id'], ['players_teams.player_id', 'players_teams.team_id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('player_id', 'team_id')
|
||||||
|
)
|
||||||
|
op.drop_table('players_teams_availability')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Make PlayerTeamAvailability a db.Model
|
||||||
|
|
||||||
|
Revision ID: 8ea29cf493f5
|
||||||
|
Revises: b00632365b58
|
||||||
|
Create Date: 2024-10-30 22:21:13.718428
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '8ea29cf493f5'
|
||||||
|
down_revision = 'b00632365b58'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('player_team_availability',
|
||||||
|
sa.Column('player_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('team_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('start_time', sa.TIMESTAMP(), nullable=False),
|
||||||
|
sa.Column('end_time', sa.TIMESTAMP(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['player_id', 'team_id'], ['players_teams.player_id', 'players_teams.team_id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('player_id', 'team_id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('player_team_availability')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Add team.discord_webhook_url
|
||||||
|
|
||||||
|
Revision ID: 958df14798d5
|
||||||
|
Revises: 062a154a0797
|
||||||
|
Create Date: 2024-10-31 09:56:43.335627
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '958df14798d5'
|
||||||
|
down_revision = '062a154a0797'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('teams', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('discord_webhook_url', sa.String(length=255), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('teams', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('discord_webhook_url')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Add auth_session.key
|
||||||
|
|
||||||
|
Revision ID: a340b3da0f2a
|
||||||
|
Revises: 273f73c81783
|
||||||
|
Create Date: 2024-10-29 23:17:29.296293
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a340b3da0f2a'
|
||||||
|
down_revision = '273f73c81783'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('auth_session', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('key', sa.String(length=31), nullable=False))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('auth_session', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('key')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: b00632365b58
|
||||||
|
Revises: a340b3da0f2a
|
||||||
|
Create Date: 2024-10-29 23:27:37.306568
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'b00632365b58'
|
||||||
|
down_revision = 'a340b3da0f2a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('auth_sessions',
|
||||||
|
sa.Column('key', sa.String(length=31), nullable=False),
|
||||||
|
sa.Column('player_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['player_id'], ['players.steam_id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('key')
|
||||||
|
)
|
||||||
|
op.drop_table('auth_session')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('auth_session',
|
||||||
|
sa.Column('player_id', sa.BIGINT(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||||
|
sa.Column('key', sa.VARCHAR(length=31), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['player_id'], ['players.steam_id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('player_id')
|
||||||
|
)
|
||||||
|
op.drop_table('auth_sessions')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Initial migration
|
||||||
|
|
||||||
|
Revision ID: ce676db8c655
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-10-28 17:42:13.639729
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'ce676db8c655'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('players',
|
||||||
|
sa.Column('steam_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=63), nullable=False),
|
||||||
|
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('steam_id')
|
||||||
|
)
|
||||||
|
op.create_table('teams',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('team_name', sa.String(length=63), nullable=False),
|
||||||
|
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('team_name')
|
||||||
|
)
|
||||||
|
op.create_table('players_teams',
|
||||||
|
sa.Column('player_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('team_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('team_role', sa.Enum('Player', 'CoachMentor', name='teamrole'), nullable=False),
|
||||||
|
sa.Column('playtime', sa.Interval(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['player_id'], ['players.steam_id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('player_id', 'team_id')
|
||||||
|
)
|
||||||
|
op.create_table('players_teams_roles',
|
||||||
|
sa.Column('player_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('team_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('role', sa.Enum('Unknown', 'Scout', 'PocketScout', 'FlankScout', 'Soldier', 'PocketSoldier', 'Roamer', 'Pyro', 'Demoman', 'HeavyWeapons', 'Engineer', 'Medic', 'Sniper', 'Spy', name='role'), nullable=False),
|
||||||
|
sa.Column('is_main', sa.Boolean(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['player_id', 'team_id'], ['players_teams.player_id', 'players_teams.team_id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('player_id', 'team_id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('players_teams_roles')
|
||||||
|
op.drop_table('players_teams')
|
||||||
|
op.drop_table('teams')
|
||||||
|
op.drop_table('players')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,146 @@
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
import enum
|
||||||
|
from typing import List
|
||||||
|
from flask import Flask
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from sqlalchemy import TIMESTAMP, BigInteger, Boolean, Enum, ForeignKey, ForeignKeyConstraint, Integer, Interval, MetaData, String, func
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy_utc import UtcDateTime
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
convention = {
|
||||||
|
"ix": "ix_%(column_0_label)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s"
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = MetaData(naming_convention=convention)
|
||||||
|
db = SQLAlchemy(model_class=Base, metadata=metadata)
|
||||||
|
migrate = Migrate(render_as_batch=True)
|
||||||
|
|
||||||
|
class Player(db.Model):
|
||||||
|
__tablename__ = "players"
|
||||||
|
|
||||||
|
steam_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(63))
|
||||||
|
|
||||||
|
teams: Mapped[List["PlayerTeam"]] = relationship(back_populates="player")
|
||||||
|
auth_sessions: Mapped[List["AuthSession"]] = relationship(back_populates="player")
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
class Team(db.Model):
|
||||||
|
__tablename__ = "teams"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, autoincrement=True, primary_key=True)
|
||||||
|
team_name: Mapped[str] = mapped_column(String(63), unique=True)
|
||||||
|
discord_webhook_url: Mapped[str] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
players: Mapped[List["PlayerTeam"]] = relationship(back_populates="team")
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
class PlayerTeam(db.Model):
|
||||||
|
__tablename__ = "players_teams"
|
||||||
|
|
||||||
|
class TeamRole(enum.Enum):
|
||||||
|
Player = 0
|
||||||
|
CoachMentor = 1
|
||||||
|
|
||||||
|
player_id: Mapped[int] = mapped_column(ForeignKey("players.steam_id"), primary_key=True)
|
||||||
|
team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), primary_key=True)
|
||||||
|
|
||||||
|
player: Mapped["Player"] = relationship(back_populates="teams")
|
||||||
|
team: Mapped["Team"] = relationship(back_populates="players")
|
||||||
|
|
||||||
|
player_roles: Mapped[List["PlayerTeamRole"]] = relationship(back_populates="player_team")
|
||||||
|
availability: Mapped[List["PlayerTeamAvailability"]] = relationship(back_populates="player_team")
|
||||||
|
|
||||||
|
team_role: Mapped[TeamRole] = mapped_column(Enum(TeamRole), default=TeamRole.Player)
|
||||||
|
playtime: Mapped[timedelta] = mapped_column(Interval)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
class PlayerTeamRole(db.Model):
|
||||||
|
__tablename__ = "players_teams_roles"
|
||||||
|
|
||||||
|
class Role(enum.Enum):
|
||||||
|
Unknown = 0
|
||||||
|
|
||||||
|
Scout = 1
|
||||||
|
PocketScout = 2
|
||||||
|
FlankScout = 3
|
||||||
|
|
||||||
|
Soldier = 4
|
||||||
|
PocketSoldier = 5
|
||||||
|
Roamer = 6
|
||||||
|
|
||||||
|
Pyro = 7
|
||||||
|
Demoman = 8
|
||||||
|
HeavyWeapons = 9
|
||||||
|
Engineer = 10
|
||||||
|
Medic = 11
|
||||||
|
Sniper = 12
|
||||||
|
Spy = 13
|
||||||
|
|
||||||
|
player_id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
team_id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|
||||||
|
player_team: Mapped["PlayerTeam"] = relationship("PlayerTeam", back_populates="player_roles")
|
||||||
|
|
||||||
|
#player: Mapped["Player"] = relationship(back_populates="teams")
|
||||||
|
|
||||||
|
role: Mapped[Role] = mapped_column(Enum(Role))
|
||||||
|
is_main: Mapped[bool] = mapped_column(Boolean)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
[player_id, team_id],
|
||||||
|
[PlayerTeam.player_id, PlayerTeam.team_id]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class PlayerTeamAvailability(db.Model):
|
||||||
|
__tablename__ = "players_teams_availability"
|
||||||
|
|
||||||
|
player_id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
team_id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
start_time: Mapped[datetime] = mapped_column(UtcDateTime, primary_key=True)
|
||||||
|
|
||||||
|
player_team: Mapped["PlayerTeam"] = relationship(
|
||||||
|
"PlayerTeam",back_populates="availability")
|
||||||
|
|
||||||
|
availability: Mapped[int] = mapped_column(Integer, default=2)
|
||||||
|
end_time: Mapped[datetime] = mapped_column(UtcDateTime)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
[player_id, team_id],
|
||||||
|
[PlayerTeam.player_id, PlayerTeam.team_id]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class AuthSession(db.Model):
|
||||||
|
__tablename__ = "auth_sessions"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def gen_cookie_expiration():
|
||||||
|
valid_until = date.today() + timedelta(days=7)
|
||||||
|
AuthSession.gen_cookie_expiration()
|
||||||
|
return valid_until
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(31), primary_key=True)
|
||||||
|
player_id: Mapped[int] = mapped_column(ForeignKey("players.steam_id"))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
player: Mapped["Player"] = relationship(back_populates="auth_sessions")
|
||||||
|
|
||||||
|
def init_db(app: Flask):
|
||||||
|
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///db.sqlite3"
|
||||||
|
db.init_app(app)
|
||||||
|
migrate.init_app(app, db)
|
||||||
|
return app
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"typeCheckingMode": "standard"
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
flask
|
||||||
|
|
||||||
|
Flask-CORS
|
||||||
|
|
||||||
|
sqlalchemy
|
||||||
|
Flask-SQLAlchemy
|
||||||
|
SQLAlchemy-Utc
|
||||||
|
|
||||||
|
pydantic
|
||||||
|
Flask-Pydantic
|
||||||
|
|
||||||
|
alembic
|
||||||
|
Flask-Migrate
|
||||||
|
|
||||||
|
requests
|
|
@ -0,0 +1,237 @@
|
||||||
|
import datetime
|
||||||
|
from flask import Blueprint, abort, jsonify, make_response, request
|
||||||
|
import pydantic
|
||||||
|
from flask_pydantic import validate
|
||||||
|
from models import Player, PlayerTeam, PlayerTeamAvailability, PlayerTeamRole, db
|
||||||
|
|
||||||
|
from middleware import requires_authentication
|
||||||
|
import models
|
||||||
|
import utc
|
||||||
|
|
||||||
|
|
||||||
|
api_schedule = Blueprint("schedule", __name__, url_prefix="/schedule")
|
||||||
|
|
||||||
|
class ViewScheduleForm(pydantic.BaseModel):
|
||||||
|
window_start: datetime.datetime
|
||||||
|
team_id: int
|
||||||
|
window_size_days: int = 7
|
||||||
|
|
||||||
|
@api_schedule.get("/")
|
||||||
|
@validate(query=ViewScheduleForm)
|
||||||
|
@requires_authentication
|
||||||
|
def get(query: ViewScheduleForm, *args, **kwargs):
|
||||||
|
window_start = query.window_start
|
||||||
|
window_end = window_start + datetime.timedelta(days=query.window_size_days)
|
||||||
|
player: Player = kwargs["player"]
|
||||||
|
|
||||||
|
availability_regions = db.session.query(
|
||||||
|
PlayerTeamAvailability
|
||||||
|
).where(
|
||||||
|
PlayerTeamAvailability.player_id == player.steam_id
|
||||||
|
).where(
|
||||||
|
PlayerTeamAvailability.team_id == query.team_id
|
||||||
|
).where(
|
||||||
|
PlayerTeamAvailability.start_time.between(window_start, window_end) |
|
||||||
|
PlayerTeamAvailability.end_time.between(window_start, window_end) |
|
||||||
|
|
||||||
|
# handle edge case where someone for some reason might list their
|
||||||
|
# availability spanning more than a week total
|
||||||
|
((PlayerTeamAvailability.start_time < window_start) &
|
||||||
|
(PlayerTeamAvailability.end_time > window_end))
|
||||||
|
).all()
|
||||||
|
|
||||||
|
window_size_hours = 24 * query.window_size_days
|
||||||
|
availability = [0] * window_size_hours
|
||||||
|
for region in availability_regions:
|
||||||
|
region: PlayerTeamAvailability
|
||||||
|
|
||||||
|
# this is the start time relative to the window (as timedelta)
|
||||||
|
#relative_start_time = (region.start_time.replace(tzinfo=utc.utc) - window_start)
|
||||||
|
#relative_start_hour = int(relative_start_time.total_seconds() // 3600)
|
||||||
|
#relative_end_time = (region.end_time.replace(tzinfo=utc.utc) - window_start)
|
||||||
|
#relative_end_hour = int(relative_end_time.total_seconds() // 3600)
|
||||||
|
|
||||||
|
relative_start_time = region.start_time - window_start
|
||||||
|
relative_start_hour = int(relative_start_time.total_seconds() // 3600)
|
||||||
|
relative_end_time = region.end_time - window_start
|
||||||
|
relative_end_hour = int(relative_end_time.total_seconds() // 3600)
|
||||||
|
|
||||||
|
i = max(0, relative_start_hour)
|
||||||
|
while i < window_size_hours and i < relative_end_hour:
|
||||||
|
print(i, "=", region.availability)
|
||||||
|
availability[i] = region.availability
|
||||||
|
i += 1
|
||||||
|
return {
|
||||||
|
"availability": availability
|
||||||
|
}
|
||||||
|
|
||||||
|
class PutScheduleForm(pydantic.BaseModel):
|
||||||
|
window_start: datetime.datetime
|
||||||
|
window_size_days: int = 7
|
||||||
|
team_id: int
|
||||||
|
availability: list[int]
|
||||||
|
|
||||||
|
def find_consecutive_blocks(arr: list[int]) -> list[tuple[int, int, int]]:
|
||||||
|
blocks: list[tuple[int, int, int]] = []
|
||||||
|
current_block_value = 0
|
||||||
|
current_block_start = 0
|
||||||
|
|
||||||
|
for i in range(len(arr)):
|
||||||
|
if arr[i] != current_block_value:
|
||||||
|
# we find a different value
|
||||||
|
if current_block_value > 0:
|
||||||
|
blocks.append((current_block_value, current_block_start, i))
|
||||||
|
# begin a new block
|
||||||
|
current_block_start = i
|
||||||
|
current_block_value = arr[i]
|
||||||
|
|
||||||
|
if current_block_value > 0:
|
||||||
|
blocks.append((current_block_value, current_block_start, len(arr)))
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
@api_schedule.put("/")
|
||||||
|
@validate(body=PutScheduleForm, get_json_params={})
|
||||||
|
@requires_authentication
|
||||||
|
def put(body: PutScheduleForm, **kwargs):
|
||||||
|
window_start = body.window_start.replace(tzinfo=utc.utc)
|
||||||
|
window_end = window_start + datetime.timedelta(days=body.window_size_days)
|
||||||
|
player: Player = kwargs["player"]
|
||||||
|
if not player:
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
# TODO: add error message
|
||||||
|
if len(body.availability) != 168:
|
||||||
|
abort(400, {
|
||||||
|
"error": "Availability must be length " + str(168)
|
||||||
|
})
|
||||||
|
|
||||||
|
cur_availability = db.session.query(
|
||||||
|
PlayerTeamAvailability
|
||||||
|
).where(
|
||||||
|
PlayerTeamAvailability.player_id == player.steam_id
|
||||||
|
).where(
|
||||||
|
PlayerTeamAvailability.team_id == body.team_id
|
||||||
|
).where(
|
||||||
|
PlayerTeamAvailability.start_time.between(window_start, window_end) |
|
||||||
|
PlayerTeamAvailability.end_time.between(window_start, window_end)
|
||||||
|
).order_by(
|
||||||
|
PlayerTeamAvailability.start_time
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# cut the availability times so that they do not intersect our window
|
||||||
|
if len(cur_availability) > 0:
|
||||||
|
if cur_availability[0].start_time < window_start:
|
||||||
|
if cur_availability[0].end_time > window_end:
|
||||||
|
# if the availability overlaps the entire window, duplicate it
|
||||||
|
# this way, we can trim the start_time of the duplicate
|
||||||
|
cur_availability.append(cur_availability[0])
|
||||||
|
cur_availability[0].end_time = window_start
|
||||||
|
if cur_availability[-1].end_time > window_end:
|
||||||
|
cur_availability[-1].start_time = window_end
|
||||||
|
|
||||||
|
# remove all availability regions strictly inside window
|
||||||
|
i = 0
|
||||||
|
for region in cur_availability[:]:
|
||||||
|
if region.start_time >= window_start and region.end_time <= window_end:
|
||||||
|
print("Deleting", region)
|
||||||
|
db.session.delete(region)
|
||||||
|
cur_availability.pop(i)
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if len(cur_availability) > 2:
|
||||||
|
# this is not supposed to happen
|
||||||
|
db.session.rollback()
|
||||||
|
raise ValueError()
|
||||||
|
|
||||||
|
# create time regions inside our window based on the availability array
|
||||||
|
availability_blocks = []
|
||||||
|
|
||||||
|
for block in find_consecutive_blocks(body.availability):
|
||||||
|
availability_value = block[0]
|
||||||
|
hour_start = block[1]
|
||||||
|
hour_end = block[2]
|
||||||
|
|
||||||
|
abs_start = window_start + datetime.timedelta(hours=hour_start)
|
||||||
|
abs_end = window_start + datetime.timedelta(hours=hour_end)
|
||||||
|
|
||||||
|
print("Create availability from", abs_start, "to", abs_end)
|
||||||
|
|
||||||
|
new_availability = PlayerTeamAvailability()
|
||||||
|
new_availability.availability = availability_value
|
||||||
|
new_availability.start_time = abs_start
|
||||||
|
new_availability.end_time = abs_end
|
||||||
|
new_availability.player_id = player.steam_id
|
||||||
|
new_availability.team_id = body.team_id
|
||||||
|
|
||||||
|
availability_blocks.append(new_availability)
|
||||||
|
|
||||||
|
# merge availability blocks if needed
|
||||||
|
if len(cur_availability) > 0 and len(availability_blocks) > 0:
|
||||||
|
if availability_blocks[0].start_time == cur_availability[0].end_time:
|
||||||
|
cur_availability[0].end_time = availability_blocks[0].end_time
|
||||||
|
availability_blocks.pop(0)
|
||||||
|
|
||||||
|
if len(cur_availability) > 0 and len(availability_blocks) > 0:
|
||||||
|
if availability_blocks[-1].end_time == cur_availability[-1].start_time:
|
||||||
|
cur_availability[-1].start_time = availability_blocks[-1].start_time
|
||||||
|
availability_blocks.pop(-1)
|
||||||
|
|
||||||
|
db.session.add_all(availability_blocks)
|
||||||
|
db.session.commit()
|
||||||
|
return make_response({ }, 300)
|
||||||
|
|
||||||
|
class ViewAvailablePlayersForm(pydantic.BaseModel):
|
||||||
|
start_time: datetime.datetime
|
||||||
|
team_id: int
|
||||||
|
|
||||||
|
@api_schedule.get("/view-available")
|
||||||
|
@validate()
|
||||||
|
@requires_authentication
|
||||||
|
def view_available(query: ViewAvailablePlayersForm, **kwargs):
|
||||||
|
start_time = query.start_time.replace(tzinfo=utc.utc)
|
||||||
|
player: Player = kwargs["player"]
|
||||||
|
|
||||||
|
#q = (
|
||||||
|
# db.select(PlayerTeamAvailability)
|
||||||
|
# .filter(
|
||||||
|
# (PlayerTeamAvailability.player_id == player.steam_id) &
|
||||||
|
# (PlayerTeamAvailability.team_id == query.team_id) &
|
||||||
|
# (PlayerTeamAvailability.start_time == start_time)
|
||||||
|
# )
|
||||||
|
#)
|
||||||
|
|
||||||
|
#availability: Sequence[PlayerTeamAvailability] = \
|
||||||
|
# db.session.execute(q).scalars().all()
|
||||||
|
|
||||||
|
availability = db.session.query(
|
||||||
|
PlayerTeamAvailability
|
||||||
|
).where(
|
||||||
|
PlayerTeamAvailability.player_id == player.steam_id
|
||||||
|
).where(
|
||||||
|
PlayerTeamAvailability.team_id == query.team_id
|
||||||
|
).where(
|
||||||
|
(PlayerTeamAvailability.start_time <= start_time) &
|
||||||
|
(PlayerTeamAvailability.end_time > start_time)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
def map_roles_to_json(roles: list[PlayerTeamRole],
|
||||||
|
player_team: PlayerTeam,
|
||||||
|
entry: PlayerTeamAvailability):
|
||||||
|
for role in roles:
|
||||||
|
yield {
|
||||||
|
"steamId": entry.player_id,
|
||||||
|
"username": entry.player_team.player.username,
|
||||||
|
"role": role.role.name,
|
||||||
|
"isMain": role.is_main,
|
||||||
|
"availability": entry.availability,
|
||||||
|
"playtime": int(player_team.playtime.total_seconds()),
|
||||||
|
}
|
||||||
|
|
||||||
|
def map_availability_to_json(entry: PlayerTeamAvailability):
|
||||||
|
player_team = entry.player_team
|
||||||
|
player_roles = player_team.player_roles
|
||||||
|
return list(map_roles_to_json(player_roles, player_team, entry))
|
||||||
|
|
||||||
|
return jsonify(list(map(map_availability_to_json, availability)))
|
|
@ -0,0 +1,53 @@
|
||||||
|
import datetime
|
||||||
|
from typing import List
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
import pydantic
|
||||||
|
from flask_pydantic import validate
|
||||||
|
from models import Player, PlayerTeam, Team, db
|
||||||
|
from middleware import requires_authentication
|
||||||
|
import models
|
||||||
|
|
||||||
|
|
||||||
|
api_team = Blueprint("team", __name__, url_prefix="/team")
|
||||||
|
|
||||||
|
@api_team.get("/view/")
|
||||||
|
@api_team.get("/view/<team_id>/")
|
||||||
|
@requires_authentication
|
||||||
|
def view(team_id = None, **kwargs):
|
||||||
|
player: Player = kwargs["player"]
|
||||||
|
|
||||||
|
q_filter = PlayerTeam.player_id == player.steam_id
|
||||||
|
if team_id is not None:
|
||||||
|
q_filter = q_filter & (PlayerTeam.team_id == team_id)
|
||||||
|
|
||||||
|
q = db.session.query(
|
||||||
|
Team
|
||||||
|
).join(
|
||||||
|
PlayerTeam
|
||||||
|
).join(
|
||||||
|
Player
|
||||||
|
).filter(
|
||||||
|
PlayerTeam.player_id == player.steam_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def map_player_team_to_player_json(player_team: PlayerTeam):
|
||||||
|
return {
|
||||||
|
"steamId": player_team.player.steam_id,
|
||||||
|
"username": player_team.player.username,
|
||||||
|
}
|
||||||
|
|
||||||
|
def map_team_to_json(team: Team):
|
||||||
|
return {
|
||||||
|
"teamName": team.team_name,
|
||||||
|
"id": team.id,
|
||||||
|
"players": list(map(map_player_team_to_player_json, team.players)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if team_id is None:
|
||||||
|
teams = q.all()
|
||||||
|
return jsonify(list(map(map_team_to_json, teams)))
|
||||||
|
else:
|
||||||
|
team = q.one_or_none()
|
||||||
|
if team:
|
||||||
|
return jsonify(map_team_to_json(team))
|
||||||
|
return jsonify(), 404
|
|
@ -0,0 +1,15 @@
|
||||||
|
from datetime import timedelta, tzinfo
|
||||||
|
|
||||||
|
delta_zero = timedelta(0)
|
||||||
|
|
||||||
|
class UTC(tzinfo):
|
||||||
|
def utcoffset(self, dt):
|
||||||
|
return delta_zero
|
||||||
|
|
||||||
|
def dst(self, dt):
|
||||||
|
return delta_zero
|
||||||
|
|
||||||
|
def tzname(self, dt):
|
||||||
|
return "UTC"
|
||||||
|
|
||||||
|
utc = UTC()
|
Loading…
Reference in New Issue