Implement some basic features

master
John Montagu, the 4th Earl of Sandvich 2024-11-02 12:33:27 -07:00
parent 06ef477d65
commit 3fab130ae0
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
38 changed files with 1506 additions and 20 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
__pycache__/ __pycache__/
db.sqlite3 db.sqlite3
sqlite3/ sqlite3/
venv/

View File

@ -0,0 +1 @@
VITE_API_BASE_URL=/api

View File

@ -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>

View File

@ -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);
} }

View File

@ -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;
}
};

View File

@ -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 = [

View File

@ -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 }} &ndash; {{ dateEnd }}</span> <span class="date-range">{{ dateStart }} &ndash; {{ 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>

View File

@ -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",

View File

@ -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,
}
});

View File

@ -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,
}; };
}); });

View File

@ -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,
}
});

View File

@ -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>

View File

@ -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>

View File

@ -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);
}
});
}
}
}
} }
}) })

View File

@ -0,0 +1 @@
export FLASK_APP=app.py

View File

@ -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)

View File

@ -0,0 +1,7 @@
import pydantic
class User(pydantic.BaseModel):
steam_id: int = pydantic.Field(2)
class TestForm(pydantic.BaseModel):
value: str = "lol"

View File

@ -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]

View File

@ -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

View File

@ -0,0 +1 @@
Single-database configuration for Flask.

View File

@ -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

View File

@ -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()

View File

@ -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"}

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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')

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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

View File

@ -0,0 +1,3 @@
{
"typeCheckingMode": "standard"
}

View File

@ -0,0 +1,15 @@
flask
Flask-CORS
sqlalchemy
Flask-SQLAlchemy
SQLAlchemy-Utc
pydantic
Flask-Pydantic
alembic
Flask-Migrate
requests

View File

@ -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)))

View File

@ -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

View File

@ -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()