From d1f28ea443b090c7593791eba9631796ccaeafe1 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Sun, 20 Mar 2022 23:12:13 +0100 Subject: [PATCH] allow admins to lock/unlock rounds --- app/Http/Controllers/RoundController.php | 38 ++++++++++ app/Models/Protocol.php | 14 ++++ app/Policies/RoundPolicy.php | 25 +++++++ resources/js/components/common/Icon.js | 2 + resources/js/components/protocol/Item.js | 11 +++ resources/js/components/rounds/Item.js | 2 + resources/js/components/rounds/LockButton.js | 58 +++++++++++++++ resources/js/components/rounds/LockDialog.js | 78 ++++++++++++++++++++ resources/js/components/rounds/SeedDialog.js | 2 + resources/js/components/tournament/Detail.js | 29 ++++---- resources/js/helpers/Round.js | 11 ++- resources/js/helpers/Tournament.js | 4 +- resources/js/helpers/permissions.js | 3 + resources/js/i18n/de.js | 22 +++++- resources/js/i18n/en.js | 20 ++++- routes/api.php | 2 + 16 files changed, 296 insertions(+), 25 deletions(-) create mode 100644 resources/js/components/rounds/LockButton.js create mode 100644 resources/js/components/rounds/LockDialog.js diff --git a/app/Http/Controllers/RoundController.php b/app/Http/Controllers/RoundController.php index f7e7042..4938b3a 100644 --- a/app/Http/Controllers/RoundController.php +++ b/app/Http/Controllers/RoundController.php @@ -60,4 +60,42 @@ class RoundController extends Controller return $round->toJson(); } + public function lock(Request $request, Round $round) { + $this->authorize('lock', $round); + + $round->locked = true; + $round->update(); + + Protocol::roundLocked( + $round->tournament, + $round, + $request->user(), + ); + + RoundChanged::dispatch($round); + + $round->load('results'); + + return $round->toJson(); + } + + public function unlock(Request $request, Round $round) { + $this->authorize('unlock', $round); + + $round->locked = false; + $round->update(); + + Protocol::roundUnlocked( + $round->tournament, + $round, + $request->user(), + ); + + RoundChanged::dispatch($round); + + $round->load('results'); + + return $round->toJson(); + } + } diff --git a/app/Models/Protocol.php b/app/Models/Protocol.php index 5b871f4..4659665 100644 --- a/app/Models/Protocol.php +++ b/app/Models/Protocol.php @@ -62,6 +62,19 @@ class Protocol extends Model ProtocolAdded::dispatch($protocol); } + public static function roundUnlocked(Tournament $tournament, Round $round, User $user = null) { + $protocol = static::create([ + 'tournament_id' => $tournament->id, + 'user_id' => $user ? $user->id : null, + 'type' => 'round.unlock', + 'details' => [ + 'tournament' => static::tournamentMemo($tournament), + 'round' => static::roundMemo($round), + ], + ]); + ProtocolAdded::dispatch($protocol); + } + public static function tournamentCreated(Tournament $tournament, User $user) { $protocol = static::create([ 'tournament_id' => $tournament->id, @@ -98,6 +111,7 @@ class Protocol extends Model protected static function roundMemo(Round $round) { return [ 'id' => $round->id, + 'number' => $round->number, 'seed' => $round->seed, ]; } diff --git a/app/Policies/RoundPolicy.php b/app/Policies/RoundPolicy.php index 7ea3cac..230b225 100644 --- a/app/Policies/RoundPolicy.php +++ b/app/Policies/RoundPolicy.php @@ -103,4 +103,29 @@ class RoundPolicy { return $user->role === 'admin' || ($user->isParticipant($round->tournament) && !$round->locked); } + + /** + * Determine whether the user can lock this round. + * + * @param \App\Models\User $user + * @param \App\Models\Round $round + * @return \Illuminate\Auth\Access\Response|bool + */ + public function lock(User $user, Round $round) + { + return $user->role === 'admin' || $user->isTournamentAdmin($round->tournament); + } + + /** + * Determine whether the user can unlock this round. + * + * @param \App\Models\User $user + * @param \App\Models\Round $round + * @return \Illuminate\Auth\Access\Response|bool + */ + public function unlock(User $user, Round $round) + { + return $this->lock($user, $round); + } + } diff --git a/resources/js/components/common/Icon.js b/resources/js/components/common/Icon.js index 53f251c..3474093 100644 --- a/resources/js/components/common/Icon.js +++ b/resources/js/components/common/Icon.js @@ -63,11 +63,13 @@ Icon.FINISHED = makePreset('FinishedIcon', 'square-check'); Icon.FIRST_PLACE = makePreset('FirstPlaceIcon', 'trophy'); Icon.FORFEIT = makePreset('ForfeitIcon', 'square-xmark'); Icon.LANGUAGE = makePreset('LanguageIcon', 'language'); +Icon.LOCKED = makePreset('LockedIcon', 'lock'); Icon.LOGOUT = makePreset('LogoutIcon', 'sign-out-alt'); Icon.PENDING = makePreset('PendingIcon', 'clock'); Icon.PROTOCOL = makePreset('ProtocolIcon', 'file-alt'); Icon.SECOND_PLACE = makePreset('SecondPlaceIcon', 'medal'); Icon.STREAM = makePreset('StreamIcon', ['fab', 'twitch']); Icon.THIRD_PLACE = makePreset('ThirdPlaceIcon', 'award'); +Icon.UNLOCKED = makePreset('UnlockedIcon', 'lock-open'); export default Icon; diff --git a/resources/js/components/protocol/Item.js b/resources/js/components/protocol/Item.js index 3337d04..3b16eeb 100644 --- a/resources/js/components/protocol/Item.js +++ b/resources/js/components/protocol/Item.js @@ -16,6 +16,9 @@ const getEntryDate = entry => { : dateStr; }; +const getEntryRoundNumber = entry => + (entry && entry.details && entry.details.round && entry.details.round.number) || '?'; + const getEntryResultTime = entry => { if (!entry || !entry.details || !entry.details.result) return 'ERROR'; const result = entry.details.result; @@ -33,6 +36,14 @@ const getEntryDescription = entry => { } case 'round.create': case 'round.lock': + case 'round.unlock': + return i18n.t( + `protocol.description.${entry.type}`, + { + ...entry, + number: getEntryRoundNumber(entry), + }, + ); case 'tournament.lock': return i18n.t( `protocol.description.${entry.type}`, diff --git a/resources/js/components/rounds/Item.js b/resources/js/components/rounds/Item.js index d8fe9d8..33394f4 100644 --- a/resources/js/components/rounds/Item.js +++ b/resources/js/components/rounds/Item.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withTranslation } from 'react-i18next'; +import LockButton from './LockButton'; import SeedButton from './SeedButton'; import SeedCode from './SeedCode'; import List from '../results/List'; @@ -43,6 +44,7 @@ const Item = ({ />

: null} + ; diff --git a/resources/js/components/rounds/LockButton.js b/resources/js/components/rounds/LockButton.js new file mode 100644 index 0000000..a7a60fb --- /dev/null +++ b/resources/js/components/rounds/LockButton.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import { Button } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; + +import LockDialog from './LockDialog'; +import Icon from '../common/Icon'; +import { mayLockRound } from '../../helpers/permissions'; +import { withUser } from '../../helpers/UserContext'; +import i18n from '../../i18n'; + +const LockButton = ({ + round, + tournament, + user, +}) => { + const [showDialog, setShowDialog] = useState(false); + + if (!mayLockRound(user, tournament, round)) { + if (round.locked) { + return ; + } else { + return ; + } + } + + return <> + setShowDialog(false)} + round={round} + show={showDialog} + /> + + ; +}; + +LockButton.propTypes = { + round: PropTypes.shape({ + locked: PropTypes.bool, + }), + tournament: PropTypes.shape({ + }), + user: PropTypes.shape({ + }), +}; + +export default withTranslation()(withUser(LockButton)); diff --git a/resources/js/components/rounds/LockDialog.js b/resources/js/components/rounds/LockDialog.js new file mode 100644 index 0000000..690e85c --- /dev/null +++ b/resources/js/components/rounds/LockDialog.js @@ -0,0 +1,78 @@ +import axios from 'axios'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Alert, Button, Modal } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; +import toastr from 'toastr'; + +import { isComplete } from '../../helpers/Round'; +import i18n from '../../i18n'; + +const LockDialog = ({ + onHide, + round, + show, + tournament, +}) => + + + + {i18n.t(round.locked ? 'rounds.unlock' : 'rounds.lock')} + + + +

{i18n.t(round.locked + ? 'rounds.unlockDescription' + : 'rounds.lockDescription')} +

+ {!round.locked && !isComplete(tournament, round) ? + + {i18n.t('rounds.lockIncompleteWarning')} + + : null} +
+ + {onHide ? + + : null} + + +
; + +LockDialog.propTypes = { + onHide: PropTypes.func, + round: PropTypes.shape({ + id: PropTypes.number, + locked: PropTypes.bool, + }), + show: PropTypes.bool, + tournament: PropTypes.shape({ + }), +}; + +export default withTranslation()(LockDialog); diff --git a/resources/js/components/rounds/SeedDialog.js b/resources/js/components/rounds/SeedDialog.js index c4db7d3..e181b64 100644 --- a/resources/js/components/rounds/SeedDialog.js +++ b/resources/js/components/rounds/SeedDialog.js @@ -27,6 +27,8 @@ const SeedDialog = ({ SeedDialog.propTypes = { onHide: PropTypes.func, + participant: PropTypes.shape({ + }), round: PropTypes.shape({ }), show: PropTypes.bool, diff --git a/resources/js/components/tournament/Detail.js b/resources/js/components/tournament/Detail.js index c966ca5..5b8e780 100644 --- a/resources/js/components/tournament/Detail.js +++ b/resources/js/components/tournament/Detail.js @@ -12,7 +12,6 @@ import { mayViewProtocol, } from '../../helpers/permissions'; import { - getRunners, getTournamentAdmins, hasRunners, hasTournamentAdmins, @@ -36,20 +35,7 @@ const Detail = ({ - -
-

{i18n.t('rounds.heading')}

- {addRound && mayAddRounds(user, tournament) ? - - : null} -
- {tournament.rounds ? - - : null} - - +

{i18n.t('tournaments.scoreboard')}

@@ -67,6 +53,19 @@ const Detail = ({ : null} + +
+

{i18n.t('rounds.heading')}

+ {addRound && mayAddRounds(user, tournament) ? + + : null} +
+ {tournament.rounds ? + + : null} +
; diff --git a/resources/js/helpers/Round.js b/resources/js/helpers/Round.js index 086517e..f662def 100644 --- a/resources/js/helpers/Round.js +++ b/resources/js/helpers/Round.js @@ -1,8 +1,15 @@ +import Participant from './Participant'; +import Tournament from './Tournament'; + export const isComplete = (tournament, round) => { if (!tournament || !tournament.participants) return false; if (!round || !round.results) return false; - return tournament.participants.length === round.results.length && - round.results.filter(r => !r.has_finished).length === 0; + const runners = Tournament.getRunners(tournament); + for (let i = 0; i < runners.length; ++i) { + const result = Participant.findResult(runners[i], round); + if (!result || !result.has_finished) return false; + } + return true; }; export const patchResult = (round, result) => { diff --git a/resources/js/helpers/Tournament.js b/resources/js/helpers/Tournament.js index 31414de..792b593 100644 --- a/resources/js/helpers/Tournament.js +++ b/resources/js/helpers/Tournament.js @@ -38,7 +38,7 @@ export const compareScore = (a, b) => { const b_score = b && b.score ? b.score : 0; if (a_score < b_score) return -1; if (b_score < a_score) return 1; - return Participant.compareUsername(a.participant, b.participant); + return Participant.compareUsername(a.participant, b.participant) * -1; }; export const findParticipant = (tournament, user) => { @@ -112,6 +112,8 @@ export default { calculateScores, compareScore, findParticipant, + getRunners, + getTournamentAdmins, patchResult, patchRound, patchUser, diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index 21016f1..3266294 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -30,6 +30,9 @@ export const hasFinished = (user, round) => export const mayAddRounds = (user, tournament) => isAdmin(user) || (!tournament.locked && isParticipant(user, tournament)); +export const mayLockRound = (user, tournament) => + isAdmin(user) || (!tournament.locked && isTournamentAdmin(user, tournament)); + export const maySetSeed = (user, tournament) => isAdmin(user) || isParticipant(user, tournament); diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 3bfadc1..87806b2 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -39,11 +39,13 @@ export default { FinishedIcon: 'Abgeschlossen', FirstPlaceIcon: 'Erster Platz', ForfeitIcon: 'Aufgegeben', + LockedIcon: 'Gesperrt', LogoutIcon: 'Logout', PendingIcon: 'Ausstehend', SecondPlaceIcon: 'Zweiter Platz', - ThirdPlaceIcon: 'Dritter Platz', StreamIcon: 'Stream', + ThirdPlaceIcon: 'Dritter Platz', + UnlockedIcon: 'Offen', zelda: { 'big-key': 'Big Key', 'blue-boomerang': 'Boomerang', @@ -111,11 +113,12 @@ export default { report: 'Ergebnis von <0>{{time}} eingetragen', }, round: { - create: 'Runde hinzugefügt', - lock: 'Runde festgesetzt', + create: 'Runde #{{number}} hinzugefügt', + lock: 'Runde #{{number}} gesperrt', + unlock: 'Runde #{{number}} entsperrt', }, tournament: { - lock: 'Turnier festgesetzt', + lock: 'Turnier gesperrt', }, unknown: 'Unbekannter Protokolleintrag vom Typ {{type}}.', }, @@ -138,10 +141,21 @@ export default { heading: 'Runden', new: 'Neue Runde', noSeed: 'Noch kein Seed', + lock: 'Runde sperren', + lockDescription: 'Wenn die Runde gesperrt wird, können Runner keine Änderungen an ihrem Ergebnis mehr vornehmen.', + locked: 'Die Runde ist für weitere Änderungen am Ergebnis gesperrt.', + lockError: 'Fehler beim Sperren', + lockIncompleteWarning: 'Achtung: Noch nicht alle Runner haben ihr Ergebnis für diese Runde eingereicht!', + lockSuccess: 'Runde gesperrt', seed: 'Seed', setSeed: 'Seed eintragen', setSeedError: 'Seed konnte nicht eintragen werden', setSeedSuccess: 'Seed eingetragen', + unlock: 'Runde entsperren', + unlockDescription: 'Die Runde wird wieder freigegeben und Runner können wieder Änderungen an ihrem Ergebnis vornehmen.', + unlocked: 'Die Runde ist offen für Änderungen am Ergebnis.', + unlockError: 'Fehler beim Entsperren', + unlockSuccess: 'Runde entsperrt', }, tournaments: { admins: 'Organisation', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index ccd5e95..533ba51 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -39,11 +39,13 @@ export default { FinishedIcon: 'Finished', FirstPlaceIcon: 'First Place', ForfeitIcon: 'Forfeit', + LockedIcon: 'Locked', LogoutIcon: 'Logout', PendingIcon: 'Pending', SecondPlaceIcon: 'Second Place', - ThirdPlaceIcon: 'Third Place', StreamIcon: 'Stream', + ThirdPlaceIcon: 'Third Place', + UnlockedIcon: 'Unlocked', zelda: { 'big-key': 'Big Key', 'blue-boomerang': 'Boomerang', @@ -111,8 +113,9 @@ export default { report: 'Result of {{time}} reported', }, round: { - create: 'Round added', - lock: 'Round locked', + create: 'Round #{{number}} added', + lock: 'Round #{{number}} locked', + unlock: 'Round #{{number}} unlocked', }, tournament: { lock: 'Tournament locked', @@ -138,10 +141,21 @@ export default { heading: 'Rounds', new: 'New round', noSeed: 'No seed set', + lock: 'Lock round', + lockDescription: 'When a round is locked, runners cannot submit or change results.', + locked: 'Results for this round have been locked.', + lockError: 'Error locking round', + lockIncompleteWarning: 'Warning: Not all runners have submitted their results for this round yet!', + lockSuccess: 'Round locked', seed: 'Seed', setSeed: 'Set seed', setSeedError: 'Seed could not be set', setSeedSuccess: 'Seed set', + unlock: 'Unock round', + unlockDescription: 'The round is unlocked and runers are free to submit or change their results again.', + unlocked: 'Results for this round are subject to change.', + unlockError: 'Error unlocking round', + unlockSuccess: 'Round unlocked', }, tournaments: { admins: 'Admins', diff --git a/routes/api.php b/routes/api.php index ab79dce..46ec7d0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -23,7 +23,9 @@ Route::get('protocol/{tournament}', 'App\Http\Controllers\ProtocolController@for Route::post('results', 'App\Http\Controllers\ResultController@create'); Route::post('rounds', 'App\Http\Controllers\RoundController@create'); +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'); Route::get('tournaments/{id}', 'App\Http\Controllers\TournamentController@single'); -- 2.39.2