feat(backend): Implement submitting matches
parent
aaa4d40ed9
commit
a509c797ff
|
@ -7,6 +7,7 @@ import team
|
|||
from spec import spec
|
||||
import user
|
||||
import events
|
||||
import match
|
||||
|
||||
connect_db_with_app()
|
||||
connect_celery_with_app()
|
||||
|
@ -17,6 +18,7 @@ api.register_blueprint(schedule.api_schedule)
|
|||
api.register_blueprint(team.api_team)
|
||||
api.register_blueprint(user.api_user)
|
||||
api.register_blueprint(events.api_events)
|
||||
api.register_blueprint(match.api_match)
|
||||
|
||||
@api.get("/debug/set-cookie")
|
||||
@api.post("/debug/set-cookie")
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
from models.player import Player
|
||||
from models.team import Team
|
||||
from models.player_team import PlayerTeam
|
||||
from models.team_integration import TeamDiscordIntegration, TeamLogsTfIntegration
|
||||
from flask_testing import TestCase
|
||||
from app_db import app, db, connect_db_with_app
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
|
||||
connect_db_with_app(SQLALCHEMY_DATABASE_URI, False)
|
||||
|
||||
|
||||
class BaseTestCase(TestCase):
|
||||
TESTING = True
|
||||
|
||||
def create_app(self):
|
||||
return app
|
||||
|
||||
def setUp(self):
|
||||
db.create_all()
|
||||
self.populate_db()
|
||||
return app
|
||||
|
||||
def tearDown(self):
|
||||
from app_db import db
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
def populate_db(self):
|
||||
print(list(map(lambda x: x.username, db.session.query(Player).all())))
|
||||
player = Player(steam_id=76561198248436608, username="pyro from csgo")
|
||||
team = Team(team_name="Team Pepeja", tz_timezone="America/New_York", minute_offset=30)
|
||||
|
||||
db.session.add(player)
|
||||
db.session.add(team)
|
||||
|
||||
db.session.flush()
|
||||
|
||||
|
||||
player_team = PlayerTeam(
|
||||
player_id=player.steam_id,
|
||||
team_id=team.id,
|
||||
team_role=PlayerTeam.TeamRole.Player,
|
||||
is_team_leader=True,
|
||||
)
|
||||
logs_tf_integration = TeamLogsTfIntegration(
|
||||
team_id=team.id,
|
||||
min_team_member_count=2,
|
||||
)
|
||||
|
||||
db.session.add(player_team)
|
||||
db.session.add(logs_tf_integration)
|
||||
|
||||
db.session.commit()
|
|
@ -0,0 +1,227 @@
|
|||
from collections.abc import Generator
|
||||
from datetime import timedelta, datetime
|
||||
from time import sleep
|
||||
import requests
|
||||
from sqlalchemy.sql import func, update
|
||||
from sqlalchemy.types import DATETIME, Interval
|
||||
import app_db
|
||||
import models.match
|
||||
from models.match import Match
|
||||
from models.team_match import TeamMatch
|
||||
from models.player import Player
|
||||
from models.player_match import PlayerMatch
|
||||
from models.player_team import PlayerTeam
|
||||
from models.team_integration import TeamLogsTfIntegration
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
FETCH_URL = "https://logs.tf/api/v1/log/{}"
|
||||
SEARCH_URL = "https://logs.tf/api/v1/log?limit=25?offset={}"
|
||||
|
||||
|
||||
def get_log_ids(last_log_id: int):
|
||||
current: int = 2147483647
|
||||
while current > last_log_id:
|
||||
response = requests.get(SEARCH_URL.format(current))
|
||||
for summary in response.json()["logs"]:
|
||||
id: int = summary["id"]
|
||||
if id == last_log_id:
|
||||
break
|
||||
# yield models.match.RawLogSummary.from_response(summary)
|
||||
yield id
|
||||
current = id
|
||||
|
||||
def extract(log_id: int) -> models.match.RawLogDetails:
|
||||
response = requests.get(FETCH_URL.format(log_id))
|
||||
return response.json()
|
||||
|
||||
def steam3_to_steam64(steam3_id: str) -> int:
|
||||
if steam3_id.startswith("[U:1:") and steam3_id.endswith("]"):
|
||||
numeric_id = int(steam3_id[5:-1])
|
||||
steam64_id = numeric_id + 76561197960265728
|
||||
return steam64_id
|
||||
else:
|
||||
raise ValueError("Invalid Steam3 ID format")
|
||||
|
||||
def steam64_to_steam3(steam64_id: int) -> str:
|
||||
if steam64_id >= 76561197960265728:
|
||||
numeric_id = steam64_id - 76561197960265728
|
||||
steam3_id = f"[U:1:{numeric_id}]"
|
||||
return steam3_id
|
||||
else:
|
||||
raise ValueError("Invalid Steam64 ID format")
|
||||
|
||||
def extract_steam_ids(players: dict[str, models.match.LogPlayer]):
|
||||
blue_steam_ids: list[int] = []
|
||||
red_steam_ids: list[int] = []
|
||||
steam_ids: list[int] = []
|
||||
|
||||
for steam_id, player in players.items():
|
||||
steam64_id = steam3_to_steam64(steam_id)
|
||||
steam_ids.append(steam64_id)
|
||||
if player["team"] == "Red":
|
||||
red_steam_ids.append(steam64_id)
|
||||
elif player["team"] == "Blue":
|
||||
blue_steam_ids.append(steam64_id)
|
||||
|
||||
return steam_ids, blue_steam_ids, red_steam_ids
|
||||
|
||||
@shared_task
|
||||
def update_playtime(steam_ids: list[int]):
|
||||
# update players with playtime (recalculate through aggregation)
|
||||
subquery = (
|
||||
app_db.db.session.query(
|
||||
PlayerTeam.id,
|
||||
#func.datetime(func.sum(Match.duration), "unixepoch").label("total_playtime")
|
||||
func.sum(Match.duration).label("total_playtime")
|
||||
)
|
||||
.join(PlayerMatch, PlayerMatch.player_id == PlayerTeam.player_id)
|
||||
.join(TeamMatch, TeamMatch.team_id == PlayerTeam.team_id)
|
||||
.join(Match, Match.logs_tf_id == TeamMatch.match_id)
|
||||
.where(PlayerTeam.player_id.in_(steam_ids))
|
||||
.group_by(PlayerTeam.id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
update_query = app_db.db.session.execute(
|
||||
update(PlayerTeam)
|
||||
.where(PlayerTeam.id == subquery.c.id)
|
||||
.values(playtime=subquery.c.total_playtime)
|
||||
)
|
||||
|
||||
|
||||
def get_common_teams(steam_ids: list[int]):
|
||||
#aggregate_func = None
|
||||
|
||||
#with app_db.app.app_context():
|
||||
# if app_db.db.engine.name == "postgresql":
|
||||
# aggregate_func = func.array_agg(PlayerTeam.player_id)
|
||||
# else:
|
||||
# aggregate_func = func.group_concat(PlayerTeam.player_id, ",")
|
||||
|
||||
#if aggregate_func is None:
|
||||
# raise NotImplementedError("Unsupported database engine")
|
||||
|
||||
return (
|
||||
app_db.db.session.query(
|
||||
PlayerTeam.team_id,
|
||||
func.count(PlayerTeam.team_id),
|
||||
TeamLogsTfIntegration.min_team_member_count,
|
||||
#aggregate_func
|
||||
)
|
||||
.outerjoin(
|
||||
TeamLogsTfIntegration,
|
||||
TeamLogsTfIntegration.team_id == PlayerTeam.team_id
|
||||
)
|
||||
.where(PlayerTeam.player_id.in_(steam_ids))
|
||||
.group_by(PlayerTeam.team_id)
|
||||
.order_by(func.count(PlayerTeam.team_id).desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def transform(
|
||||
log_id: int,
|
||||
details: models.match.RawLogDetails,
|
||||
existing_match: Match | None = None,
|
||||
invoked_by_team_id: int | None = None
|
||||
):
|
||||
steam_ids, blue_steam_ids, red_steam_ids = extract_steam_ids(details["players"])
|
||||
|
||||
# fetch players in steam_ids if they exist
|
||||
players = (
|
||||
app_db.db.session.query(Player)
|
||||
.where(Player.steam_id.in_(steam_ids))
|
||||
.all()
|
||||
)
|
||||
|
||||
if len(players) == 0:
|
||||
return
|
||||
|
||||
if not existing_match:
|
||||
match = Match()
|
||||
match.logs_tf_id = log_id
|
||||
match.logs_tf_title = details["info"]["title"]
|
||||
match.blue_score = details["teams"]["Blue"]["score"]
|
||||
match.red_score = details["teams"]["Red"]["score"]
|
||||
match.duration = details["length"]
|
||||
match.match_time = datetime.fromtimestamp(details["info"]["date"])
|
||||
yield match
|
||||
else:
|
||||
match = existing_match
|
||||
|
||||
#app_db.db.session.add(match)
|
||||
|
||||
for player in players:
|
||||
player_data = details["players"][steam64_to_steam3(player.steam_id)]
|
||||
|
||||
if not player_data:
|
||||
print(f"Player {player.steam_id} not found in log {log_id}")
|
||||
continue
|
||||
|
||||
player_match = PlayerMatch()
|
||||
player_match.player_id = player.steam_id
|
||||
player_match.match_id = match.logs_tf_id
|
||||
player_match.kills = player_data["kills"]
|
||||
player_match.deaths = player_data["deaths"]
|
||||
player_match.assists = player_data["assists"]
|
||||
player_match.damage = player_data["dmg"]
|
||||
player_match.damage_taken = player_data["dt"]
|
||||
|
||||
yield player_match
|
||||
|
||||
# get common teams
|
||||
# if common teams exist, automatically create a TeamMatch for the match
|
||||
for team, ids in { "Blue": blue_steam_ids, "Red": red_steam_ids }.items():
|
||||
for row in get_common_teams(ids):
|
||||
row_tuple = tuple(row)
|
||||
team_id = row_tuple[0]
|
||||
player_count = row_tuple[1]
|
||||
log_min_player_count = row_tuple[2] or 100
|
||||
|
||||
should_create_team_match = False
|
||||
|
||||
|
||||
if invoked_by_team_id and team_id == invoked_by_team_id:
|
||||
# if manually uploading a log, then add TeamMatch for the team
|
||||
# that uploaded the log
|
||||
should_create_team_match = True
|
||||
elif not invoked_by_team_id and player_count >= log_min_player_count:
|
||||
# if automatically fetching logs, then add TeamMatch for teams
|
||||
# with player count >= log_min_player_count
|
||||
should_create_team_match = True
|
||||
|
||||
if should_create_team_match:
|
||||
team_match = TeamMatch()
|
||||
team_match.team_id = team_id
|
||||
team_match.match_id = match.logs_tf_id
|
||||
team_match.team_color = team
|
||||
yield team_match
|
||||
|
||||
#app_db.db.session.flush()
|
||||
update_playtime.delay(list(map(lambda x: x.steam_id, players)))
|
||||
|
||||
|
||||
@shared_task
|
||||
def load_specific_match(id: int, team_id: int | None):
|
||||
match = (
|
||||
app_db.db.session.query(Match)
|
||||
.where(Match.logs_tf_id == id)
|
||||
.first()
|
||||
)
|
||||
|
||||
raw_match = extract(id)
|
||||
app_db.db.session.bulk_save_objects(transform(id, raw_match, match, team_id))
|
||||
app_db.db.session.commit()
|
||||
sleep(3) # avoid rate limiting if multiple tasks are queued
|
||||
|
||||
|
||||
def main():
|
||||
last: int = (
|
||||
app_db.db.session.query(
|
||||
func.max(models.match.Match.logs_tf_id)
|
||||
).scalar()
|
||||
) or 3767233
|
||||
|
||||
for summary in get_log_ids(last):
|
||||
print(summary)
|
||||
sleep(3)
|
|
@ -1,8 +1,8 @@
|
|||
from app_db import connect_celery_with_app, app, connect_db_with_app
|
||||
|
||||
connect_db_with_app(False)
|
||||
connect_db_with_app("sqlite:///db.sqlite3", False)
|
||||
connect_celery_with_app()
|
||||
|
||||
celery_app = app.extensions["celery"]
|
||||
|
||||
import jobs.test_job
|
||||
import jobs.fetch_logstf
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
from flask import Blueprint, abort
|
||||
from pydantic.v1 import validator
|
||||
#from pydantic.functional_validators import field_validator
|
||||
from spectree import Response
|
||||
from sqlalchemy.orm import joinedload
|
||||
from jobs.fetch_logstf import load_specific_match
|
||||
from models.player_team import PlayerTeam
|
||||
from models.team import Team
|
||||
from models.team_match import TeamMatch, TeamMatchSchema
|
||||
from spec import BaseModel, spec
|
||||
from middleware import requires_authentication, requires_team_membership
|
||||
from models.match import Match, MatchSchema
|
||||
from app_db import db
|
||||
from models.player import Player
|
||||
from models.player_match import PlayerMatch
|
||||
|
||||
|
||||
api_match = Blueprint("match", __name__, url_prefix="/match")
|
||||
|
||||
@api_match.get("/id/<int:match_id>")
|
||||
@spec.validate(
|
||||
resp=Response(
|
||||
HTTP_200=MatchSchema,
|
||||
),
|
||||
|
||||
)
|
||||
@requires_authentication
|
||||
def get_match(player: Player, match_id: int, **_):
|
||||
match = (
|
||||
db.session.query(Match)
|
||||
.join(PlayerMatch)
|
||||
.where(Match.logs_tf_id == match_id)
|
||||
.where(PlayerMatch.player_id == player.steam_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not match:
|
||||
abort(404)
|
||||
|
||||
return MatchSchema.from_model(match).dict(by_alias=True), 200
|
||||
|
||||
class SubmitMatchJson(BaseModel):
|
||||
match_ids: list[int]
|
||||
|
||||
@validator("match_ids")
|
||||
@classmethod
|
||||
def validate_match_ids(cls, match_ids):
|
||||
if len(match_ids) < 1:
|
||||
raise ValueError("match_ids must contain at least one match id")
|
||||
if len(match_ids) > 10:
|
||||
raise ValueError("match_ids must contain at most 10 match ids")
|
||||
|
||||
@api_match.put("/")
|
||||
@spec.validate(
|
||||
resp=Response(
|
||||
HTTP_204=None,
|
||||
),
|
||||
operation_id="submit_match",
|
||||
)
|
||||
@requires_authentication
|
||||
def submit_match(json: SubmitMatchJson, **_):
|
||||
import sys
|
||||
print(json, file=sys.stderr)
|
||||
if json.match_ids is None:
|
||||
print("json.match_ids is None", file=sys.stderr)
|
||||
|
||||
for id in json.match_ids:
|
||||
load_specific_match.delay(id, None)
|
||||
return { }, 204
|
||||
|
||||
@api_match.get("/team/<int:team_id>")
|
||||
@spec.validate(
|
||||
resp=Response(
|
||||
HTTP_200=list[TeamMatchSchema],
|
||||
),
|
||||
operation_id="get_matches_for_team",
|
||||
)
|
||||
@requires_authentication
|
||||
@requires_team_membership()
|
||||
def get_matches_for_team(team_id: Team, **_):
|
||||
matches = (
|
||||
db.session.query(TeamMatch)
|
||||
.where(TeamMatch.team_id == team_id)
|
||||
.options(joinedload(TeamMatch.match))
|
||||
.all()
|
||||
)
|
||||
|
||||
return [TeamMatchSchema.from_model(match).dict(by_alias=True) for match in matches], 200
|
||||
|
||||
@api_match.get("/player")
|
||||
@spec.validate(
|
||||
resp=Response(
|
||||
HTTP_200=list[TeamMatchSchema],
|
||||
),
|
||||
operation_id="get_matches_for_player_teams",
|
||||
)
|
||||
@requires_authentication
|
||||
def get_matches_for_player_teams(player: Player, **_):
|
||||
matches = (
|
||||
db.session.query(TeamMatch)
|
||||
.join(PlayerTeam, PlayerTeam.team_id == TeamMatch.team_id)
|
||||
.join(Match)
|
||||
.join(PlayerMatch)
|
||||
.where(PlayerMatch.player_id == player.steam_id)
|
||||
.options(joinedload(TeamMatch.match))
|
||||
.all()
|
||||
)
|
||||
return [TeamMatchSchema.from_model(match).dict(by_alias=True) for match in matches], 200
|
|
@ -0,0 +1,56 @@
|
|||
"""Add the rest of the match tables
|
||||
|
||||
Revision ID: 7995474ef2cc
|
||||
Revises: fda727438444
|
||||
Create Date: 2024-12-09 16:17:25.518959
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7995474ef2cc'
|
||||
down_revision = 'fda727438444'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('players_matches',
|
||||
sa.Column('player_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('match_id', sa.Integer(), nullable=False),
|
||||
sa.Column('kills', sa.Integer(), nullable=False),
|
||||
sa.Column('deaths', sa.Integer(), nullable=False),
|
||||
sa.Column('assists', sa.Integer(), nullable=False),
|
||||
sa.Column('damage', sa.BigInteger(), nullable=False),
|
||||
sa.Column('damage_taken', sa.BigInteger(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['match_id'], ['matches.logs_tf_id'], ),
|
||||
sa.ForeignKeyConstraint(['player_id'], ['players.steam_id'], ),
|
||||
sa.PrimaryKeyConstraint('player_id', 'match_id')
|
||||
)
|
||||
op.create_table('teams_matches',
|
||||
sa.Column('team_id', sa.Integer(), nullable=False),
|
||||
sa.Column('match_id', sa.Integer(), nullable=False),
|
||||
sa.Column('team_color', sa.String(length=4), nullable=False),
|
||||
sa.ForeignKeyConstraint(['match_id'], ['matches.logs_tf_id'], ),
|
||||
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
|
||||
sa.PrimaryKeyConstraint('team_id', 'match_id')
|
||||
)
|
||||
with op.batch_alter_table('matches', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('blue_score', sa.Integer(), nullable=False))
|
||||
batch_op.add_column(sa.Column('red_score', sa.Integer(), nullable=False))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('matches', schema=None) as batch_op:
|
||||
batch_op.drop_column('red_score')
|
||||
batch_op.drop_column('blue_score')
|
||||
|
||||
op.drop_table('teams_matches')
|
||||
op.drop_table('players_matches')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,50 @@
|
|||
"""Change intervals to integers
|
||||
|
||||
Revision ID: d15570037f47
|
||||
Revises: 7995474ef2cc
|
||||
Create Date: 2024-12-09 20:16:18.385467
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd15570037f47'
|
||||
down_revision = '7995474ef2cc'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('matches', schema=None) as batch_op:
|
||||
batch_op.alter_column('duration',
|
||||
existing_type=sa.DATETIME(),
|
||||
type_=sa.Integer(),
|
||||
existing_nullable=False)
|
||||
|
||||
with op.batch_alter_table('players_teams', schema=None) as batch_op:
|
||||
batch_op.alter_column('playtime',
|
||||
existing_type=sa.DATETIME(),
|
||||
type_=sa.Integer(),
|
||||
existing_nullable=False)
|
||||
|
||||
# ### 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.alter_column('playtime',
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.DATETIME(),
|
||||
existing_nullable=False)
|
||||
|
||||
with op.batch_alter_table('matches', schema=None) as batch_op:
|
||||
batch_op.alter_column('duration',
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.DATETIME(),
|
||||
existing_nullable=False)
|
||||
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,35 @@
|
|||
"""Add match table
|
||||
|
||||
Revision ID: fda727438444
|
||||
Revises: c242e3f99c64
|
||||
Create Date: 2024-12-09 12:45:16.974122
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'fda727438444'
|
||||
down_revision = 'c242e3f99c64'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('matches',
|
||||
sa.Column('logs_tf_id', sa.Integer(), nullable=False),
|
||||
sa.Column('logs_tf_title', sa.String(length=255), nullable=False),
|
||||
sa.Column('duration', sa.Interval(), nullable=False),
|
||||
sa.Column('match_time', sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('logs_tf_id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('matches')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,99 @@
|
|||
from datetime import datetime, timedelta
|
||||
from typing import TypedDict
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.types import TIMESTAMP, Integer, Interval, String
|
||||
import app_db
|
||||
import spec
|
||||
|
||||
|
||||
class Match(app_db.BaseModel):
|
||||
__tablename__ = "matches"
|
||||
|
||||
logs_tf_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
logs_tf_title: Mapped[str] = mapped_column(String(255))
|
||||
duration: Mapped[int] = mapped_column(Integer)
|
||||
match_time: Mapped[datetime] = mapped_column(TIMESTAMP)
|
||||
blue_score: Mapped[int] = mapped_column(Integer)
|
||||
red_score: Mapped[int] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
teams: Mapped["TeamMatch"] = relationship("TeamMatch", back_populates="match")
|
||||
players: Mapped["PlayerMatch"] = relationship("PlayerMatch", back_populates="match")
|
||||
|
||||
class MatchSchema(spec.BaseModel):
|
||||
logs_tf_id: int
|
||||
logs_tf_title: str
|
||||
duration: int
|
||||
match_time: datetime
|
||||
blue_score: int
|
||||
red_score: int
|
||||
created_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, model: Match):
|
||||
return cls(
|
||||
logs_tf_id=model.logs_tf_id,
|
||||
logs_tf_title=model.logs_tf_title,
|
||||
duration=model.duration,
|
||||
match_time=model.match_time,
|
||||
blue_score=model.blue_score,
|
||||
red_score=model.red_score,
|
||||
created_at=model.created_at
|
||||
)
|
||||
|
||||
class RawLogSummary:
|
||||
id: int
|
||||
title: str
|
||||
map: str
|
||||
date: int
|
||||
players: int
|
||||
views: int
|
||||
|
||||
@classmethod
|
||||
def from_response(cls, response: dict):
|
||||
object = cls()
|
||||
object.id = response["id"]
|
||||
object.title = response["title"]
|
||||
object.map = response["map"]
|
||||
object.date = response["date"]
|
||||
object.players = response["players"]
|
||||
object.views = response["views"]
|
||||
return object
|
||||
|
||||
class LogTeam(TypedDict):
|
||||
score: int
|
||||
#kills: int
|
||||
#deaths: int
|
||||
#dmg: int
|
||||
#charges: int
|
||||
#drops: int
|
||||
#firstcaps: int
|
||||
#caps: int
|
||||
|
||||
class LogPlayer(TypedDict):
|
||||
team: str
|
||||
kills: int
|
||||
deaths: int
|
||||
assists: int
|
||||
dmg: int
|
||||
dt: int
|
||||
|
||||
class LogInfo(TypedDict):
|
||||
title: str
|
||||
map: str
|
||||
date: int
|
||||
|
||||
class LogRound(TypedDict):
|
||||
length: int
|
||||
|
||||
class RawLogDetails(TypedDict):
|
||||
teams: dict[str, LogTeam]
|
||||
players: dict[str, LogPlayer]
|
||||
#rounds: list[LogRound]
|
||||
info: LogInfo
|
||||
length: int
|
||||
|
||||
|
||||
from models.team_match import TeamMatch
|
||||
from models.player_match import PlayerMatch
|
|
@ -16,6 +16,7 @@ class Player(app_db.BaseModel):
|
|||
teams: Mapped[list["PlayerTeam"]] = relationship(back_populates="player")
|
||||
auth_sessions: Mapped[list["AuthSession"]] = relationship(back_populates="player")
|
||||
events: Mapped[list["PlayerEvent"]] = relationship(back_populates="player")
|
||||
matches: Mapped[list["PlayerMatch"]] = relationship(back_populates="player")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
|
@ -31,3 +32,4 @@ class PlayerSchema(spec.BaseModel):
|
|||
from models.auth_session import AuthSession
|
||||
from models.player_event import PlayerEvent
|
||||
from models.player_team import PlayerTeam
|
||||
from models.player_match import PlayerMatch
|
||||
|
|
|
@ -58,7 +58,7 @@ class PlayerEventRolesSchema(spec.BaseModel):
|
|||
role=RoleSchema.from_model(player_event.role) if player_event.role else None,
|
||||
roles=[RoleSchema.from_model(role) for role in player_team.player_roles],
|
||||
has_confirmed=player_event.has_confirmed,
|
||||
playtime=int(player_team.playtime.total_seconds()),
|
||||
playtime=player_team.playtime,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.schema import ForeignKey
|
||||
from sqlalchemy.types import BigInteger, Integer
|
||||
import app_db
|
||||
|
||||
|
||||
class PlayerMatch(app_db.BaseModel):
|
||||
__tablename__ = "players_matches"
|
||||
|
||||
player_id: Mapped[int] = mapped_column(ForeignKey("players.steam_id"), primary_key=True)
|
||||
match_id: Mapped[int] = mapped_column(ForeignKey("matches.logs_tf_id"), primary_key=True)
|
||||
|
||||
kills: Mapped[int] = mapped_column(Integer)
|
||||
deaths: Mapped[int] = mapped_column(Integer)
|
||||
assists: Mapped[int] = mapped_column(Integer)
|
||||
damage: Mapped[int] = mapped_column(BigInteger)
|
||||
damage_taken: Mapped[int] = mapped_column(BigInteger)
|
||||
|
||||
player: Mapped["Player"] = relationship("Player", back_populates="matches")
|
||||
match: Mapped["Match"] = relationship("Match", back_populates="players")
|
||||
|
||||
|
||||
from models.match import Match
|
||||
from models.player import Player
|
|
@ -34,7 +34,7 @@ class PlayerTeam(app_db.BaseModel):
|
|||
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, default=timedelta(0))
|
||||
playtime: Mapped[int] = mapped_column(Integer, default=0)
|
||||
is_team_leader: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
|
|
|
@ -34,6 +34,8 @@ class Team(app_db.BaseModel):
|
|||
lazy="raise",
|
||||
)
|
||||
|
||||
matches: Mapped[list["TeamMatch"]] = relationship(back_populates="team")
|
||||
|
||||
def update_integrations(self, integrations: "TeamIntegrationSchema"):
|
||||
if integrations.discord_integration:
|
||||
discord_integration = self.discord_integration \
|
||||
|
@ -130,3 +132,4 @@ from models.team_integration import (
|
|||
TeamLogsTfIntegrationSchema,
|
||||
)
|
||||
from models.event import Event
|
||||
from models.team_match import TeamMatch
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.schema import ForeignKey
|
||||
from sqlalchemy.types import Integer, String
|
||||
import app_db
|
||||
import spec
|
||||
|
||||
|
||||
class TeamMatch(app_db.BaseModel):
|
||||
__tablename__ = "teams_matches"
|
||||
|
||||
team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), primary_key=True)
|
||||
match_id: Mapped[int] = mapped_column(ForeignKey("matches.logs_tf_id"), primary_key=True)
|
||||
team_color: Mapped[str] = mapped_column(String(4))
|
||||
|
||||
team: Mapped["Team"] = relationship("Team", back_populates="matches")
|
||||
match: Mapped["Match"] = relationship("Match", back_populates="teams")
|
||||
|
||||
class TeamMatchSchema(spec.BaseModel):
|
||||
match: "MatchSchema"
|
||||
our_score: int
|
||||
their_score: int
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, model: "TeamMatch"):
|
||||
our_score = model.match.blue_score if model.team_color == "Blue" else model.match.red_score
|
||||
their_score = model.match.red_score if model.team_color == "Blue" else model.match.blue_score
|
||||
|
||||
return cls(
|
||||
match=MatchSchema.from_model(model.match),
|
||||
our_score=our_score,
|
||||
their_score=their_score,
|
||||
)
|
||||
|
||||
|
||||
from models.match import Match, MatchSchema
|
||||
from models.team import Team
|
|
@ -24,3 +24,5 @@ pytz # timezone handling
|
|||
discord-webhook # for sending messages to Discord webhooks
|
||||
|
||||
celery[redis]
|
||||
|
||||
Flask-Testing
|
||||
|
|
|
@ -283,7 +283,7 @@ def view_available_at_time(query: ViewAvailablePlayersQuery, player: Player, **k
|
|||
|
||||
return PlayerTeamAvailabilityRoleSchema(
|
||||
player=PlayerSchema.from_model(player),
|
||||
playtime=int(player_team.playtime.total_seconds()),
|
||||
playtime=player_team.playtime,
|
||||
availability=player_avail.availability,
|
||||
roles=list(map(RoleSchema.from_model, player_roles)),
|
||||
)
|
||||
|
|
|
@ -377,7 +377,7 @@ def view_team_members(player: Player, team_id: int, **kwargs):
|
|||
steam_id=str(player.steam_id),
|
||||
roles=list(map(map_role_to_schema, roles)),
|
||||
availability=availability,
|
||||
playtime=player_team.playtime.total_seconds() / 3600,
|
||||
playtime=player_team.playtime / 3600,
|
||||
created_at=player_team.created_at,
|
||||
is_team_leader=player_team.is_team_leader,
|
||||
).dict(by_alias=True)
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import unittest
|
||||
import flask_testing
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
suite = unittest.TestLoader().discover("tests")
|
||||
unittest.TextTestRunner(verbosity=1).run(suite)
|
|
@ -0,0 +1,104 @@
|
|||
from datetime import timedelta
|
||||
from base_test_case import BaseTestCase
|
||||
|
||||
from app_db import db
|
||||
from models.match import Match, RawLogDetails
|
||||
from models.player import Player
|
||||
from models.player_match import PlayerMatch
|
||||
from models.player_team import PlayerTeam
|
||||
from models.team_match import TeamMatch
|
||||
|
||||
|
||||
class TestLogstfJob(BaseTestCase):
|
||||
def populate_db(self):
|
||||
from app_db import db
|
||||
|
||||
super().populate_db()
|
||||
|
||||
wesker_u = Player(steam_id=76561198024482308, username="Wesker U")
|
||||
wesker_u_pt = PlayerTeam(
|
||||
player_id=wesker_u.steam_id,
|
||||
team_id=1,
|
||||
team_role=PlayerTeam.TeamRole.Player,
|
||||
is_team_leader=True,
|
||||
)
|
||||
|
||||
db.session.add(wesker_u)
|
||||
db.session.add(wesker_u_pt)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def test_get_common_teams(self):
|
||||
from jobs.fetch_logstf import get_common_teams
|
||||
|
||||
rows = get_common_teams([76561198248436608, 76561198024482308])
|
||||
assert len(rows) == 1
|
||||
assert rows == [(1, 2, 2)]
|
||||
|
||||
def test_transform(self):
|
||||
from jobs.fetch_logstf import transform
|
||||
|
||||
details: RawLogDetails = {
|
||||
"players": {
|
||||
"[U:1:288170880]": {
|
||||
"team": "Red",
|
||||
"kills": 1,
|
||||
"deaths": 2,
|
||||
"assists": 3,
|
||||
"dmg": 4,
|
||||
"dt": 5,
|
||||
},
|
||||
"[U:1:64216580]": {
|
||||
"team": "Red",
|
||||
"kills": 6,
|
||||
"deaths": 7,
|
||||
"assists": 8,
|
||||
"dmg": 9,
|
||||
"dt": 10,
|
||||
},
|
||||
"[U:1:64216581]": {
|
||||
"team": "Blue",
|
||||
"kills": 6,
|
||||
"deaths": 7,
|
||||
"assists": 8,
|
||||
"dmg": 9,
|
||||
"dt": 10,
|
||||
},
|
||||
},
|
||||
"info": {
|
||||
"title": "I LOVE DUSTBOWL",
|
||||
"map": "cp_dustbowl",
|
||||
"date": 1614547200,
|
||||
},
|
||||
"teams": {
|
||||
"Blue": {
|
||||
"score": 1
|
||||
},
|
||||
"Red": {
|
||||
"score": 2
|
||||
}
|
||||
},
|
||||
"length": 3025,
|
||||
}
|
||||
|
||||
for instance in transform(1, details):
|
||||
db.session.add(instance)
|
||||
db.session.commit()
|
||||
|
||||
assert db.session.query(Player).count() == 2
|
||||
assert db.session.query(PlayerMatch).count() == 2
|
||||
assert db.session.query(TeamMatch).count() == 1
|
||||
assert db.session.query(Match).count() == 1
|
||||
assert db.session.query(PlayerTeam).count() == 2
|
||||
player_team = db.session.query(PlayerTeam).first()
|
||||
assert player_team is not None
|
||||
print(player_team.playtime)
|
||||
assert player_team.playtime == 3025
|
||||
|
||||
def test_steam3_to_steam64(self):
|
||||
from jobs.fetch_logstf import steam3_to_steam64
|
||||
assert steam3_to_steam64("[U:1:123456]") == 76561197960265728 + 123456
|
||||
|
||||
def test_steam64_to_steam3(self):
|
||||
from jobs.fetch_logstf import steam64_to_steam3
|
||||
assert steam64_to_steam3(76561197960265728 + 123456) == "[U:1:123456]"
|
|
@ -0,0 +1,83 @@
|
|||
from collections import deque, defaultdict
|
||||
|
||||
from models.player_team import PlayerTeam
|
||||
from models.player_team_role import PlayerTeamRole
|
||||
|
||||
from collections import deque
|
||||
|
||||
|
||||
class BipartiteGraph:
|
||||
graph: dict[int, list[PlayerTeamRole.Role]] = {}
|
||||
pair_u: dict[int, PlayerTeamRole.Role | None] = {}
|
||||
pair_v: dict[PlayerTeamRole.Role, int | None] = {}
|
||||
dist: dict[int | None, float] = {}
|
||||
U: set[int] = set()
|
||||
V: set[PlayerTeamRole.Role] = set()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ids_to_roles: dict[int, list[PlayerTeamRole.Role]],
|
||||
required_roles: list[PlayerTeamRole.Role],
|
||||
):
|
||||
self.graph = self.build_graph(ids_to_roles, required_roles)
|
||||
self.pair_u = {}
|
||||
self.pair_v = {}
|
||||
self.dist = {}
|
||||
self.U = set(ids_to_roles.keys())
|
||||
self.V = set(
|
||||
role
|
||||
for roles in ids_to_roles.values()
|
||||
for role in roles
|
||||
if role in required_roles
|
||||
)
|
||||
|
||||
def build_graph(
|
||||
self,
|
||||
ids_to_roles: dict[int, list[PlayerTeamRole.Role]],
|
||||
required_roles: list[PlayerTeamRole.Role],
|
||||
):
|
||||
graph = {}
|
||||
for u, roles in ids_to_roles.items():
|
||||
graph[u] = [v for v in roles if v in required_roles]
|
||||
return graph
|
||||
|
||||
def bfs(self):
|
||||
queue = deque()
|
||||
for u in self.U:
|
||||
if u not in self.pair_u:
|
||||
self.dist[u] = 0
|
||||
queue.append(u)
|
||||
else:
|
||||
self.dist[u] = float("inf")
|
||||
self.dist[None] = float("inf")
|
||||
|
||||
while queue:
|
||||
u = queue.popleft()
|
||||
if self.dist[u] < self.dist[None]:
|
||||
for v in self.graph[u]:
|
||||
if self.dist[self.pair_v.get(v, None)] == float("inf"):
|
||||
self.dist[self.pair_v.get(v, None)] = self.dist[u] + 1
|
||||
queue.append(self.pair_v.get(v, None))
|
||||
|
||||
return self.dist[None] != float("inf")
|
||||
|
||||
def dfs(self, u):
|
||||
if u is not None:
|
||||
for v in self.graph[u]:
|
||||
if self.dist[self.pair_v.get(v, None)] == self.dist[u] + 1:
|
||||
if self.dfs(self.pair_v.get(v, None)):
|
||||
self.pair_u[u] = v
|
||||
self.pair_v[v] = u
|
||||
return True
|
||||
self.dist[u] = float("inf")
|
||||
return False
|
||||
return True
|
||||
|
||||
def hopcroft_karp(self):
|
||||
matching = 0
|
||||
while self.bfs():
|
||||
for u in self.U:
|
||||
if u not in self.pair_u:
|
||||
if self.dfs(u):
|
||||
matching += 1
|
||||
return matching
|
Loading…
Reference in New Issue