Skip to content

Commit

Permalink
Team logos (#529)
Browse files Browse the repository at this point in the history
  • Loading branch information
robigan authored Feb 28, 2024
1 parent 949301c commit e3fa10e
Show file tree
Hide file tree
Showing 18 changed files with 224 additions and 29 deletions.
25 changes: 25 additions & 0 deletions backend/alembic/versions/19ddf67a4eeb_adding_teams_logo_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Adding teams.logo_paths
Revision ID: 19ddf67a4eeb
Revises: c08e04993dd7
Create Date: 2024-02-24 12:14:16.037628
"""

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str | None = "19ddf67a4eeb"
down_revision: str | None = "c08e04993dd7"
branch_labels: str | None = None
depends_on: str | None = None


def upgrade() -> None:
op.add_column("teams", sa.Column("logo_path", sa.String(), nullable=True))


def downgrade() -> None:
op.drop_column("teams", "logo_path")
14 changes: 14 additions & 0 deletions backend/bracket/logic/teams.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import aiofiles.os

from bracket.sql.teams import get_team_by_id
from bracket.utils.id_types import TeamId, TournamentId


async def get_team_logo_path(tournament_id: TournamentId, team_id: TeamId) -> str | None:
team = await get_team_by_id(team_id, tournament_id)
logo_path = (
f"static/team-logos/{team.logo_path}"
if team is not None and team.logo_path is not None
else None # pylint: disable=line-too-long
)
return logo_path if logo_path is not None and await aiofiles.os.path.exists(logo_path) else None
2 changes: 1 addition & 1 deletion backend/bracket/logic/tournaments.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

async def get_tournament_logo_path(tournament_id: TournamentId) -> str | None:
tournament = await sql_get_tournament(tournament_id)
logo_path = f"static/{tournament.logo_path}" if tournament.logo_path else None
logo_path = f"static/tournament-logos/{tournament.logo_path}" if tournament.logo_path else None
return logo_path if logo_path is not None and await aiofiles.os.path.exists(logo_path) else None


Expand Down
2 changes: 2 additions & 0 deletions backend/bracket/models/db/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class Team(BaseModelORM):
wins: int = 0
draws: int = 0
losses: int = 0
logo_path: str | None = None


class TeamWithPlayers(BaseModel):
Expand All @@ -37,6 +38,7 @@ class TeamWithPlayers(BaseModel):
draws: int = 0
losses: int = 0
name: str
logo_path: str | None = None

@property
def player_ids(self) -> list[PlayerId]:
Expand Down
47 changes: 46 additions & 1 deletion backend/bracket/routes/teams.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
from fastapi import APIRouter, Depends
import os
from uuid import uuid4

import aiofiles
import aiofiles.os
from fastapi import APIRouter, Depends, UploadFile
from heliclockter import datetime_utc

from bracket.database import database
from bracket.logic.ranking.elo import recalculate_ranking_for_tournament_id
from bracket.logic.subscriptions import check_requirement
from bracket.logic.teams import get_team_logo_path
from bracket.models.db.team import FullTeamWithPlayers, Team, TeamBody, TeamMultiBody, TeamToInsert
from bracket.models.db.user import UserPublic
from bracket.routes.auth import (
Expand All @@ -28,6 +34,7 @@
from bracket.utils.db import fetch_one_parsed
from bracket.utils.errors import ForeignKey, check_foreign_key_violation
from bracket.utils.id_types import PlayerId, TeamId, TournamentId
from bracket.utils.logging import logger
from bracket.utils.pagination import PaginationTeams
from bracket.utils.types import assert_some

Expand Down Expand Up @@ -102,6 +109,44 @@ async def update_team_by_id(
)


@router.post("/tournaments/{tournament_id}/teams/{team_id}/logo", response_model=SingleTeamResponse)
async def update_team_logo(
tournament_id: TournamentId,
file: UploadFile | None = None,
_: UserPublic = Depends(user_authenticated_for_tournament),
team: Team = Depends(team_dependency),
) -> SingleTeamResponse:
team_id = assert_some(team.id)
old_logo_path = await get_team_logo_path(tournament_id, team_id)
filename: str | None = None
new_logo_path: str | None = None

if file:
assert file.filename is not None
extension = os.path.splitext(file.filename)[1]
assert extension in (".png", ".jpg", ".jpeg")

filename = f"{uuid4()}{extension}"
new_logo_path = f"static/team-logos/{filename}" if file is not None else None

if new_logo_path:
await aiofiles.os.makedirs("static/team-logos", exist_ok=True)
async with aiofiles.open(new_logo_path, "wb") as f:
await f.write(await file.read())

if old_logo_path is not None and old_logo_path != new_logo_path:
try:
await aiofiles.os.remove(old_logo_path)
except Exception as exc:
logger.error(f"Could not remove logo that should still exist: {old_logo_path}\n{exc}")

await database.execute(
teams.update().where(teams.c.id == team_id),
values={"logo_path": filename},
)
return SingleTeamResponse(data=assert_some(await get_team_by_id(team_id, tournament_id)))


@router.delete("/tournaments/{tournament_id}/teams/{team_id}", response_model=SuccessResponse)
async def delete_team(
tournament_id: TournamentId,
Expand Down
3 changes: 2 additions & 1 deletion backend/bracket/routes/tournaments.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,10 @@ async def upload_logo(
assert extension in (".png", ".jpg", ".jpeg")

filename = f"{uuid4()}{extension}"
new_logo_path = f"static/{filename}" if file is not None else None
new_logo_path = f"static/tournament-logos/{filename}" if file is not None else None

if new_logo_path:
await aiofiles.os.makedirs("static/tournament-logos", exist_ok=True)
async with aiofiles.open(new_logo_path, "wb") as f:
await f.write(await file.read())

Expand Down
1 change: 1 addition & 0 deletions backend/bracket/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
Column("wins", Integer, nullable=False, server_default="0"),
Column("draws", Integer, nullable=False, server_default="0"),
Column("losses", Integer, nullable=False, server_default="0"),
Column("logo_path", String, nullable=True),
)

players = Table(
Expand Down
2 changes: 2 additions & 0 deletions backend/tests/integration_tests/api/matches_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ async def test_upcoming_matches_endpoint(
"wins": 0,
"draws": 0,
"losses": 0,
"logo_path": None,
},
"team2": {
"id": team2_inserted.id,
Expand Down Expand Up @@ -309,6 +310,7 @@ async def test_upcoming_matches_endpoint(
"wins": 0,
"draws": 0,
"losses": 0,
"logo_path": None,
},
"elo_diff": "200",
"swiss_diff": "0",
Expand Down
42 changes: 42 additions & 0 deletions backend/tests/integration_tests/api/teams_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import aiofiles.os
import aiohttp

from bracket.database import database
from bracket.models.db.team import Team
from bracket.schema import teams
Expand Down Expand Up @@ -30,6 +33,7 @@ async def test_teams_endpoint(
"wins": 0,
"draws": 0,
"losses": 0,
"logo_path": None,
}
],
"count": 1,
Expand Down Expand Up @@ -102,3 +106,41 @@ async def test_update_team_invalid_players(
HTTPMethod.PUT, f"teams/{team_inserted.id}", auth_context, None, body
)
assert response == {"detail": "Could not find Player(s) with ID {-1}"}


async def test_team_upload_and_remove_logo(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
test_file_path = "tests/integration_tests/assets/test_logo.png"
data = aiohttp.FormData()
data.add_field(
"file",
open(test_file_path, "rb"), # pylint: disable=consider-using-with
filename="test_logo.png",
content_type="image/png",
)

async with inserted_team(
DUMMY_TEAM1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as team_inserted:
response = await send_tournament_request(
method=HTTPMethod.POST,
endpoint=f"teams/{team_inserted.id}/logo",
auth_context=auth_context,
body=data,
)

assert response["data"]["logo_path"], f"Response: {response}"
assert await aiofiles.os.path.exists(f"static/team-logos/{response['data']['logo_path']}")

response = await send_tournament_request(
method=HTTPMethod.POST,
endpoint="logo",
auth_context=auth_context,
body=aiohttp.FormData(),
)

assert response["data"]["logo_path"] is None, f"Response: {response}"
assert not await aiofiles.os.path.exists(
f"static/team-logos/{response['data']['logo_path']}"
)
7 changes: 5 additions & 2 deletions backend/tests/integration_tests/api/tournaments_test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import aiofiles
import aiofiles.os
import aiohttp

from bracket.database import database
Expand Down Expand Up @@ -149,11 +150,13 @@ async def test_tournament_upload_and_remove_logo(
)

assert response["data"]["logo_path"], f"Response: {response}"
assert await aiofiles.os.path.exists(f"static/{response['data']['logo_path']}")
assert await aiofiles.os.path.exists(f"static/tournament-logos/{response['data']['logo_path']}")

response = await send_tournament_request(
method=HTTPMethod.POST, endpoint="logo", auth_context=auth_context, body=aiohttp.FormData()
)

assert response["data"]["logo_path"] is None, f"Response: {response}"
assert not await aiofiles.os.path.exists(f"static/{response['data']['logo_path']}")
assert not await aiofiles.os.path.exists(
f"static/tournament-logos/{response['data']['logo_path']}"
)
4 changes: 3 additions & 1 deletion frontend/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"invalid_password_validation": "Invalid password",
"iterations_input_label": "Iterations",
"login_success_title": "Login successful",
"logo_settings_title": "Logo Settings",
"logout_success_title": "Logout successful",
"logout_title": "Logout",
"lowercase_required": "Includes lowercase letter",
Expand Down Expand Up @@ -212,7 +213,8 @@
"tournament_title": "tournament",
"tournaments_title": "tournaments",
"upcoming_matches_empty_table_info": "upcoming matches",
"upload_placeholder": "Drop a file here to upload as tournament logo.",
"upload_placeholder_tournament": "Drop a file here to upload as tournament logo.",
"upload_placeholder_team": "Drop a file here to upload as team logo.",
"uppercase_required": "Includes uppercase letter",
"user_settings_spotlight_description": "Change name, email, password etc.",
"user_settings_title": "User Settings",
Expand Down
8 changes: 5 additions & 3 deletions frontend/public/locales/nl/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@
"invalid_password_validation": "Ongeldig wachtwoord",
"iterations_input_label": "Iteraties",
"login_success_title": "Login succesvol",
"logout_success_title": "Uitloggen succesvol",
"logo_settings_title": "Logo-instellingen",
"logout_success_title": "Uitloggen succesvol",
"logout_title": "Uitloggen",
"lowercase_required": "Heeft een kleine letter",
"margin_minutes_choose_title": "Vul de marge tussen wedstrijden in",
Expand Down Expand Up @@ -212,8 +213,9 @@
"tournament_title": "toernooi",
"tournaments_title": "toernooien",
"upcoming_matches_empty_table_info": "komende wedstrijden",
"upload_placeholder": "Plaats hier een bestand om te uploaden als toernooilogo.",
"uppercase_required": "Heeft een hoofdletter",
"upload_placeholder_tournament": "Plaats hier een bestand om te uploaden als toernooilogo.",
"upload_placeholder_team": "Drop hier een bestand om te uploaden als teamlogo.",
"uppercase_required": "Heeft een hoofdletter",
"user_settings_spotlight_description": "Wijzig naam, e-mailadres, wachtwoord etc.",
"user_settings_title": "Gebruikersinstellingen",
"user_title": "Gebruiker",
Expand Down
4 changes: 3 additions & 1 deletion frontend/public/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"invalid_password_validation": "无效的密码",
"iterations_input_label": "迭代",
"login_success_title": "登录成功",
"logo_settings_title": "徽标设置",
"logout_success_title": "登出成功",
"logout_title": "登出",
"lowercase_required": "包含小写字母",
Expand Down Expand Up @@ -212,7 +213,8 @@
"tournament_title": "锦标赛",
"tournaments_title": "锦标赛",
"upcoming_matches_empty_table_info": "即将进行的比赛",
"upload_placeholder": "在此处放置文件以上传为锦标赛标志。",
"upload_placeholder_tournament": "在此处放置文件以上传为锦标赛标志。",
"upload_placeholder_team": "在此上传文件作为队徽。",
"uppercase_required": "包含大写字母",
"user_settings_spotlight_description": "更改名称、电子邮件、密码等",
"user_settings_title": "用户设置",
Expand Down
31 changes: 28 additions & 3 deletions frontend/src/components/modals/team_update_modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Button, Checkbox, Modal, MultiSelect, TextInput } from '@mantine/core';
import { Button, Center, Checkbox, Fieldset, Modal, MultiSelect, TextInput, Image } from '@mantine/core';
import { useForm } from '@mantine/form';
import { BiEditAlt } from '@react-icons/all-files/bi/BiEditAlt';
import { useTranslation } from 'next-i18next';
Expand All @@ -7,8 +7,14 @@ import { SWRResponse } from 'swr';

import { Player } from '../../interfaces/player';
import { TeamInterface } from '../../interfaces/team';
import { getPlayers, requestSucceeded } from '../../services/adapter';
import { getBaseApiUrl, getPlayers, removeTeamLogo, requestSucceeded } from '../../services/adapter';
import { updateTeam } from '../../services/team';
import { DropzoneButton } from '../utils/file_upload';

function TeamLogo({ team }: { team: TeamInterface | null }) {
if (team == null || team.logo_path == null) return null;
return <Image radius="md" src={`${getBaseApiUrl()}/static/${team.logo_path}`} />;
}

export default function TeamUpdateModal({
tournament_id,
Expand Down Expand Up @@ -73,12 +79,31 @@ export default function TeamUpdateModal({
placeholder={t('team_member_select_placeholder')}
maxDropdownHeight={160}
searchable
mb="12rem"
mt={12}
limit={25}
{...form.getInputProps('player_ids')}
/>

<Fieldset legend={t('logo_settings_title')} mt={12} radius="md">
<DropzoneButton tournamentId={tournament_id} teamId={team.id} swrResponse={swrTeamsResponse} variant="team" />
<Center my="lg">
<div style={{ width: '50%' }}>
<TeamLogo team={team} />
</div>
</Center>
<Button
variant="outline"
color="red"
fullWidth
onClick={async () => {
await removeTeamLogo(tournament_id, team.id);
await swrTeamsResponse.mutate();
}}
>
{t('remove_logo')}
</Button>
</Fieldset>

<Button fullWidth style={{ marginTop: 10 }} color="green" type="submit">
{t('save_button')}
</Button>
Expand Down
Loading

0 comments on commit e3fa10e

Please sign in to comment.