Implement some basic features
parent
06ef477d65
commit
3fab130ae0
|
@ -1,3 +1,4 @@
|
|||
__pycache__/
|
||||
db.sqlite3
|
||||
sqlite3/
|
||||
venv/
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
VITE_API_BASE_URL=/api
|
|
@ -1,5 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from "vue-router";
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -9,6 +11,16 @@ import { RouterLink, RouterView } from "vue-router";
|
|||
<RouterLink to="/">Home</RouterLink>
|
||||
<RouterLink to="/schedule">Schedule</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>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
@ -49,10 +49,13 @@ button {
|
|||
}
|
||||
|
||||
button > i.bi {
|
||||
margin-right: 4px;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button > i.bi.margin {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--surface-0);
|
||||
}
|
||||
|
@ -85,6 +88,16 @@ button.transparent:hover {
|
|||
background-color: var(--surface-0);
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
color: var(--overlay-0);
|
||||
cursor: initial;
|
||||
}
|
||||
|
||||
button[disabled]:hover {
|
||||
color: var(--overlay-0);
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 800;
|
||||
font-size: 200%;
|
||||
|
@ -104,5 +117,5 @@ select {
|
|||
}
|
||||
|
||||
.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";
|
||||
|
||||
const model = defineModel();
|
||||
const firstHour = 14;
|
||||
const lastHour = 22;
|
||||
|
||||
const props = defineProps({
|
||||
selectionMode: Number,
|
||||
isDisabled: Boolean,
|
||||
dateStart: Date,
|
||||
firstHour: {
|
||||
type: Number,
|
||||
default: 14
|
||||
},
|
||||
lastHour: {
|
||||
type: Number,
|
||||
default: 22,
|
||||
},
|
||||
});
|
||||
|
||||
const selectionStart = reactive({ x: undefined, y: undefined });
|
||||
|
@ -21,15 +27,15 @@ const lowerBoundX = computed(() => {
|
|||
Math.min(selectionStart.x, selectionEnd.x)
|
||||
});
|
||||
const upperBoundX = computed(() => {
|
||||
return isShiftDown.value ? 7 :
|
||||
return isShiftDown.value ? 6 :
|
||||
Math.max(selectionStart.x, selectionEnd.x)
|
||||
});
|
||||
const lowerBoundY = computed(() => {
|
||||
return isCtrlDown.value ? firstHour :
|
||||
return isCtrlDown.value ? props.firstHour :
|
||||
Math.min(selectionStart.y, selectionEnd.y)
|
||||
});
|
||||
const upperBoundY = computed(() => {
|
||||
return isCtrlDown.value ? lastHour :
|
||||
return isCtrlDown.value ? props.lastHour :
|
||||
Math.max(selectionStart.y, selectionEnd.y)
|
||||
});
|
||||
|
||||
|
@ -53,8 +59,8 @@ const days = computed(() => {
|
|||
});
|
||||
|
||||
const hours = computed(() => {
|
||||
return Array.from(Array(lastHour - firstHour + 1).keys())
|
||||
.map(x => x + firstHour);
|
||||
return Array.from(Array(props.lastHour - props.firstHour + 1).keys())
|
||||
.map(x => x + props.firstHour);
|
||||
});
|
||||
|
||||
const daysOfWeek = [
|
||||
|
|
|
@ -3,6 +3,10 @@ import { computed, defineModel } from "vue";
|
|||
|
||||
const model = defineModel();
|
||||
|
||||
const props = defineProps({
|
||||
isDisabled: Boolean,
|
||||
});
|
||||
|
||||
const dateStart = computed(() => model.value.toLocaleDateString());
|
||||
const dateEnd = computed(() => {
|
||||
let dateEndObject = new Date(model.value);
|
||||
|
@ -19,11 +23,11 @@ function incrementDate(delta: number) {
|
|||
|
||||
<template>
|
||||
<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>
|
||||
</button>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from "vue-router";
|
|||
import HomeView from "../views/HomeView.vue";
|
||||
import ScheduleView from "../views/ScheduleView.vue";
|
||||
import RosterBuilderView from "../views/RosterBuilderView.vue";
|
||||
import LoginView from "../views/LoginView.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
@ -11,6 +12,11 @@ const router = createRouter({
|
|||
name: "home",
|
||||
component: HomeView
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: LoginView
|
||||
},
|
||||
{
|
||||
path: "/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 { reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
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 route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const teamId = computed({
|
||||
get: () => route.query.teamId,
|
||||
set: (value) => router.push({ query: { teamId: value } }),
|
||||
});
|
||||
|
||||
watch(dateStart, () => {
|
||||
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 {
|
||||
dateStart,
|
||||
windowStart,
|
||||
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 AvailabilityComboBox from "../components/AvailabilityComboBox.vue";
|
||||
import WeekSelectionBox from "../components/WeekSelectionBox.vue";
|
||||
import { reactive, ref } from "vue";
|
||||
import { useScheduleStore } from "../stores/schedule.ts";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useTeamsStore } from "../stores/teams";
|
||||
import { useScheduleStore } from "../stores/schedule";
|
||||
|
||||
const teams = useTeamsStore();
|
||||
const schedule = useScheduleStore();
|
||||
|
||||
const options = reactive([
|
||||
const options = ref([
|
||||
"TEAM PEPEJA forsenCD",
|
||||
"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 availability = reactive(new Array(168));
|
||||
|
@ -20,6 +26,24 @@ const availability = schedule.availability;
|
|||
const selectionMode = ref(1);
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
@ -31,16 +55,25 @@ const isEditing = ref(false);
|
|||
<AvailabilityComboBox :options="options" v-model="comboBoxIndex" />
|
||||
</div>
|
||||
<div>
|
||||
<WeekSelectionBox v-model="schedule.dateStart" />
|
||||
<WeekSelectionBox
|
||||
v-model="schedule.dateStart"
|
||||
:is-disabled="isEditing" />
|
||||
</div>
|
||||
</div>
|
||||
<AvailabilityGrid v-model="availability"
|
||||
:selection-mode="selectionMode"
|
||||
:is-disabled="!isEditing"
|
||||
:date-start="schedule.dateStart"
|
||||
:first-hour="firstHour"
|
||||
:last-hour="lastHour"
|
||||
/>
|
||||
<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">
|
||||
<div class="radio-group">
|
||||
<button
|
||||
|
@ -56,14 +89,12 @@ const isEditing = ref(false);
|
|||
Definitely available
|
||||
</button>
|
||||
</div>
|
||||
<button @click="isEditing = false">
|
||||
<button @click="saveSchedule()">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
Save
|
||||
</button>
|
||||
</template>
|
||||
<button v-else class="accent" @click="isEditing = true">
|
||||
<i class="bi bi-pencil-fill"></i>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -12,5 +12,29 @@ export default defineConfig({
|
|||
alias: {
|
||||
'@': 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