Multi users and teams creation (#342)

fixes https://github.com/evroon/bracket/issues/292
This commit is contained in:
Erik Vroon
2023-11-21 20:07:35 +01:00
committed by GitHub
parent 72c818e76f
commit 4e616d8d97
19 changed files with 472 additions and 122 deletions

View File

@@ -23,7 +23,12 @@ class Player(BaseModelORM):
class PlayerBody(BaseModelORM):
name: str = Field(..., max_length=30)
name: str = Field(..., min_length=1, max_length=30)
active: bool
class PlayerMultiBody(BaseModelORM):
names: str
active: bool

View File

@@ -70,9 +70,14 @@ class FullTeamWithPlayers(TeamWithPlayers, Team):
class TeamBody(BaseModelORM):
name: str = Field(..., max_length=30)
name: str = Field(..., min_length=1, max_length=30)
active: bool
player_ids: list[int] = Field(..., unique_items=True)
class TeamMultiBody(BaseModelORM):
names: str = Field(..., min_length=1, max_length=30)
active: bool
player_ids: list[int]
class TeamToInsert(BaseModelORM):

View File

@@ -4,7 +4,8 @@ from fastapi import APIRouter, Depends
from heliclockter import datetime_utc
from bracket.database import database
from bracket.models.db.player import Player, PlayerBody, PlayerToInsert
from bracket.models.db.player import Player, PlayerBody, PlayerMultiBody, PlayerToInsert
from bracket.models.db.players import START_ELO
from bracket.models.db.user import UserPublic
from bracket.routes.auth import user_authenticated_for_tournament
from bracket.routes.models import PlayersResponse, SinglePlayerResponse, SuccessResponse
@@ -66,30 +67,37 @@ async def delete_player(
return SuccessResponse()
@router.post("/tournaments/{tournament_id}/players", response_model=SinglePlayerResponse)
async def create_player(
@router.post("/tournaments/{tournament_id}/players", response_model=SuccessResponse)
async def create_single_player(
player_body: PlayerBody,
tournament_id: int,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SinglePlayerResponse:
last_record_id = await database.execute(
) -> SuccessResponse:
await insert_player(player_body, tournament_id)
return SuccessResponse()
async def insert_player(player_body: PlayerBody, tournament_id: int) -> None:
await database.execute(
query=players.insert(),
values=PlayerToInsert(
**player_body.dict(),
created=datetime_utc.now(),
tournament_id=tournament_id,
elo_score=Decimal('0.0'),
elo_score=Decimal(START_ELO),
swiss_score=Decimal('0.0'),
).dict(),
)
return SinglePlayerResponse(
data=assert_some(
await fetch_one_parsed(
database,
Player,
players.select().where(
players.c.id == last_record_id and players.c.tournament_id == tournament_id
),
)
)
)
@router.post("/tournaments/{tournament_id}/players_multi", response_model=SuccessResponse)
async def create_multiple_players(
player_body: PlayerMultiBody,
tournament_id: int,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SuccessResponse:
player_names = [player.strip() for player in player_body.names.split('\n') if len(player) > 0]
for player_name in player_names:
await insert_player(PlayerBody(name=player_name, active=player_body.active), tournament_id)
return SuccessResponse()

View File

@@ -4,7 +4,7 @@ from starlette import status
from bracket.database import database
from bracket.logic.ranking.elo import recalculate_ranking_for_tournament_id
from bracket.models.db.team import FullTeamWithPlayers, Team, TeamBody, TeamToInsert
from bracket.models.db.team import FullTeamWithPlayers, Team, TeamBody, TeamMultiBody, TeamToInsert
from bracket.models.db.user import UserPublic
from bracket.routes.auth import (
user_authenticated_for_tournament,
@@ -133,3 +133,24 @@ async def create_team(
team_result = await get_team_by_id(last_record_id, tournament_id)
assert team_result is not None
return SingleTeamResponse(data=team_result)
@router.post("/tournaments/{tournament_id}/teams_multi", response_model=SuccessResponse)
async def create_multiple_teams(
team_body: TeamMultiBody,
tournament_id: int,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SuccessResponse:
team_names = [team.strip() for team in team_body.names.split('\n') if len(team) > 0]
for team_name in team_names:
await database.execute(
query=teams.insert(),
values=TeamToInsert(
name=team_name,
active=team_body.active,
created=datetime_utc.now(),
tournament_id=tournament_id,
).dict(),
)
return SuccessResponse()

View File

@@ -41,10 +41,21 @@ async def test_create_player(
) -> None:
body = {'name': 'Some new name', 'active': True}
response = await send_tournament_request(HTTPMethod.POST, 'players', auth_context, json=body)
assert response['data']['name'] == body['name']
assert response['success'] is True
await assert_row_count_and_clear(players, 1)
async def test_create_players(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
body = {'names': 'Player x\nPlayer y', 'active': True}
response = await send_tournament_request(
HTTPMethod.POST, 'players_multi', auth_context, json=body
)
assert response['success'] is True
await assert_row_count_and_clear(players, 2)
async def test_delete_player(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:

View File

@@ -43,6 +43,17 @@ async def test_create_team(
await assert_row_count_and_clear(teams, 1)
async def test_create_teams(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
body = {'names': 'Team -1\nTeam -2', 'active': True}
response = await send_tournament_request(
HTTPMethod.POST, 'teams_multi', auth_context, None, body
)
assert response['success'] is True
await assert_row_count_and_clear(teams, 2)
async def test_delete_team(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:

View File

@@ -2,12 +2,7 @@ import { Button } from '@mantine/core';
export default function SaveButton(props: any) {
return (
<Button
color="green"
size="md"
style={{ marginBottom: 10, marginRight: 10, marginLeft: 10 }}
{...props}
>
<Button color="green" size="md" mb="10px" mx="10px" {...props}>
{props.title}
</Button>
);

View File

@@ -0,0 +1,25 @@
import { Textarea } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import React from 'react';
export function MultiPlayersInput({ form }: { form: UseFormReturnType<any> }) {
return (
<Textarea
label="Add multiple players. Put every player on a separate line"
placeholder="Player 1"
minRows={10}
{...form.getInputProps('names')}
/>
);
}
export function MultiTeamsInput({ form }: { form: UseFormReturnType<any> }) {
return (
<Textarea
label="Add multiple teams. Put every team on a separate line"
placeholder="Team 1"
minRows={10}
{...form.getInputProps('names')}
/>
);
}

View File

@@ -0,0 +1,148 @@
import { Button, Checkbox, Group, Modal, Tabs, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconUser, IconUserPlus, IconUsers } from '@tabler/icons-react';
import { useState } from 'react';
import { SWRResponse } from 'swr';
import { createMultiplePlayers, createPlayer } from '../../services/player';
import SaveButton from '../buttons/save';
import { MultiPlayersInput } from '../forms/player_create_csv_input';
function MultiPlayerTab({
tournament_id,
swrPlayersResponse,
setOpened,
}: {
tournament_id: number;
swrPlayersResponse: SWRResponse;
setOpened: any;
}) {
const form = useForm({
initialValues: {
names: '',
active: true,
},
validate: {
names: (value) => (value.length > 0 ? null : 'Enter at least one player'),
},
});
return (
<form
onSubmit={form.onSubmit(async (values) => {
await createMultiplePlayers(tournament_id, values.names, values.active);
await swrPlayersResponse.mutate(null);
setOpened(false);
})}
>
<MultiPlayersInput form={form} />
<Checkbox
mt="md"
label="These players are active"
{...form.getInputProps('active', { type: 'checkbox' })}
/>
<Button fullWidth style={{ marginTop: 10 }} color="green" type="submit">
Save players
</Button>
</form>
);
}
function SinglePlayerTab({
tournament_id,
swrPlayersResponse,
setOpened,
}: {
tournament_id: number;
swrPlayersResponse: SWRResponse;
setOpened: any;
}) {
const form = useForm({
initialValues: {
name: '',
active: true,
player_ids: [],
},
validate: {
name: (value) => (value.length > 0 ? null : 'Name too short'),
},
});
return (
<form
onSubmit={form.onSubmit(async (values) => {
await createPlayer(tournament_id, values.name, values.active);
await swrPlayersResponse.mutate(null);
setOpened(false);
})}
>
<TextInput
withAsterisk
label="Name"
placeholder="Best Player Ever"
{...form.getInputProps('name')}
/>
<Checkbox
mt="md"
label="This player is active"
{...form.getInputProps('active', { type: 'checkbox' })}
/>
<Button fullWidth style={{ marginTop: 10 }} color="green" type="submit">
Save player
</Button>
</form>
);
}
export default function PlayerCreateModal({
tournament_id,
swrPlayersResponse,
}: {
tournament_id: number;
swrPlayersResponse: SWRResponse;
}) {
const [opened, setOpened] = useState(false);
return (
<>
<Modal opened={opened} onClose={() => setOpened(false)} title="Create Player">
<Tabs defaultValue="single">
<Tabs.List position="center" grow>
<Tabs.Tab value="single" icon={<IconUser size="0.8rem" />}>
Single player
</Tabs.Tab>
<Tabs.Tab value="multi" icon={<IconUsers size="0.8rem" />}>
Multiple players
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="single" pt="xs">
<SinglePlayerTab
swrPlayersResponse={swrPlayersResponse}
tournament_id={tournament_id}
setOpened={setOpened}
/>
</Tabs.Panel>
<Tabs.Panel value="multi" pt="xs">
<MultiPlayerTab
swrPlayersResponse={swrPlayersResponse}
tournament_id={tournament_id}
setOpened={setOpened}
/>
</Tabs.Panel>
</Tabs>
</Modal>
<Group position="right">
<SaveButton
onClick={() => setOpened(true)}
leftIcon={<IconUserPlus size={24} />}
title="Add Player"
mt="1.5rem"
/>
</Group>
</>
);
}

View File

@@ -1,44 +1,31 @@
import { Button, Checkbox, Group, Modal, TextInput } from '@mantine/core';
import { Button, Checkbox, Modal, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { BiEditAlt } from '@react-icons/all-files/bi/BiEditAlt';
import { GoPlus } from '@react-icons/all-files/go/GoPlus';
import { useState } from 'react';
import { SWRResponse } from 'swr';
import { Player } from '../../interfaces/player';
import { createPlayer, updatePlayer } from '../../services/player';
import SaveButton from '../buttons/save';
import { updatePlayer } from '../../services/player';
export default function PlayerModal({
export default function PlayerUpdateModal({
tournament_id,
player,
swrPlayersResponse,
}: {
tournament_id: number;
player: Player | null;
player: Player;
swrPlayersResponse: SWRResponse;
}) {
const is_create_form = player == null;
const operation_text = is_create_form ? 'Create Player' : 'Edit Player';
const icon = is_create_form ? <GoPlus size={20} /> : <BiEditAlt size={20} />;
const [opened, setOpened] = useState(false);
const modalOpenButton = is_create_form ? (
<Group position="right">
<SaveButton
onClick={() => setOpened(true)}
leftIcon={<GoPlus size={24} />}
title={operation_text}
/>
</Group>
) : (
const modalOpenButton = (
<Button
color="green"
size="xs"
style={{ marginRight: 10 }}
onClick={() => setOpened(true)}
leftIcon={icon}
leftIcon={<BiEditAlt size={20} />}
>
{operation_text}
Edit Player
</Button>
);
@@ -47,7 +34,6 @@ export default function PlayerModal({
name: player == null ? '' : player.name,
active: player == null ? true : player.active,
},
validate: {
name: (value) => (value.length > 0 ? null : 'Name too short'),
},
@@ -55,13 +41,12 @@ export default function PlayerModal({
return (
<>
<Modal opened={opened} onClose={() => setOpened(false)} title={operation_text}>
<Modal opened={opened} onClose={() => setOpened(false)} title="Edit Player">
<form
onSubmit={form.onSubmit(async (values) => {
if (is_create_form) await createPlayer(tournament_id, values.name, values.active, null);
else await updatePlayer(tournament_id, player.id, values.name, values.active, null);
await updatePlayer(tournament_id, player.id, values.name, values.active, null);
await swrPlayersResponse.mutate(null);
// setOpened(false);
setOpened(false);
})}
>
<TextInput

View File

@@ -84,7 +84,7 @@ export function Spotlight() {
actions={allActions}
searchIcon={<IconSearch size="1.2rem" />}
searchPlaceholder="Search..."
shortcut="mod + k"
shortcut={['mod + k', 'mod + y', '/']}
nothingFoundMessage="Nothing found..."
/>
);

View File

@@ -0,0 +1,164 @@
import { Button, Checkbox, Group, Modal, MultiSelect, Tabs, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconUser, IconUsers, IconUsersPlus } from '@tabler/icons-react';
import { useState } from 'react';
import { SWRResponse } from 'swr';
import { Player } from '../../interfaces/player';
import { getPlayers } from '../../services/adapter';
import { createTeam, createTeams } from '../../services/team';
import SaveButton from '../buttons/save';
import { MultiTeamsInput } from '../forms/player_create_csv_input';
function MultiTeamTab({
tournament_id,
swrTeamsResponse,
setOpened,
}: {
tournament_id: number;
swrTeamsResponse: SWRResponse;
setOpened: any;
}) {
const form = useForm({
initialValues: {
names: '',
active: true,
},
validate: {
names: (value) => (value.length > 0 ? null : 'Enter at least one team'),
},
});
return (
<form
onSubmit={form.onSubmit(async (values) => {
await createTeams(tournament_id, values.names, values.active);
await swrTeamsResponse.mutate(null);
setOpened(false);
})}
>
<MultiTeamsInput form={form} />
<Checkbox
mt="md"
label="These teams are active"
{...form.getInputProps('active', { type: 'checkbox' })}
/>
<Button fullWidth style={{ marginTop: 10 }} color="green" type="submit">
Save
</Button>
</form>
);
}
function SingleTeamTab({
tournament_id,
swrTeamsResponse,
setOpened,
}: {
tournament_id: number;
swrTeamsResponse: SWRResponse;
setOpened: any;
}) {
const { data } = getPlayers(tournament_id, false);
const players: Player[] = data != null ? data.data : [];
const form = useForm({
initialValues: {
name: '',
active: true,
player_ids: [],
},
validate: {
name: (value) => (value.length > 0 ? null : 'Name too short'),
},
});
return (
<form
onSubmit={form.onSubmit(async (values) => {
await createTeam(tournament_id, values.name, values.active, values.player_ids);
await swrTeamsResponse.mutate(null);
setOpened(false);
})}
>
<TextInput
withAsterisk
label="Name"
placeholder="Best Team Ever"
{...form.getInputProps('name')}
/>
<Checkbox
mt="md"
label="This team is active"
{...form.getInputProps('active', { type: 'checkbox' })}
/>
<MultiSelect
data={players.map((p) => ({ value: `${p.id}`, label: p.name }))}
label="Team members"
placeholder="Pick all that you like"
dropdownPosition="bottom"
maxDropdownHeight={160}
searchable
mb="12rem"
mt={12}
limit={25}
{...form.getInputProps('player_ids')}
/>
<Button fullWidth style={{ marginTop: 10 }} color="green" type="submit">
Save
</Button>
</form>
);
}
export default function TeamCreateModal({
tournament_id,
swrTeamsResponse,
}: {
tournament_id: number;
swrTeamsResponse: SWRResponse;
}) {
const [opened, setOpened] = useState(false);
return (
<>
<Modal opened={opened} onClose={() => setOpened(false)} title="Create Team">
<Tabs defaultValue="single">
<Tabs.List position="center" grow>
<Tabs.Tab value="single" icon={<IconUser size="0.8rem" />}>
Single team
</Tabs.Tab>
<Tabs.Tab value="multi" icon={<IconUsers size="0.8rem" />}>
Multiple teams
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="single" pt="xs">
<SingleTeamTab
swrTeamsResponse={swrTeamsResponse}
tournament_id={tournament_id}
setOpened={setOpened}
/>
</Tabs.Panel>
<Tabs.Panel value="multi" pt="xs">
<MultiTeamTab
swrTeamsResponse={swrTeamsResponse}
tournament_id={tournament_id}
setOpened={setOpened}
/>
</Tabs.Panel>
</Tabs>
</Modal>
<Group position="right">
<SaveButton
onClick={() => setOpened(true)}
leftIcon={<IconUsersPlus size={24} />}
title="Add Team"
mt="1.5rem"
/>
</Group>
</>
);
}

View File

@@ -1,58 +1,32 @@
import { Button, Checkbox, Group, Modal, MultiSelect, TextInput } from '@mantine/core';
import { Button, Checkbox, Modal, MultiSelect, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { BiEditAlt } from '@react-icons/all-files/bi/BiEditAlt';
import { GoPlus } from '@react-icons/all-files/go/GoPlus';
import { useState } from 'react';
import { SWRResponse } from 'swr';
import { Player } from '../../interfaces/player';
import { TeamInterface } from '../../interfaces/team';
import { getPlayers } from '../../services/adapter';
import { createTeam, updateTeam } from '../../services/team';
import SaveButton from '../buttons/save';
import { updateTeam } from '../../services/team';
export default function TeamModal({
export default function TeamUpdateModal({
tournament_id,
team,
swrTeamsResponse,
}: {
tournament_id: number;
team: TeamInterface | null;
team: TeamInterface;
swrTeamsResponse: SWRResponse;
}) {
const { data } = getPlayers(tournament_id, false);
const players: Player[] = data != null ? data.data : [];
const is_create_form = team == null;
const operation_text = is_create_form ? 'Create Team' : 'Edit Team';
const icon = is_create_form ? <GoPlus size={20} /> : <BiEditAlt size={20} />;
const [opened, setOpened] = useState(false);
const modalOpenButton = is_create_form ? (
<Group position="right">
<SaveButton
onClick={() => setOpened(true)}
leftIcon={<GoPlus size={24} />}
title={operation_text}
style={{ marginTop: '1rem' }}
/>
</Group>
) : (
<Button
color="green"
size="xs"
style={{ marginRight: 10 }}
onClick={() => setOpened(true)}
leftIcon={icon}
>
{operation_text}
</Button>
);
const form = useForm({
initialValues: {
name: team == null ? '' : team.name,
active: team == null ? true : team.active,
player_ids: team == null ? [] : team.players.map((player) => `${player.id}`),
name: team.name,
active: team.active,
player_ids: team.players.map((player) => `${player.id}`),
},
validate: {
@@ -62,21 +36,12 @@ export default function TeamModal({
return (
<>
<Modal opened={opened} onClose={() => setOpened(false)} title={operation_text}>
<Modal opened={opened} onClose={() => setOpened(false)} title="Edit Team">
<form
onSubmit={form.onSubmit(async (values) => {
if (is_create_form) {
await createTeam(tournament_id, values.name, values.active, values.player_ids);
} else {
await updateTeam(
tournament_id,
team.id,
values.name,
values.active,
values.player_ids
);
}
swrTeamsResponse.mutate(null);
await updateTeam(tournament_id, team.id, values.name, values.active, values.player_ids);
await swrTeamsResponse.mutate(null);
setOpened(false);
})}
>
@@ -112,7 +77,15 @@ export default function TeamModal({
</form>
</Modal>
{modalOpenButton}
<Button
color="green"
size="xs"
style={{ marginRight: 10 }}
onClick={() => setOpened(true)}
leftIcon={<BiEditAlt size={20} />}
>
Edit Team
</Button>
</>
);
}

View File

@@ -15,7 +15,7 @@ import {
getLossColor,
getWinColor,
} from '../info/player_statistics';
import PlayerModal from '../modals/player_modal';
import PlayerUpdateModal from '../modals/player_update_modal';
import { DateTime } from '../utils/datetime';
import { EmptyTableInfo } from '../utils/empty_table_info';
import RequestErrorAlert from '../utils/error_alert';
@@ -94,7 +94,7 @@ export default function PlayersTable({
/>
</td>
<td>
<PlayerModal
<PlayerUpdateModal
swrPlayersResponse={swrPlayersResponse}
tournament_id={tournamentData.id}
player={player}

View File

@@ -7,7 +7,7 @@ import { TournamentMinimal } from '../../interfaces/tournament';
import { deleteTeam } from '../../services/team';
import DeleteButton from '../buttons/delete';
import PlayerList from '../info/player_list';
import TeamModal from '../modals/team_modal';
import TeamUpdateModal from '../modals/team_update_modal';
import { DateTime } from '../utils/datetime';
import { EmptyTableInfo } from '../utils/empty_table_info';
import RequestErrorAlert from '../utils/error_alert';
@@ -43,7 +43,7 @@ export default function TeamsTable({
<td>{team.swiss_score.toFixed(1)}</td>
<td>{team.elo_score.toFixed(0)}</td>
<td>
<TeamModal
<TeamUpdateModal
tournament_id={tournamentData.id}
team={team}
swrTeamsResponse={swrTeamsResponse}

View File

@@ -1,6 +1,6 @@
import { Grid, Title } from '@mantine/core';
import PlayerModal from '../../../components/modals/player_modal';
import PlayerCreateModal from '../../../components/modals/player_create_modal';
import PlayersTable from '../../../components/tables/players';
import { getTournamentIdFromRouter } from '../../../components/utils/util';
import { getPlayers } from '../../../services/adapter';
@@ -16,10 +16,9 @@ export default function Players() {
<Title>Players</Title>
</Grid.Col>
<Grid.Col span={3}>
<PlayerModal
<PlayerCreateModal
swrPlayersResponse={swrPlayersResponse}
tournament_id={tournamentData.id}
player={null}
/>
</Grid.Col>
</Grid>

View File

@@ -2,7 +2,7 @@ import { Grid, Group, Select, Title } from '@mantine/core';
import React, { useState } from 'react';
import { SWRResponse } from 'swr';
import TeamModal from '../../../components/modals/team_modal';
import TeamCreateModal from '../../../components/modals/team_create_modal';
import TeamsTable from '../../../components/tables/teams';
import { getTournamentIdFromRouter, responseIsValid } from '../../../components/utils/util';
import { StageItemWithRounds } from '../../../interfaces/stage_item';
@@ -75,10 +75,9 @@ export default function Teams() {
groupStageItems={groupStageItems}
setFilteredStageItemId={setFilteredStageItemId}
/>
<TeamModal
<TeamCreateModal
swrTeamsResponse={swrTeamsResponse}
tournament_id={tournamentData.id}
team={null}
/>
</Group>
</Grid.Col>

View File

@@ -1,17 +1,14 @@
import { createAxios, handleRequestError } from './adapter';
export async function createPlayer(
tournament_id: number,
name: string,
active: boolean,
team_id: string | null
) {
export async function createPlayer(tournament_id: number, name: string, active: boolean) {
return createAxios()
.post(`tournaments/${tournament_id}/players`, {
name,
active,
team_id,
})
.post(`tournaments/${tournament_id}/players`, { name, active })
.catch((response: any) => handleRequestError(response));
}
export async function createMultiplePlayers(tournament_id: number, names: string, active: boolean) {
return createAxios()
.post(`tournaments/${tournament_id}/players_multi`, { names, active })
.catch((response: any) => handleRequestError(response));
}

View File

@@ -13,6 +13,10 @@ export async function createTeam(
});
}
export async function createTeams(tournament_id: number, names: string, active: boolean) {
return createAxios().post(`tournaments/${tournament_id}/teams_multi`, { names, active });
}
export async function deleteTeam(tournament_id: number, team_id: number) {
await createAxios()
.delete(`tournaments/${tournament_id}/teams/${team_id}`)