--- /dev/null
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\GroupAssignment;
+use App\Models\Protocol;
+use Illuminate\Http\Request;
+
+class GroupAssignmentController extends Controller {
+
+ public function changeAssignment(Request $request, GroupAssignment $assignment) {
+ $validatedData = $request->validate([
+ 'group' => 'string|required|in:A,B,C,D,E,F,G,H',
+ ]);
+ $this->authorize('changeAssignment', $assignment);
+
+ $assignment->group = $validatedData['group'];
+ $assignment->save();
+
+ Protocol::groupSwap(
+ $assignment->tournament,
+ $assignment->user,
+ ['number' => $assignment->round_number, 'group' => $assignment->group],
+ $request->user(),
+ );
+
+ return $assignment->toArray();
+ }
+
+}
use App\Events\RoundAdded;
use App\Events\RoundChanged;
+use App\Models\GroupAssignment;
use App\Models\Protocol;
use App\Models\Round;
use App\Models\Tournament;
return redirect($round->seed);
}
+ public function groups(Round $round) {
+ $this->authorize('seeGroups', $round);
+
+ $assignments = GroupAssignment::query()
+ ->where('tournament_id', '=', $round->tournament_id)
+ ->where('round_number', '=', $round->number)
+ ->with('user')
+ ->get()
+ ->toArray();
+ $groups = Round::query()
+ ->where('tournament_id', '=', $round->tournament_id)
+ ->where('number', '=', $round->number)
+ ->get()
+ ->pluck('group')
+ ->toArray();
+
+ return [
+ 'groups' => $groups,
+ 'assignments' => $assignments,
+ ];
+ }
+
public function uploadSeed(Request $request, Round $round) {
$this->authorize('update', $round);
$this->authorize('update', $tournament);
$validatedData = $request->validate([
'group_size' => 'integer|nullable',
+ 'group_swap_style' => 'string|nullable|in:admin,always,finished,never',
'result_reveal' => 'string|nullable|in:always,finishers,never,participants',
'show_numbers' => 'boolean|nullable',
]);
if (array_key_exists('group_size', $validatedData)) {
$tournament->group_size = $validatedData['group_size'];
}
+ if (array_key_exists('group_swap_style', $validatedData)) {
+ $tournament->group_swap_style = $validatedData['group_swap_style'];
+ }
if (isset($validatedData['result_reveal'])) {
$tournament->result_reveal = $validatedData['result_reveal'];
}
namespace App\Models;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Database\Eloquent\BroadcastsEvents;
use Illuminate\Database\Eloquent\Model;
class GroupAssignment extends Model {
+ use BroadcastsEvents;
+
+ public function broadcastOn($event) {
+ $channels = [
+ new PrivateChannel('Tournament.'.$this->tournament_id.'.'.$this->user_id),
+ ];
+ return $channels;
+ }
+
+ public function broadcastWith($event) {
+ $this->load(['user']);
+ }
+
+
public function tournament() {
return $this->belongsTo(Tournament::class);
}
--- /dev/null
+<?php
+
+namespace App\Policies;
+
+use App\Models\GroupAssignment;
+use App\Models\User;
+use Illuminate\Auth\Access\HandlesAuthorization;
+
+class GroupAssignmentPolicy {
+
+ use HandlesAuthorization;
+
+ /**
+ * Determine whether the user can change this assignment.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\GroupAssignment $assignment
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function changeAssignment(User $user, GroupAssignment $assignment): bool {
+ return $user->isTournamentAdmin($assignment->tournament);
+ }
+
+}
return !!$user;
}
+ /**
+ * Determine whether the user can see al group assignments for this round.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\Round $round
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function seeGroups(User $user = null, Round $round): bool
+ {
+ return $user->isTournamentCrew($round->tournament);
+ }
+
/**
* Determine whether the user can lock this round.
*
*/
public function swapGroup(User $user, Round $round)
{
+ if ($round->locked || $round->tournament->locked) {
+ return false;
+ }
+ if (in_array($round->tournament->group_swap_style, ['admin', 'never'])) {
+ return false;
+ }
$result = $user->findResult($round);
- if (!$result || $round->locked || $round->tournament->locked) {
+ if ($round->tournament->group_swap_style == 'finished' && !$result) {
return false;
}
$remaining = $round->tournament->rounds()
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::table('tournaments', function (Blueprint $table) {
+ $table->string('group_swap_style')->default('finished');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('tournaments', function (Blueprint $table) {
+ $table->dropColumn('group_swap_style');
+ });
+ }
+
+};
Icon.FIRST_PLACE = makePreset('FirstPlaceIcon', 'trophy');
Icon.FORBIDDEN = makePreset('ForbiddenIcon', 'square-xmark');
Icon.FORFEIT = makePreset('ForfeitIcon', 'square-minus');
+Icon.GROUPS = makePreset('GroupsIcon', 'group-arrows-rotate');
Icon.HASH = makePreset('HashIcon', 'hashtag');
Icon.INFO = makePreset('Info', 'circle-info');
Icon.INVERT = makePreset('InvertIcon', 'circle-half-stroke');
--- /dev/null
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import GroupsDialog from './GroupsDialog';
+import Icon from '../common/Icon';
+
+const GroupsButton = ({
+ round,
+ tournament,
+}) => {
+ const [showDialog, setShowDialog] = useState(false);
+
+ const { t } = useTranslation();
+
+ return <>
+ <GroupsDialog
+ onHide={() => setShowDialog(false)}
+ round={round}
+ show={showDialog}
+ tournament={tournament}
+ />
+ <Button
+ onClick={() => setShowDialog(true)}
+ size="sm"
+ title={t('rounds.groups')}
+ variant="outline-secondary"
+ >
+ <Icon.GROUPS title="" />
+ </Button>
+ </>;
+};
+
+GroupsButton.propTypes = {
+ round: PropTypes.shape({
+ locked: PropTypes.bool,
+ }),
+ tournament: PropTypes.shape({
+ }),
+};
+
+export default GroupsButton;
--- /dev/null
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form, Modal, Table } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import Loading from '../common/Loading';
+import UserBox from '../users/Box';
+import { mayModifyGroups } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
+
+const GroupsDialog = ({
+ onHide,
+ round,
+ show,
+ tournament,
+}) => {
+ const [groups, setGroups] = React.useState([]);
+ const [loading, setLoading] = React.useState(false);
+
+ const { t } = useTranslation();
+ const { user } = useUser();
+
+ const mayModify = React.useMemo(() => mayModifyGroups(user, tournament, round), [round, tournament, user]);
+
+ React.useEffect(() => {
+ if (!show || !round?.id) return;
+ setLoading(true);
+ const ctrl = new AbortController();
+ axios
+ .get(`/api/rounds/${round.id}/groups`, { signal: ctrl.signal })
+ .then(response => {
+ setGroups(response.data);
+ setLoading(false);
+ })
+ .catch((e) => {
+ console.error(e);
+ setLoading(false);
+ });
+ return () => {
+ ctrl.abort();
+ };
+ }, [round?.id, show]);
+
+ const changeAssignment = React.useCallback(async (asgn_id, group) => {
+ try {
+ const response = await axios.post(`/api/group-assignments/${asgn_id}/change`, { group });
+ setGroups((oldGroups) => ({
+ assignments: oldGroups.assignments.map((asgn) => {
+ if (asgn.id === asgn_id) {
+ return response.data;
+ }
+ return asgn;
+ }),
+ groups: oldGroups.groups,
+ }));
+ toastr.success(t('groups.changeSuccess'));
+ } catch (e) {
+ console.error(e);
+ toastr.error(t('groups.changeError'));
+ }
+ }, [t]);
+
+ return <Modal className="groups-dialog" onHide={onHide} show={show}>
+ <Modal.Header closeButton>
+ <Modal.Title>
+ {t('rounds.groups')}
+ </Modal.Title>
+ </Modal.Header>
+ {loading ?
+ <Loading />
+ :
+ <Table>
+ <thead>
+ <tr>
+ <th className="ps-3">{t('results.runner')}</th>
+ <th>{t('groups.group')}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {(groups?.assignments || []).map((asgn) => (
+ <tr key={asgn.id}>
+ <td>
+ <UserBox user={asgn.user} />
+ </td>
+ <td>
+ {mayModify ?
+ <Form.Select
+ onChange={({ target: { value } }) =>
+ changeAssignment(asgn.id, value)}
+ value={asgn.group}
+ >
+ {groups.groups.map((group) => (
+ <option key={group} value={group}>{group}</option>
+ ))}
+ </Form.Select>
+ :
+ asgn.group
+ }
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </Table>
+ }
+ <Modal.Footer>
+ <Button onClick={onHide} variant="secondary">
+ {t('button.close')}
+ </Button>
+ </Modal.Footer>
+ </Modal>;
+};
+
+GroupsDialog.propTypes = {
+ onHide: PropTypes.func,
+ round: PropTypes.shape({
+ id: PropTypes.number,
+ }),
+ show: PropTypes.bool,
+ tournament: PropTypes.shape({
+ }),
+};
+
+export default GroupsDialog;
import DeleteButton from './DeleteButton';
import EditButton from './EditButton';
+import GroupsButton from './GroupsButton';
import LockButton from './LockButton';
import SeedButton from './SeedButton';
import SeedCode from './SeedCode';
import {
mayDeleteRound,
mayEditRound,
+ maySeeGroups,
mayReportResult,
mayVerifyResults,
mayViewProtocol,
{mayEditRound(user, tournament, round) ?
<EditButton round={round} tournament={tournament} />
: null}
+ {maySeeGroups(user, tournament, round) ?
+ <GroupsButton round={round} tournament={tournament} />
+ : null}
{mayViewProtocol(user, tournament, round) ?
<RoundProtocol roundId={round.id} tournamentId={tournament.id} />
: null}
value={tournament.show_numbers}
/>
</div>
- {Tournament.hasAssignedGroups(tournament) ? (
+ {Tournament.hasAssignedGroups(tournament) ? <>
<div className="d-flex align-items-center justify-content-between mb-3">
<span>{i18n.t('tournaments.groupSize')}</span>
<Form.Control
value={tournament.group_size}
/>
</div>
- ) : null}
+ <div className="d-flex align-items-center justify-content-between mb-3">
+ <span>{i18n.t('tournaments.groupSwapStyle')}</span>
+ <Form.Select
+ className="w-50"
+ onChange={({ target: { value } }) =>
+ settings(tournament, { group_swap_style: value })}
+ value={tournament.group_swap_style}
+ >
+ {['always', 'finished', 'admin', 'never'].map((key) =>
+ <option
+ key={key}
+ title={i18n.t(`tournaments.groupSwapStyleDescription.${key}`)}
+ value={key}
+ >
+ {i18n.t(`tournaments.groupSwapStyles.${key}`)}
+ </option>
+ )}
+ </Form.Select>
+ </div>
+ </> : null}
<div className="d-flex align-items-center justify-content-between mb-3">
<span title={i18n.t('tournaments.resultRevealDescription')}>
{i18n.t('tournaments.resultReveal')}
</span>
<Form.Select
+ className="w-50"
onChange={({ target: { value } }) =>
settings(tournament, { result_reveal: value })}
- style={{ width: '50%' }}
value={tournament.result_reveal}
>
{['never', 'finishers', 'participants', 'always'].map((key) =>
tournament: PropTypes.shape({
accept_applications: PropTypes.bool,
discord: PropTypes.string,
- locked: PropTypes.bool,
group_size: PropTypes.number,
+ group_swap_style: PropTypes.string,
+ locked: PropTypes.bool,
result_reveal: PropTypes.string,
show_numbers: PropTypes.bool,
}),
};
export const maySwapGroup = (user, tournament, round, result) => {
- if (!user || !result || tournament?.group_size <= 1 || tournament.locked || !tournament?.rounds || !round || round.locked) {
+ if (!user || tournament?.group_size <= 1 || tournament.locked || !tournament.rounds || !round || round.locked) {
+ return false;
+ }
+ const style = tournament.group_swap_style || 'finished';
+ if (['admin', 'never'].includes(style)) {
+ return false;
+ }
+ if (style === 'finished' && !result) {
return false;
}
const remaining_rounds = tournament.rounds.filter(
return remaining_rounds.length > 0;
};
-export const mayModifyResults = (user, tournament, round) => {
+export const maySeeGroups = (user, tournament, round) => {
return isTournamentCrew(user, tournament);
};
+export const mayModifyGroups = (user, tournament, round) => {
+ return tournament?.group_swap_style !== 'never' && !round?.locked && isTournamentAdmin(user, tournament);
+};
+
+export const mayModifyResults = (user, tournament, round) => {
+ return !round?.locked && isTournamentCrew(user, tournament);
+};
+
export const mayModifyResult = (user, tournament, round, result) => {
return mayModifyResults(user, tournament) && user && result && !result.verified_at && user.id !== result.user_id;
};
uploading: 'Am Hochladen...',
},
groups: {
+ changeError: 'Fehler beim Gruppenwechsel',
+ changeSuccess: 'Gruppe gewechselt',
complete: 'Abgeschlossen',
empty: 'Noch keine Gruppen verfügbar',
+ group: 'Gruppe',
heading: 'Gruppen',
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.',
editError: 'Fehler beim Speichern',
editSuccess: 'Gespeichert',
empty: 'Noch keine Runde gestartet',
+ groups: 'Gruppenzuweisung',
heading: 'Runden',
new: 'Neue Runde',
noSeed: 'Noch kein Seed',
discordSettingsSuccess: 'Discord Einstellungen gespeichert',
discordSuccess: 'Discord verknüpft',
groupSize: 'Seeds pro Runde',
+ groupSwapStyle: 'Seed-Wechsel',
+ groupSwapStyles: {
+ admin: 'Durch Admins',
+ always: 'Immer',
+ finished: 'Wenn beendet',
+ never: 'Nie',
+ },
inviteBot: 'Bot einladen',
locked: 'Turnier sperren',
lockError: 'Fehler beim Sperren',
uploading: 'Uploading...',
},
groups: {
+ changeError: 'Error changing groups',
+ changeSuccess: 'Group assignment changed',
complete: 'Complete',
empty: 'No groups available yet',
+ group: 'Group',
heading: '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.',
editError: 'Error saving round',
editSuccess: 'Saved successfully',
empty: 'No rounds yet',
+ groups: 'Group assignments',
heading: 'Rounds',
new: 'New round',
noSeed: 'No seed set',
discordSettingsSuccess: 'Discord settings saved',
discordSuccess: 'Discord associated',
groupSize: 'Seeds per Round',
+ groupSwapStyle: 'Seed swapping',
+ groupSwapStyles: {
+ admin: 'By admins',
+ always: 'Always',
+ finished: 'After finishing',
+ never: 'Never',
+ },
inviteBot: 'Invite bot',
locked: 'Lock rounds',
lockError: 'Error locking tournament',
};
}, []);
+ useEffect(() => {
+ if (!tournament?.id || !user?.id) return;
+ window.Echo.private(`Tournament.${tournament.id}.${user.id}`)
+ .listen('.GroupAssignmentUpdated', ({ model }) => {
+ console.log(model);
+ setTournament((t) => ({
+ ...t,
+ group_assignments: t.group_assignments.map((asgn) => {
+ if (asgn.id === model.id) {
+ return model;
+ }
+ return asgn;
+ }),
+ }));
+ });
+ return () => {
+ window.Echo.leave(`Tournament.${tournament.id}.${user.id}`);
+ };
+ }, [tournament?.id, user?.id]);
+
if (loading) {
return <Loading />;
}
Route::get('events/{event:name}', 'App\Http\Controllers\EventController@single');
Route::post('events/{event}/add-episode', 'App\Http\Controllers\EpisodeController@create');
+Route::post('group-assignments/{assignment}/change', 'App\Http\Controllers\GroupAssignmentController@changeAssignment');
+
Route::get('markers/{map}', 'App\Http\Controllers\TechniqueController@forMap');
Route::get('pages/{type}', 'App\Http\Controllers\TechniqueController@byType');
Route::post('rounds', 'App\Http\Controllers\RoundController@create');
Route::put('rounds/{round}', 'App\Http\Controllers\RoundController@update');
Route::delete('rounds/{round}', 'App\Http\Controllers\RoundController@delete');
+Route::get('rounds/{round}/groups', 'App\Http\Controllers\RoundController@groups');
Route::post('rounds/{round}/lock', 'App\Http\Controllers\RoundController@lock');
Route::post('rounds/{round}/setSeed', 'App\Http\Controllers\RoundController@setSeed');
Route::post('rounds/{round}/unlock', 'App\Http\Controllers\RoundController@unlock');
$tournament = Tournament::findOrFail($id);
return true;
});
+
+Broadcast::channel('Tournament.{id}.{user_id}', function ($user, $id, $user_id) {
+ $tournament = Tournament::findOrFail($id);
+ return $user->id == $user_id;
+});