From 08c23423e200376a225d889def47590d1963f6ce Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Thu, 20 Nov 2025 16:23:15 +0100 Subject: [PATCH] group assignment self sevice (untested) --- app/Http/Controllers/TournamentController.php | 42 +++++++++++++++++++ app/Models/Protocol.php | 14 +++++++ app/Models/Tournament.php | 24 +++++++++++ app/Policies/TournamentPolicy.php | 12 ++++++ resources/js/components/tournament/Detail.jsx | 9 ++++ .../components/tournament/GroupInterface.jsx | 37 ++++++++++++++++ resources/js/helpers/Tournament.js | 18 ++++++++ resources/js/i18n/de.js | 7 ++++ resources/js/i18n/en.js | 7 ++++ resources/js/pages/Tournament.jsx | 16 ++++++- routes/api.php | 1 + 11 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 resources/js/components/tournament/GroupInterface.jsx diff --git a/app/Http/Controllers/TournamentController.php b/app/Http/Controllers/TournamentController.php index b29cb2e..2f88af7 100644 --- a/app/Http/Controllers/TournamentController.php +++ b/app/Http/Controllers/TournamentController.php @@ -167,6 +167,48 @@ class TournamentController extends Controller return $tournament->toJson(); } + public function selfAssignGroups(Request $request, Tournament $tournament) { + $this->authorize('selfAssignGroups', $tournament); + $user = $request->user(); + + $existing = GroupAssignment::query() + ->whereBelongsTo($tournament) + ->whereBelongsTo($user) + ->get() + ->pluck('round_number'); + + $round_numbers = $tournament->rounds->pluck('number')->unique(); + + $picks = []; + foreach ($round_numbers as $number) { + if (!$existing->contains($number)) { + $group = $tournament->pickGroup($number, $user); + $picks[] = [ + 'number' => $number, + 'group' => $group, + ]; + } + } + + if (!empty($picks)) { + foreach ($picks as $pick) { + GroupAssignment::create([ + 'tournament_id' => $tournament->id, + 'user_id' => $user->id, + 'round_number' => $pick['number'], + 'group' => $pick['group'], + ]); + Protocol::groupAssignment($tournament, $user, $picks, $user); + } + } + + return GroupAssignment::query() + ->whereBelongsTo($tournament) + ->whereBelongsTo($$user) + ->get() + ->toJson(); + } + public function web(Request $request, Tournament $tournament) { $view = view('app') ->with('title', $tournament->getTranslatedTitle()) diff --git a/app/Models/Protocol.php b/app/Models/Protocol.php index 4eddf71..5c317a3 100644 --- a/app/Models/Protocol.php +++ b/app/Models/Protocol.php @@ -52,6 +52,20 @@ class Protocol extends Model ProtocolAdded::dispatch($protocol); } + public static function groupAssignment(Tournament $tournament, User $assignee, $picks, User $user) { + $protocol = static::create([ + 'tournament_id' => $tournament->id, + 'user_id' => $user->id, + 'type' => 'group.assign', + 'details' => [ + 'tournament' => static::tournamentMemo($tournament), + 'assignee' => static::userMemo($assignee), + 'picks' => $picks, + ], + ]); + ProtocolAdded::dispatch($protocol); + } + public static function resultCommented(Tournament $tournament, Result $result, User $user) { $protocol = static::create([ 'tournament_id' => $tournament->id, diff --git a/app/Models/Tournament.php b/app/Models/Tournament.php index c2697a0..a317c5c 100644 --- a/app/Models/Tournament.php +++ b/app/Models/Tournament.php @@ -69,6 +69,26 @@ class Tournament extends Model { } } + public function pickGroup($number, User $user) { + $available_rounds = $this->round()->where('number', '=', $number)->get(); + $assigned_groups = $this->group_assignments()->where('round_number', '=', $number)->get(); + $weights = array(); + foreach ($available_rounds as $round) { + $weights[$round->group] = $assigned_groups->count() + 1; + } + foreach ($assigned_groups as $assignment) { + --$weights[$assignment->group]; + } + $rand = random_int(1, array_sum($weights)); + foreach ($weights as $group => $weight) { + $rand -= $weight; + if ($rand <= 0) { + return $group; + } + } + return 'A'; + } + public function applications() { return $this->hasMany(Application::class); @@ -78,6 +98,10 @@ class Tournament extends Model { return $this->belongsTo(Technique::class); } + public function group_assignments() { + return $this->hasMany(GroupAssignment::class); + } + public function participants() { return $this->hasMany(Participant::class); } diff --git a/app/Policies/TournamentPolicy.php b/app/Policies/TournamentPolicy.php index f58eee7..f8e448b 100644 --- a/app/Policies/TournamentPolicy.php +++ b/app/Policies/TournamentPolicy.php @@ -128,4 +128,16 @@ class TournamentPolicy return $user->isTournamentCrew($tournament); } + /** + * Determine whether the user self assign groups within the tournament. + * + * @param \App\Models\User $user + * @param \App\Models\Tournament $tournament + * @return \Illuminate\Auth\Access\Response|bool + */ + public function selfAssignGroups(User $user, Tournament $tournament) + { + return !!$user; + } + } diff --git a/resources/js/components/tournament/Detail.jsx b/resources/js/components/tournament/Detail.jsx index a931865..241c2f0 100644 --- a/resources/js/components/tournament/Detail.jsx +++ b/resources/js/components/tournament/Detail.jsx @@ -4,6 +4,7 @@ import { Button, Col, Container, Row } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import ApplyButton from './ApplyButton'; +import GroupInterface from './GroupInterface'; import Scoreboard from './Scoreboard'; import ScoreChartButton from './ScoreChartButton'; import SettingsButton from './SettingsButton'; @@ -23,6 +24,7 @@ import { getTranslation } from '../../helpers/Technique'; import { getTournamentAdmins, getTournamentMonitors, + hasAssignedGroups, hasRunners, hasScoreboard, hasTournamentAdmins, @@ -125,6 +127,12 @@ const Detail = ({ + {hasAssignedGroups(tournament) ? + + : null}

{t('rounds.heading')}

{actions.addRound && mayAddRounds(user, tournament) ? @@ -150,6 +158,7 @@ Detail.propTypes = { addRound: PropTypes.func, editContent: PropTypes.func, moreRounds: PropTypes.func, + selfAssignGroups: PropTypes.func, }).isRequired, tournament: PropTypes.shape({ description: PropTypes.shape({ diff --git a/resources/js/components/tournament/GroupInterface.jsx b/resources/js/components/tournament/GroupInterface.jsx new file mode 100644 index 0000000..aed89d2 --- /dev/null +++ b/resources/js/components/tournament/GroupInterface.jsx @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import { missingGroupAssignment } from '../../helpers/Tournament'; +import { useUser } from '../../hooks/user'; + +const GroupInterface = ({ selfAssign, tournament }) => { + const { t } = useTranslation(); + const { user } = useUser(); + + if (!user) { + return

{t('groups.loginRequired')}

+ } + + if (missingGroupAssignment(tournament, user)) { + return
+

{t('groups.missingAssignments')}

+ +
+ } + + return
+ Groups here +
; +}; + +GroupInterface.propTypes = { + selfAssign: PropTypes.func, + tournament: PropTypes.shape({ + }), +}; + +export default GroupInterface; diff --git a/resources/js/helpers/Tournament.js b/resources/js/helpers/Tournament.js index 7270351..93657ff 100644 --- a/resources/js/helpers/Tournament.js +++ b/resources/js/helpers/Tournament.js @@ -40,6 +40,8 @@ export const canLoadMoreRounds = tournament => { return last_round && last_round.number > 1; }; +export const hasAssignedGroups = tournament => (tournament?.type === 'open-grouped-async'); + export const hasScoreboard = tournament => !!(tournament && tournament.type === 'signup-async'); export const hasSignup = tournament => !!(tournament && tournament.type === 'signup-async'); @@ -103,6 +105,22 @@ export const hasTournamentMonitors = tournament => { return getTournamentMonitors(tournament).length > 0; }; +const unique = (value, index, array) => array.indexOf(value) === index; + +export const missingGroupAssignment = (tournament, user) => { + if (!user) return true; + if (!tournament?.group_assignments?.length) return false; + if (!tournament.rounds?.length) return false; + const gas = tournament.group_assignments; + const rns = tournament.rounds.map(r => r.number).filter(unique); + for (let i = 0; i < rns.length; ++i) { + if (!gas.find(ga => ga.round_number === rns[i])) { + return true; + } + } + return false; +} + export const patchApplication = (tournament, application) => { if (!tournament) return tournament; if (!tournament.applications || !tournament.applications.length) { diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 461aa50..dfe73e0 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -374,6 +374,13 @@ export default { uploadError: 'Fehler beim Hochladen', uploading: 'Am Hochladen...', }, + groups: { + loginRequired: 'Dieses Turnier nutzt Gruppenzuweisung. Bitte melde dich an, um deine Seeds zu laden.', + missingAssignments: 'Dieses Turnier nutzt Gruppenzuweisung. Falls du teilnehmen möchtest, hol dir bitter hier deine Zuweisungen ab.', + selfAssignButton: 'Gruppen zuweisen', + selfAssignError: 'Fehler beim Zuweisen', + selfAssignSuccess: 'Gruppen zugewiesen', + }, icon: { AddIcon: 'Hinzufügen', AllowedIcon: 'Erlaubt', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 0cba64a..abdd40e 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -374,6 +374,13 @@ export default { uploadError: 'Error uploading', uploading: 'Uploading...', }, + groups: { + loginRequired: 'This tournament uses assigned groups. Please sign in to obtain your seeds.', + missingAssignments: 'This tournament uses assigned groups. If you want to participate, please grab your assignments here.', + selfAssignButton: 'Assign groups', + selfAssignError: 'Error assigning groups', + selfAssignSuccess: 'Groups assigned', + }, icon: { AddIcon: 'Add', AllowedIcon: 'Allowed', diff --git a/resources/js/pages/Tournament.jsx b/resources/js/pages/Tournament.jsx index b86753d..8d0a954 100644 --- a/resources/js/pages/Tournament.jsx +++ b/resources/js/pages/Tournament.jsx @@ -159,6 +159,19 @@ export const Component = () => { } }, [tournament && tournament.description_id]); + const selfAssignGroups = React.useCallback(async () => { + try { + const response = await axios.post(`/api/tournaments/${id}/self-assign-groups`); + toastr.success(t('groups.selfAssignSuccess')); + setTournament(tournament => ({ + ...tournament, + group_assignments: response.data, + })); + } catch (e) { + toastr.error(t('groups.selfAssignError', e)); + } + }, [id, t]); + const actions = React.useMemo(() => ({ addRound, editContent: mayEditContent(user) ? content => { @@ -166,7 +179,8 @@ export const Component = () => { setShowContentDialog(true); } : null, moreRounds: canLoadMoreRounds(tournament) ? moreRounds : null, - }), [addRound, moreRounds, tournament, user]); + selfAssignGroups, + }), [addRound, moreRounds, selfAssignGroups, tournament, user]); useEffect(() => { const cb = (e) => { diff --git a/routes/api.php b/routes/api.php index 86e4aac..400f678 100644 --- a/routes/api.php +++ b/routes/api.php @@ -109,6 +109,7 @@ Route::post('tournaments/{tournament}/discord-settings', 'App\Http\Controllers\T Route::post('tournaments/{tournament}/lock', 'App\Http\Controllers\TournamentController@lock'); Route::post('tournaments/{tournament}/open', 'App\Http\Controllers\TournamentController@open'); Route::post('tournaments/{tournament}/settings', 'App\Http\Controllers\TournamentController@settings'); +Route::post('tournaments/{tournament}/self-assign-groups', 'App\Http\Controllers\TournamentController@selfAssignGroups'); Route::post('tournaments/{tournament}/unlock', 'App\Http\Controllers\TournamentController@unlock'); Route::get('users', 'App\Http\Controllers\UserController@search'); -- 2.47.3