]> git.localhorst.tv Git - alttp.git/commitdiff
allow admins to lock/unlock rounds
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Sun, 20 Mar 2022 22:12:13 +0000 (23:12 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Sun, 20 Mar 2022 22:12:13 +0000 (23:12 +0100)
16 files changed:
app/Http/Controllers/RoundController.php
app/Models/Protocol.php
app/Policies/RoundPolicy.php
resources/js/components/common/Icon.js
resources/js/components/protocol/Item.js
resources/js/components/rounds/Item.js
resources/js/components/rounds/LockButton.js [new file with mode: 0644]
resources/js/components/rounds/LockDialog.js [new file with mode: 0644]
resources/js/components/rounds/SeedDialog.js
resources/js/components/tournament/Detail.js
resources/js/helpers/Round.js
resources/js/helpers/Tournament.js
resources/js/helpers/permissions.js
resources/js/i18n/de.js
resources/js/i18n/en.js
routes/api.php

index f7e70423f07a987689e03bc5ec15f69f64bc7da4..4938b3a04a0bac61c31ccd2d2ba49dd604d2209c 100644 (file)
@@ -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();
+       }
+
 }
index 5b871f43a71d87adbc8170963b89bd46629f39a4..4659665e87ffddeed7adcae097afcd0df81f3cd2 100644 (file)
@@ -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,
                ];
        }
index 7ea3cacb8544a31cc65e90e3fbdac9f2e40a70ef..230b2254d9c9469d29d388c56905aa9b0647464b 100644 (file)
@@ -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);
+       }
+
 }
index 53f251c68094ad59e3bec265fe374224a0802d01..34740931a44ed99ab9625898104143a04444a067 100644 (file)
@@ -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;
index 3337d04621cbd4ae7380c2f0fb880e7ee8535145..3b16eeb11c9da1307a9f7b142affaba2e89c7060 100644 (file)
@@ -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}`,
index d8fe9d8b1ccc88983b2f843c712481fbffe260b3..33394f4140f5873bb2c00d4a4100501796e0e3e0 100644 (file)
@@ -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 = ({
                                />
                        </p>
                : null}
+               <LockButton round={round} tournament={tournament} />
        </div>
        <List round={round} tournament={tournament} />
 </li>;
diff --git a/resources/js/components/rounds/LockButton.js b/resources/js/components/rounds/LockButton.js
new file mode 100644 (file)
index 0000000..a7a60fb
--- /dev/null
@@ -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 <Icon.LOCKED title={i18n.t('rounds.locked')} />;
+               } else {
+                       return <Icon.UNLOCKED title={i18n.t('rounds.unlocked')} />;
+               }
+       }
+
+       return <>
+               <LockDialog
+                       onHide={() => setShowDialog(false)}
+                       round={round}
+                       show={showDialog}
+               />
+               <Button
+                       onClick={() => setShowDialog(true)}
+                       size="sm"
+                       title={round.locked ? i18n.t('rounds.locked') : i18n.t('rounds.unlocked') }
+                       variant="outline-secondary"
+               >
+                       {round.locked ?
+                               <Icon.LOCKED title="" />
+                       :
+                               <Icon.UNLOCKED title="" />
+                       }
+               </Button>
+       </>;
+};
+
+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 (file)
index 0000000..690e85c
--- /dev/null
@@ -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,
+}) =>
+<Modal className="lock-dialog" onHide={onHide} show={show}>
+       <Modal.Header closeButton>
+               <Modal.Title>
+                       {i18n.t(round.locked ? 'rounds.unlock' : 'rounds.lock')}
+               </Modal.Title>
+       </Modal.Header>
+       <Modal.Body>
+               <p>{i18n.t(round.locked
+                       ? 'rounds.unlockDescription'
+                       : 'rounds.lockDescription')}
+               </p>
+       {!round.locked && !isComplete(tournament, round) ?
+               <Alert variant="warning">
+                       {i18n.t('rounds.lockIncompleteWarning')}
+               </Alert>
+       : null}
+       </Modal.Body>
+       <Modal.Footer>
+               {onHide ?
+                       <Button onClick={onHide} variant="secondary">
+                               {i18n.t('button.cancel')}
+                       </Button>
+               : null}
+               <Button
+                       onClick={async () => {
+                               if (round.locked) {
+                                       try {
+                                               await axios.post(`/api/rounds/${round.id}/unlock`);
+                                               toastr.success(i18n.t('rounds.unlockSuccess'));
+                                               onHide();
+                                       } catch (e) {
+                                               toastr.error(i18n.t('rounds.unlockError'));
+                                       }
+                               } else {
+                                       try {
+                                               await axios.post(`/api/rounds/${round.id}/lock`);
+                                               toastr.success(i18n.t('rounds.lockSuccess'));
+                                               onHide();
+                                       } catch (e) {
+                                               toastr.error(i18n.t('rounds.lockError'));
+                                       }
+                               }
+                       }}
+                       variant="primary"
+               >
+                       {i18n.t(round.locked ? 'rounds.unlock' : 'rounds.lock')}
+               </Button>
+       </Modal.Footer>
+</Modal>;
+
+LockDialog.propTypes = {
+       onHide: PropTypes.func,
+       round: PropTypes.shape({
+               id: PropTypes.number,
+               locked: PropTypes.bool,
+       }),
+       show: PropTypes.bool,
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(LockDialog);
index c4db7d3377da70beaeb04a7c680fb09fa24ee4c0..e181b64b132157f58f5d2460f21dc4abd1e99ac2 100644 (file)
@@ -27,6 +27,8 @@ const SeedDialog = ({
 
 SeedDialog.propTypes = {
        onHide: PropTypes.func,
+       participant: PropTypes.shape({
+       }),
        round: PropTypes.shape({
        }),
        show: PropTypes.bool,
index c966ca5250685da8c6a582302dfe6d8bd84ccdb7..5b8e7804a5e45e0006c27a4f9bdae44d985bff33 100644 (file)
@@ -12,7 +12,6 @@ import {
        mayViewProtocol,
 } from '../../helpers/permissions';
 import {
-       getRunners,
        getTournamentAdmins,
        hasRunners,
        hasTournamentAdmins,
@@ -36,20 +35,7 @@ const Detail = ({
                </Col>
        </Row>
        <Row>
-               <Col lg={8} xl={9}>
-                       <div className="d-flex align-items-center justify-content-between">
-                               <h2>{i18n.t('rounds.heading')}</h2>
-                               {addRound && mayAddRounds(user, tournament) ?
-                                       <Button onClick={addRound}>
-                                               {i18n.t('rounds.new')}
-                                       </Button>
-                               : null}
-                       </div>
-                       {tournament.rounds ?
-                               <Rounds rounds={tournament.rounds} tournament={tournament} />
-                       : null}
-               </Col>
-               <Col lg={4} xl={3}>
+               <Col lg={{ order: 2, span: 4 }} xl={{ order: 2, span: 3 }}>
                        <div className="d-flex align-items-center justify-content-between">
                                <h2>{i18n.t('tournaments.scoreboard')}</h2>
                        </div>
@@ -67,6 +53,19 @@ const Detail = ({
                                </>
                        : null}
                </Col>
+               <Col lg={{ order: 1, span: 8 }} xl={{ order: 1, span: 9 }}>
+                       <div className="d-flex align-items-center justify-content-between">
+                               <h2>{i18n.t('rounds.heading')}</h2>
+                               {addRound && mayAddRounds(user, tournament) ?
+                                       <Button onClick={addRound}>
+                                               {i18n.t('rounds.new')}
+                                       </Button>
+                               : null}
+                       </div>
+                       {tournament.rounds ?
+                               <Rounds rounds={tournament.rounds} tournament={tournament} />
+                       : null}
+               </Col>
        </Row>
 </Container>;
 
index 086517e2f897ec1a79bc709800a0c4b08114b5ad..f662defaa4d5f23bc476127b55a458ab6fc4a04e 100644 (file)
@@ -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) => {
index 31414de957fffab192fd073b1f8cae067cdff306..792b593091b70ba62a24414325420e8645100786 100644 (file)
@@ -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,
index 21016f1a08f6786fecfdcbe775717ace91b8279b..32662944a90412639878392d3e32663f6e3e59fe 100644 (file)
@@ -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);
 
index 3bfadc17c20663e82e345e9eaa55c1d554bd758d..87806b2b5b91b62f65d3b06a22c155d28776a4bc 100644 (file)
@@ -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',
index ccd5e9553bc124e35256b0e50efe28c4b5dc5a27..533ba51a93529b8c0395500f7c556d3150dece63 100644 (file)
@@ -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',
index ab79dce945493aebea14e1b5821286b074701f64..46ec7d04ce4bd6b56bd0d1df39b381f701741fdf 100644 (file)
@@ -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');