Add backend stuff

master
John Montagu, the 4th Earl of Sandvich 2024-11-06 20:56:21 -08:00
parent 67567046b8
commit 4d76cdce44
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
9 changed files with 395 additions and 29 deletions

View File

@ -0,0 +1,22 @@
# Use an official Python runtime as a parent image
FROM python:3.11-slim
# Set the working directory in the container
WORKDIR /app
# Install dependencies
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Copy the entire app into the container
COPY . .
# Expose the Flask development server port
EXPOSE 5000
# Set the Flask environment to development
ENV FLASK_APP=app.py
ENV FLASK_ENV=development
# Command to run the Flask application
CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"]

View File

@ -8,9 +8,12 @@ from models import init_db
from spec import spec
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)
#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)
CORS(app, origins=["*"], supports_credentials=True)
CORS(login.api_login, origins=["*"], supports_credentials=True)
CORS(schedule.api_schedule, origins=["*"], supports_credentials=True)
init_db(app)

View File

@ -0,0 +1,55 @@
"""Add Team.tz_timezone
Revision ID: ea359b0e46d7
Revises: 2b2f3ae2ec7f
Create Date: 2024-11-03 16:53:37.904012
"""
from alembic import op
import sqlalchemy as sa
import sqlalchemy_utc
# revision identifiers, used by Alembic.
revision = 'ea359b0e46d7'
down_revision = '2b2f3ae2ec7f'
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.alter_column('start_time',
existing_type=sa.TIMESTAMP(),
type_=sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True),
existing_nullable=False)
batch_op.alter_column('end_time',
existing_type=sa.TIMESTAMP(),
type_=sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True),
existing_nullable=False)
with op.batch_alter_table('teams', schema=None) as batch_op:
batch_op.add_column(sa.Column('tz_timezone', sa.String(length=31), nullable=False, default='Etc/UTC', server_default='0'))
batch_op.add_column(sa.Column('minute_offset', sa.SmallInteger(), nullable=False, default=0, server_default='0'))
# ### 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('minute_offset')
batch_op.drop_column('tz_timezone')
with op.batch_alter_table('players_teams_availability', schema=None) as batch_op:
batch_op.alter_column('end_time',
existing_type=sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True),
type_=sa.TIMESTAMP(),
existing_nullable=False)
batch_op.alter_column('start_time',
existing_type=sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True),
type_=sa.TIMESTAMP(),
existing_nullable=False)
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""Add PlayerTeam.is_team_leader
Revision ID: f50a79c4ae22
Revises: ea359b0e46d7
Create Date: 2024-11-03 17:11:35.956743
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f50a79c4ae22'
down_revision = 'ea359b0e46d7'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('players_teams', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_team_leader', sa.Boolean(), nullable=False, server_default='0'))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('players_teams', schema=None) as batch_op:
batch_op.drop_column('is_team_leader')
# ### end Alembic commands ###

View File

@ -4,7 +4,7 @@ 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 import TIMESTAMP, BigInteger, Boolean, Enum, ForeignKey, ForeignKeyConstraint, Integer, Interval, MetaData, SmallInteger, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy_utc import UtcDateTime
@ -36,10 +36,9 @@ class Player(db.Model):
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
class PlayerSpec(spec.BaseModel):
class PlayerSchema(spec.BaseModel):
steam_id: str
username: str
#teams: list["PlayerTeamSpec"]
class Team(db.Model):
__tablename__ = "teams"
@ -47,12 +46,14 @@ class Team(db.Model):
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)
tz_timezone: Mapped[str] = mapped_column(String(31), default="Etc/UTC")
minute_offset: Mapped[int] = mapped_column(SmallInteger, default=0)
players: Mapped[List["PlayerTeam"]] = relationship(back_populates="team")
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
class TeamSpec(spec.BaseModel):
class TeamSchema(spec.BaseModel):
id: int
team_name: str
discord_webhook_url: str | None
@ -71,13 +72,18 @@ class PlayerTeam(db.Model):
player: Mapped["Player"] = relationship(back_populates="teams")
team: Mapped["Team"] = relationship(back_populates="players")
player_roles: Mapped[List["PlayerTeamRole"]] = relationship(back_populates="player_team")
player_roles: Mapped[List["PlayerTeamRole"]] = relationship("PlayerTeamRole", 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)
playtime: Mapped[timedelta] = mapped_column(Interval, default=timedelta(0))
is_team_leader: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
class PlayerTeamSchema(spec.BaseModel):
player: PlayerSchema
team: TeamSchema
class PlayerTeamRole(db.Model):
__tablename__ = "players_teams_roles"
@ -107,7 +113,7 @@ class PlayerTeamRole(db.Model):
#player: Mapped["Player"] = relationship(back_populates="teams")
role: Mapped[Role] = mapped_column(Enum(Role))
role: Mapped[Role] = mapped_column(Enum(Role), primary_key=True)
is_main: Mapped[bool] = mapped_column(Boolean)
__table_args__ = (
@ -125,7 +131,7 @@ class PlayerTeamAvailability(db.Model):
start_time: Mapped[datetime] = mapped_column(UtcDateTime, primary_key=True)
player_team: Mapped["PlayerTeam"] = relationship(
"PlayerTeam",back_populates="availability")
"PlayerTeam", back_populates="availability")
availability: Mapped[int] = mapped_column(Integer, default=2)
end_time: Mapped[datetime] = mapped_column(UtcDateTime)
@ -154,6 +160,7 @@ class AuthSession(db.Model):
def init_db(app: Flask):
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///db.sqlite3"
#app.config["SQLALCHEMY_ECHO"] = True
db.init_app(app)
migrate.init_app(app, db)
return app

View File

@ -18,3 +18,5 @@ alembic
Flask-Migrate
requests
pytz # timezone handling

View File

@ -2,6 +2,7 @@ import datetime
from typing import cast
from flask import Blueprint, abort, jsonify, make_response, request
from flask_pydantic import validate
from spectree import Response
from models import Player, PlayerTeam, PlayerTeamAvailability, PlayerTeamRole, db
from middleware import requires_authentication
from spec import spec, BaseModel
@ -14,8 +15,15 @@ class ViewScheduleForm(BaseModel):
team_id: int
window_size_days: int = 7
class ViewScheduleResponse(BaseModel):
availability: list[int]
@api_schedule.get("/")
@spec.validate()
@spec.validate(
resp=Response(
HTTP_200=ViewScheduleResponse
)
)
@requires_authentication
def get(query: ViewScheduleForm, player: Player, **kwargs):
window_start = query.window_start
@ -174,7 +182,7 @@ def put(json: PutScheduleForm, player: Player, **kwargs):
db.session.add_all(availability_blocks)
db.session.commit()
return make_response({ }, 300)
return make_response({ }, 200)
class ViewAvailablePlayersForm(BaseModel):
start_time: datetime.datetime

View File

@ -0,0 +1,27 @@
import pydantic.v1
from spectree import SpecTree
from pydantic.alias_generators import to_camel
from spectree.plugins.flask_plugin import FlaskPlugin
# Naming convention:
# https://github.com/0b01001001/spectree/issues/300
# https://github.com/0b01001001/spectree/pull/302
def naming_strategy(model):
return model.__name__
# https://github.com/0b01001001/spectree/issues/304#issuecomment-1519961668
def nested_naming_strategy(_, child):
return child
spec = SpecTree(
"flask",
annotations=True,
naming_strategy=naming_strategy,
nested_naming_strategy=nested_naming_strategy
)
class BaseModel(pydantic.v1.BaseModel):
class Config:
alias_generator = to_camel
allow_population_by_field_name = True

View File

@ -1,27 +1,174 @@
import datetime
import time
from typing import List
from flask import Blueprint, abort, jsonify, make_response, request
import pydantic
from pydantic.v1 import validator
from flask_pydantic import validate
from spectree import Response
from models import Player, PlayerTeam, Team, TeamSpec, db
from sqlalchemy.orm import joinedload, subqueryload
from models import Player, PlayerSchema, PlayerTeam, PlayerTeamAvailability, PlayerTeamRole, PlayerTeamSchema, Team, TeamSchema, db
from middleware import requires_authentication
import models
from spec import spec, BaseModel
import pytz
api_team = Blueprint("team", __name__, url_prefix="/team")
def map_team_to_schema(team: Team):
return TeamSchema(
id=team.id,
team_name=team.team_name,
discord_webhook_url=None
)
def map_player_to_schema(player: Player):
return PlayerSchema(
steam_id=str(player.steam_id),
username=player.username,
)
class CreateTeamJson(BaseModel):
team_name: str
webhook_url: str
timezone: str
discord_webhook_url: str | None = None
league_timezone: str
@validator("league_timezone")
@classmethod
def validate_timezone(cls, v):
if v not in pytz.all_timezones:
raise ValueError(v + " is not a valid timezone")
return v
@validator("team_name")
@classmethod
def validate_team_name(cls, v: str):
if not v:
raise ValueError("Team name can not be blank")
return v
class ViewTeamResponse(BaseModel):
team: models.TeamSpec
team: models.TeamSchema
class ViewTeamsResponse(BaseModel):
teams: list[models.TeamSpec]
teams: list[models.TeamSchema]
@api_team.post("/")
@spec.validate(
resp=Response(
HTTP_200=ViewTeamResponse,
HTTP_403=None,
),
operation_id="create_team"
)
@requires_authentication
def create_team(json: CreateTeamJson, player: Player, **kwargs):
team = Team(
team_name=json.team_name,
tz_timezone=json.league_timezone,
)
if json.discord_webhook_url:
team.discord_webhook_url = json.discord_webhook_url
db.session.add(team)
db.session.flush() # flush, so we can get autoincremented id
player_team = PlayerTeam(
player_id=player.steam_id,
team_id=team.id,
is_team_leader=True
)
db.session.add(player_team)
db.session.commit()
response = ViewTeamResponse(team=map_team_to_schema(team))
return jsonify(response.dict())
@api_team.delete("/id/<team_id>/")
@spec.validate(
resp=Response(
HTTP_200=None,
HTTP_403=None,
HTTP_404=None,
),
operation_id="delete_team"
)
def delete_team(player: Player, team_id: int):
player_team = db.session.query(
PlayerTeam
).where(
PlayerTeam.team_id == team_id
).where(
PlayerTeam.player_id == player.steam_id
).one_or_none()
if not player_team:
abort(404)
if not player_team.is_team_leader:
abort(403)
db.session.delete(player_team.team)
db.session.commit()
return make_response(200)
class AddPlayerJson(BaseModel):
team_role: PlayerTeam.TeamRole = PlayerTeam.TeamRole.Player
is_team_leader: bool = False
@api_team.put("/id/<team_id>/player/<player_id>/")
@spec.validate(
resp=Response(
HTTP_200=None,
HTTP_403=None,
HTTP_404=None,
),
operation_id="create_or_update_player"
)
def add_player(player: Player, team_id: int, player_id: int, json: AddPlayerJson):
player_team = db.session.query(
PlayerTeam
).where(
PlayerTeam.player_id == player.steam_id
).where(
PlayerTeam.team_id == team_id
).one_or_none()
if not player_team:
abort(404)
if not player_team.is_team_leader:
abort(403)
target_player_team = db.session.query(
PlayerTeam
).where(
PlayerTeam.player_id == player_id
).where(
PlayerTeam.team_id == team_id
).one_or_none()
if not target_player_team:
target_player = db.session.query(
Player
).where(
Player.steam_id == player_id
).one_or_none()
if not target_player:
abort(404)
target_player_team = PlayerTeam()
target_player_team.player_id = player_id
target_player_team.team_id = player_team.team_id
target_player_team.team_role = json.team_role
target_player_team.is_team_leader = json.is_team_leader
db.session.commit()
return make_response(200)
@api_team.get("/all/")
@spec.validate(
@ -29,7 +176,8 @@ class ViewTeamsResponse(BaseModel):
HTTP_200=ViewTeamsResponse,
HTTP_403=None,
HTTP_404=None,
)
),
operation_id="get_teams"
)
@requires_authentication
def view_teams(**kwargs):
@ -45,7 +193,8 @@ def view_teams(**kwargs):
HTTP_200=ViewTeamResponse,
HTTP_403=None,
HTTP_404=None,
)
),
operation_id="get_team"
)
@requires_authentication
def view_team(team_id: int, **kwargs):
@ -69,21 +218,82 @@ def fetch_teams_for_player(player: Player, team_id: int | None):
if team_id is not None:
q = q.where(PlayerTeam.team_id == team_id)
def map_team_to_spec(team: Team) -> TeamSpec:
return TeamSpec(
id=team.id,
team_name=team.team_name,
discord_webhook_url=None
)
if team_id is None:
teams = q.all()
return ViewTeamsResponse(
teams=list(map(map_team_to_spec, teams))
teams=list(map(map_team_to_schema, teams))
)
else:
team = q.one_or_none()
if team:
return ViewTeamResponse(
team=map_team_to_spec(team)
team=map_team_to_schema(team)
)
class ViewTeamMembersResponse(PlayerSchema):
class RoleSchema(BaseModel):
role: str
is_main: bool
roles: list[RoleSchema]
availability: int
playtime: float
created_at: datetime.datetime
@api_team.get("/id/<team_id>/players")
@spec.validate(
resp=Response(
HTTP_200=list[ViewTeamMembersResponse],
HTTP_403=None,
HTTP_404=None,
),
operation_id="get_team_members"
)
@requires_authentication
def view_team_members(player: Player, team_id: int, **kwargs):
now = datetime.datetime.now(datetime.timezone.utc)
player_teams_query = db.session.query(
PlayerTeam
).where(
PlayerTeam.team_id == team_id
).options(
# eager load so SQLAlchemy does not make a second query just to join
joinedload(PlayerTeam.player),
joinedload(PlayerTeam.player_roles),
joinedload(PlayerTeam.availability.and_(
(PlayerTeamAvailability.start_time <= now) &
(PlayerTeamAvailability.end_time > now)
)),
)
player_teams = player_teams_query.all()
if not next(filter(lambda x: x.player_id == player.steam_id, player_teams)):
abort(404)
def map_role_to_schema(player_team_role: PlayerTeamRole):
return ViewTeamMembersResponse.RoleSchema(
role=str(player_team_role.role),
is_main=player_team_role.is_main,
)
def map_to_response(player_team: PlayerTeam):
roles = player_team.player_roles
player = player_team.player
availability = 0
if len(player_team.availability) > 0:
print(player_team.availability)
availability = player_team.availability[0].availability
return ViewTeamMembersResponse(
username=player.username,
steam_id=str(player.steam_id),
roles=list(map(map_role_to_schema, roles)),
availability=availability,
playtime=player_team.playtime.total_seconds() / 3600,
created_at=player_team.created_at,
)
return list(map(map_to_response, player_teams))