From d1f28ea443b090c7593791eba9631796ccaeafe1 Mon Sep 17 00:00:00 2001
From: Daniel Karbach
Date: Sun, 20 Mar 2022 23:12:13 +0100
Subject: [PATCH 1/1] 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}}0> 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