Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Team logos #529

Merged
merged 14 commits into from
Feb 28, 2024
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))

Check warning on line 21 in backend/alembic/versions/19ddf67a4eeb_adding_teams_logo_paths.py

View check run for this annotation

Codecov / codecov/patch

backend/alembic/versions/19ddf67a4eeb_adding_teams_logo_paths.py#L21

Added line #L21 was not covered by tests


def downgrade() -> None:
op.drop_column("teams", "logo_path")

Check warning on line 25 in backend/alembic/versions/19ddf67a4eeb_adding_teams_logo_paths.py

View check run for this annotation

Codecov / codecov/patch

backend/alembic/versions/19ddf67a4eeb_adding_teams_logo_paths.py#L25

Added line #L25 was not covered by tests
18 changes: 18 additions & 0 deletions backend/bracket/logic/teams.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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.logo_path}" if team is not None and team.logo_path is not None else None
)
return logo_path if logo_path is not None and await aiofiles.os.path.exists(logo_path) else None


async def delete_team_logo(tournament_id: TournamentId, team_id: TeamId) -> None:
logo_path = await get_team_logo_path(tournament_id, team_id)
if logo_path is not None:
await aiofiles.os.remove(logo_path)

Check warning on line 18 in backend/bracket/logic/teams.py

View check run for this annotation

Codecov / codecov/patch

backend/bracket/logic/teams.py#L16-L18

Added lines #L16 - L18 were not covered by tests
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
46 changes: 45 additions & 1 deletion backend/bracket/routes/teams.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException
import os
from uuid import uuid4

import aiofiles
from fastapi import APIRouter, Depends, HTTPException, UploadFile
from heliclockter import datetime_utc
from starlette import status

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 delete_team_logo, 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 @@ -29,6 +34,7 @@
from bracket.sql.validation import check_foreign_keys_belong_to_tournament
from bracket.utils.db import fetch_one_parsed
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 @@ -103,6 +109,44 @@
)


@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:
old_logo_path = await get_team_logo_path(tournament_id, assert_some(team.id))
robigan marked this conversation as resolved.
Show resolved Hide resolved
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/{filename}" if file is not None else None
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be better to store it in a subdirectory team_logos actually, I should have done that too for the tournament logos

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want me to store the tournaments logos in a subdir too? I assume that's what you want so I'll commit that in if you don't reply to this before I make the change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, I won't use team_logosbut rather team-logos as REST generally tends to prefer - over _.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want me to store the tournaments logos in a subdir too?

I was hesitant because it's a breaking change but let's just do it


if new_logo_path:
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 delete_team_logo(tournament_id, assert_some(team.id))
except Exception as exc:
logger.error(f"Could not remove logo that should still exist: {old_logo_path}\n{exc}")

Check warning on line 139 in backend/bracket/routes/teams.py

View check run for this annotation

Codecov / codecov/patch

backend/bracket/routes/teams.py#L136-L139

Added lines #L136 - L139 were not covered by tests

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


@router.delete("/tournaments/{tournament_id}/teams/{team_id}", response_model=SuccessResponse)
async def delete_team(
tournament_id: TournamentId,
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
40 changes: 40 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,39 @@ 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/{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']}")
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
4 changes: 3 additions & 1 deletion frontend/public/locales/nl/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"invalid_password_validation": "Ongeldig wachtwoord",
"iterations_input_label": "Iteraties",
"login_success_title": "Login succesvol",
"logo_settings_title": "Logo-instellingen",
"logout_success_title": "Uitloggen succesvol",
"logout_title": "Uitloggen",
"lowercase_required": "Heeft een kleine letter",
Expand Down Expand Up @@ -212,7 +213,8 @@
"tournament_title": "toernooi",
"tournaments_title": "toernooien",
"upcoming_matches_empty_table_info": "komende wedstrijden",
"upload_placeholder": "Plaats hier een bestand om te uploaden als toernooilogo.",
"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",
Expand Down
4 changes: 3 additions & 1 deletion frontend/public/locales/zh-CN/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"invalid_password_validation": "无效的密码",
"iterations_input_label": "迭代",
"login_success_title": "登录成功",
"logo_settings_title": "徽标设置",
"logout_title": "登出",
"lowercase_required": "包含小写字母",
"margin_minutes_choose_title": "请选择比赛之间的间隔",
Expand Down Expand Up @@ -202,7 +203,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
35 changes: 25 additions & 10 deletions frontend/src/components/utils/file_upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,47 @@ import { Group, Text } from '@mantine/core';
import { Dropzone, MIME_TYPES } from '@mantine/dropzone';
import { IconCloudUpload, IconDownload, IconX } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useRef } from 'react';
import { useMemo, useRef } from 'react';
import { SWRResponse } from 'swr';

import { AxiosError } from 'axios';
import { Tournament } from '../../interfaces/tournament';
import { handleRequestError, uploadLogo } from '../../services/adapter';
import { handleRequestError, uploadTeamLogo, uploadTournamentLogo } from '../../services/adapter';
import { TeamInterface } from '../../interfaces/team';

export function DropzoneButton({
tournament,
swrTournamentResponse,
tournamentId,
swrResponse,
variant,
teamId,
}: {
tournament: Tournament;
swrTournamentResponse: SWRResponse;
tournamentId: Tournament['id'];
swrResponse: SWRResponse;
variant: 'tournament' | 'team';
teamId?: TeamInterface['id'];
}) {
// const { classes, theme } = useStyles();
const openRef = useRef<() => void>(null);
const { t } = useTranslation();

const useUploadLogo = useMemo(() => {
if (variant === 'tournament') {
return uploadTournamentLogo.bind(null, tournamentId);
}

if (teamId === undefined) throw new TypeError('Team is undefined');
return uploadTeamLogo.bind(null, tournamentId, teamId);
}, [tournamentId, teamId, variant]);

return (
<div>
<Dropzone
mt="lg"
openRef={openRef}
onDrop={async (files) => {
const response = await uploadLogo(tournament.id, files[0]);
await swrTournamentResponse.mutate();
handleRequestError(response);
const response = await useUploadLogo(files[0]);
await swrResponse.mutate();
handleRequestError(response as unknown as AxiosError); // TODO: Check with Erik if this is correct
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't it be handleRequestError(response as AxiosError); instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, TypeScript complains about the insufficient overlap. I just added type defs to the axios library as it was annoying me that there weren't any. Also why is Axios required rather than imported?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also why is Axios required rather than imported?

Ah no particular reason, must be an oversight.

}}
// className={classes.dropzone}
radius="md"
Expand All @@ -53,7 +68,7 @@ export function DropzoneButton({
<Dropzone.Idle>{t('dropzone_idle_text')}</Dropzone.Idle>
</Text>
<Text ta="center" size="sm" mt="xs" c="dimmed">
{t('upload_placeholder')}
{t(`upload_placeholder_${variant}`)}
<br />
{t('dropzone_reject_text')}
</Text>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/interfaces/team.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export interface TeamInterface {
wins: number;
draws: number;
losses: number;
logo_path: string;
}
Loading