diff --git a/backend-flask/Dockerfile b/backend-flask/Dockerfile new file mode 100644 index 0000000..09cc4d4 --- /dev/null +++ b/backend-flask/Dockerfile @@ -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"] diff --git a/backend-flask/app.py b/backend-flask/app.py index dbf9e3a..eb1124f 100644 --- a/backend-flask/app.py +++ b/backend-flask/app.py @@ -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) diff --git a/backend-flask/migrations/versions/ea359b0e46d7_add_team_tz_timezone.py b/backend-flask/migrations/versions/ea359b0e46d7_add_team_tz_timezone.py new file mode 100644 index 0000000..30a6efb --- /dev/null +++ b/backend-flask/migrations/versions/ea359b0e46d7_add_team_tz_timezone.py @@ -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 ### diff --git a/backend-flask/migrations/versions/f50a79c4ae22_add_playerteam_is_team_leader.py b/backend-flask/migrations/versions/f50a79c4ae22_add_playerteam_is_team_leader.py new file mode 100644 index 0000000..0985c71 --- /dev/null +++ b/backend-flask/migrations/versions/f50a79c4ae22_add_playerteam_is_team_leader.py @@ -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 ### diff --git a/backend-flask/models.py b/backend-flask/models.py index cbbd545..1a6a64f 100644 --- a/backend-flask/models.py +++ b/backend-flask/models.py @@ -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 diff --git a/backend-flask/requirements.txt b/backend-flask/requirements.txt index 2ae5edc..a56038c 100644 --- a/backend-flask/requirements.txt +++ b/backend-flask/requirements.txt @@ -18,3 +18,5 @@ alembic Flask-Migrate requests + +pytz # timezone handling diff --git a/backend-flask/schedule.py b/backend-flask/schedule.py index 79f62cd..473e421 100644 --- a/backend-flask/schedule.py +++ b/backend-flask/schedule.py @@ -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 diff --git a/backend-flask/spec.py b/backend-flask/spec.py new file mode 100644 index 0000000..40401f8 --- /dev/null +++ b/backend-flask/spec.py @@ -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 + diff --git a/backend-flask/team.py b/backend-flask/team.py index f4d70e8..45d7b65 100644 --- a/backend-flask/team.py +++ b/backend-flask/team.py @@ -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//") +@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//player//") +@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//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))