From: Daniel Karbach Date: Wed, 26 Nov 2025 17:58:18 +0000 (+0100) Subject: result modification X-Git-Url: https://git.localhorst.tv/?a=commitdiff_plain;h=dca9fd6013d12661bd836a2f7d14ceef8106f68a;p=alttp.git result modification --- diff --git a/app/Http/Controllers/ResultController.php b/app/Http/Controllers/ResultController.php index 78a1c68..cca1887 100644 --- a/app/Http/Controllers/ResultController.php +++ b/app/Http/Controllers/ResultController.php @@ -89,6 +89,31 @@ class ResultController extends Controller return $result->toJson(); } + public function modify(Request $request, Result $result) { + $this->authorize('modify', $result); + + $validatedData = $request->validate([ + 'disqualified' => 'boolean', + 'forfeit' => 'boolean', + 'time' => 'numeric', + ]); + + if (isset($validatedData['disqualified'])) $result->disqualified = $validatedData['disqualified']; + if (isset($validatedData['forfeit'])) $result->forfeit = $validatedData['forfeit']; + if (isset($validatedData['time'])) $result->time = $validatedData['time']; + $result->save(); + + Protocol::resultModified( + $result->round->tournament, + $result, + $request->user(), + ); + + ResultChanged::dispatch($result); + + return $result->toJson(); + } + public function unverify(Request $request, Result $result) { $this->authorize('unverify', $result); diff --git a/app/Models/Protocol.php b/app/Models/Protocol.php index 07a1491..cfade45 100644 --- a/app/Models/Protocol.php +++ b/app/Models/Protocol.php @@ -94,6 +94,21 @@ class Protocol extends Model ProtocolAdded::dispatch($protocol); } + public static function resultModified(Tournament $tournament, Result $result, User $user) { + $protocol = static::create([ + 'tournament_id' => $tournament->id, + 'user_id' => $user->id, + 'type' => 'result.modify', + 'details' => [ + 'tournament' => static::tournamentMemo($tournament), + 'result' => static::resultMemo($result), + 'runner' => static::userMemo($result->user), + 'round' => static::roundMemo($result->round), + ], + ]); + ProtocolAdded::dispatch($protocol); + } + public static function resultUnverified(Tournament $tournament, Result $result, User $user) { $protocol = static::create([ 'tournament_id' => $tournament->id, @@ -323,6 +338,7 @@ class Protocol extends Model return [ 'id' => $result->id, 'comment' => $result->comment, + 'disqualified' => $result->disqualified, 'forfeit' => $result->forfeit, 'time' => $result->time, ]; @@ -343,9 +359,11 @@ class Protocol extends Model return [ 'id' => $tournament->id, 'accept_applications' => $tournament->accept_applications, + 'group_size' => $tournament->group_size, 'locked' => $tournament->locked, 'no_record' => $tournament->no_record, 'title' => $tournament->title, + 'type' => $tournament->type, ]; } diff --git a/app/Models/Result.php b/app/Models/Result.php index a85dbc7..317a354 100644 --- a/app/Models/Result.php +++ b/app/Models/Result.php @@ -103,6 +103,7 @@ class Result extends Model protected $casts = [ + 'disqualified' => 'boolean', 'forfeit' => 'boolean', 'got_seed_at' => 'datetime', 'time' => 'double', diff --git a/app/Policies/ResultPolicy.php b/app/Policies/ResultPolicy.php index 4c4bff5..a8f1377 100644 --- a/app/Policies/ResultPolicy.php +++ b/app/Policies/ResultPolicy.php @@ -92,6 +92,20 @@ class ResultPolicy return false; } + /** + * Determine whether the user can modify the result administratively. + * + * @param \App\Models\User $user + * @param \App\Models\Result $result + * @return \Illuminate\Auth\Access\Response|bool + */ + public function modify(User $user, Result $result) + { + return !$result->verified_at && + $user->isTournamentCrew($result->round->tournament) && + $user->id != $result->user_id; + } + /** * Determine whether the user can unverify the result. * @@ -101,7 +115,7 @@ class ResultPolicy */ public function unverify(User $user, Result $result) { - return $user->isTournamentCrew($result->round->tournament); + return $user->isTournamentCrew($result->round->tournament) && $user->id != $result->user_id; } /** @@ -113,7 +127,7 @@ class ResultPolicy */ public function verify(User $user, Result $result) { - return $user->isTournamentCrew($result->round->tournament); + return $user->isTournamentCrew($result->round->tournament) && $user->id != $result->user_id; } } diff --git a/database/migrations/2025_11_26_163943_result_disqualified.php b/database/migrations/2025_11_26_163943_result_disqualified.php new file mode 100644 index 0000000..be2cf84 --- /dev/null +++ b/database/migrations/2025_11_26_163943_result_disqualified.php @@ -0,0 +1,28 @@ +boolean('disqualified')->default(false)->after('forfeit'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('results', function (Blueprint $table) { + $table->dropColumn('disqualified'); + }); + } +}; diff --git a/resources/js/components/common/Icon.jsx b/resources/js/components/common/Icon.jsx index c6e0cfa..16f26b8 100644 --- a/resources/js/components/common/Icon.jsx +++ b/resources/js/components/common/Icon.jsx @@ -60,6 +60,7 @@ Icon.CHART = makePreset('ChartIcon', 'chart-line'); Icon.CROSSHAIRS = makePreset('CrosshairsIcon', 'crosshairs'); Icon.DELETE = makePreset('DeleteIcon', 'user-xmark'); Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']); +Icon.DISQUALIFIED = makePreset('DisqualifiedIcon', 'square-xmark'); Icon.DOWNLOAD = makePreset('DownloadIcon', 'download'); Icon.EDIT = makePreset('EditIcon', 'edit'); Icon.ERROR = makePreset('ErrorIcon', 'triangle-exclamation'); @@ -68,7 +69,7 @@ Icon.FILTER = makePreset('FilterIcon', 'filter'); Icon.FINISHED = makePreset('FinishedIcon', 'square-check'); Icon.FIRST_PLACE = makePreset('FirstPlaceIcon', 'trophy'); Icon.FORBIDDEN = makePreset('ForbiddenIcon', 'square-xmark'); -Icon.FORFEIT = makePreset('ForfeitIcon', 'square-xmark'); +Icon.FORFEIT = makePreset('ForfeitIcon', 'square-minus'); Icon.HASH = makePreset('HashIcon', 'hashtag'); Icon.INFO = makePreset('Info', 'circle-info'); Icon.INVERT = makePreset('InvertIcon', 'circle-half-stroke'); diff --git a/resources/js/components/protocol/Item.jsx b/resources/js/components/protocol/Item.jsx index e7134f9..d215bb9 100644 --- a/resources/js/components/protocol/Item.jsx +++ b/resources/js/components/protocol/Item.jsx @@ -7,6 +7,7 @@ import { Trans, useTranslation } from 'react-i18next'; import Icon from '../common/Icon'; import Spoiler from '../common/Spoiler'; import { formatTime } from '../../helpers/Result'; +import { formatNumberAlways } from '../../helpers/Round'; import { getUserName } from '../../helpers/User'; const getEntryDate = entry => { @@ -27,7 +28,7 @@ const getEntryDetailsUsername = entry => { }; const getEntryRoundNumber = entry => - (entry && entry.details && entry.details.round && entry.details.round.number) || '?'; + (entry && entry.details && entry.details.round && formatNumberAlways(entry.details.tournament, entry.details.round)) || '?'; const getEntryResultComment = entry => { if (!entry || !entry.details || !entry.details.result || !entry.details.result.comment) { @@ -53,6 +54,7 @@ const getEntryResultRunner = entry => { const getEntryResultTime = entry => { if (!entry || !entry.details || !entry.details.result) return 'ERROR'; const result = entry.details.result; + if (result.disqualified) return 'DQ XXX'; if (result.forfeit) return 'DNF XX'; return formatTime(result); }; @@ -94,6 +96,7 @@ const getEntryDescription = (entry, t) => { {{time}}, ; } + case 'result.modify': case 'result.unverify': case 'result.verify': { const number = getEntryRoundNumber(entry); @@ -145,6 +148,7 @@ const getEntryIcon = entry => { case 'result.unverify': case 'round.delete': return ; + case 'result.modify': case 'round.edit': case 'round.seed': return ; diff --git a/resources/js/components/results/DetailDialog.jsx b/resources/js/components/results/DetailDialog.jsx index 388d57d..5031cae 100644 --- a/resources/js/components/results/DetailDialog.jsx +++ b/resources/js/components/results/DetailDialog.jsx @@ -4,11 +4,12 @@ import React from 'react'; import { Button, Col, Form, Modal, Row } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; +import ModifyForm from './ModifyForm'; import Verification from './Verification'; import Box from '../users/Box'; import { formatTime, getTime } from '../../helpers/Result'; import { formatNumberAlways } from '../../helpers/Round'; -import { maySeeResult, mayVerifyResult } from '../../helpers/permissions'; +import { mayModifyResult, maySeeResult, mayVerifyResult } from '../../helpers/permissions'; import { findResult } from '../../helpers/User'; import { useUser } from '../../hooks/user'; @@ -30,6 +31,10 @@ const DetailDialog = ({ () => findResult(user, round), [round, user], ); + const mayModify = React.useMemo( + () => mayModifyResult(authUser, tournament, round, result), + [authUser, result, round, tournament], + ); const maySee = React.useMemo( () => maySeeResult(authUser, tournament, round, result), [authUser, result, round, tournament], @@ -122,6 +127,12 @@ const DetailDialog = ({ ) : null} + {mayModify && actions.modifyResult ? ( + +
{t('results.modify')}
+ actions.modifyResult(result, values)} result={result} /> +
+ ) : null} + ; +}; + +ModifyForm.propTypes = { + errors: PropTypes.shape({ + disqualified: PropTypes.string, + forfeit: PropTypes.string, + time: PropTypes.string, + }), + handleBlur: PropTypes.func, + handleChange: PropTypes.func, + handleSubmit: PropTypes.func, + touched: PropTypes.shape({ + disqualified: PropTypes.bool, + forfeit: PropTypes.bool, + time: PropTypes.bool, + }), + values: PropTypes.shape({ + disqualified: PropTypes.bool, + forfeit: PropTypes.bool, + time: PropTypes.string, + }), +}; + +export default withFormik({ + displayName: 'ModifyForm', + enableReinitialize: true, + handleSubmit: async (values, actions) => { + const { disqualified, forfeit, time } = values; + const { setErrors } = actions; + const { onSubmit } = actions.props; + try { + await onSubmit({ + disqualified, + forfeit, + time: parseTime(time) || 0, + }); + } catch (e) { + if (e.response && e.response.data && e.response.data.errors) { + setErrors(laravelErrorsToFormik(e.response.data.errors)); + } + } + }, + mapPropsToValues: ({ result }) => { + return { + disqualified: result ? !!result.disqualified : false, + forfeit: result ? !!result.forfeit : false, + time: result && result.time ? formatTime(result) : '', + }; + }, + validationSchema: yup.object().shape({ + disqualified: yup.boolean().required(), + forfeit: yup.boolean().required(), + time: yup.string().time().when('forfeit', { + is: false, + then: () => yup.string().required().time(), + }), + }), +})(ModifyForm); diff --git a/resources/js/components/results/Verification.jsx b/resources/js/components/results/Verification.jsx index 43113c5..141b004 100644 --- a/resources/js/components/results/Verification.jsx +++ b/resources/js/components/results/Verification.jsx @@ -61,11 +61,16 @@ const Verification = ({ actions, result, round, tournament }) => { {mayUnverify ? : null} ; diff --git a/resources/js/helpers/Result.jsx b/resources/js/helpers/Result.jsx index 833368a..efa592e 100644 --- a/resources/js/helpers/Result.jsx +++ b/resources/js/helpers/Result.jsx @@ -57,6 +57,9 @@ export const getIcon = (result, maySee) => { if (result.placement === 3 && maySee) { return ; } + if (result.disqualified) { + return ; + } if (result.verified_at) { return ; } @@ -67,12 +70,15 @@ export const getTime = (result, maySee) => { if (!result || !maySee) { return null; } - if (result.time) { - return formatTime(result); + if (result.disqualified) { + return 'DQ'; } if (result.forfeit) { return 'DNF'; } + if (result.time) { + return formatTime(result); + } return '?'; }; diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index e6fc01c..a1ab287 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -213,6 +213,14 @@ export const maySeeResult = (user, tournament, round, result) => { return maySeeResults(user, tournament, round); }; +export const mayModifyResults = (user, tournament, round) => { + return isTournamentCrew(user, tournament); +}; + +export const mayModifyResult = (user, tournament, round, result) => { + return mayModifyResults(user, tournament) && user && result && !result.verified_at && user.id !== result.user_id; +}; + export const mayVerifyResults = (user, tournament, round) => { return isTournamentCrew(user, tournament); }; diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 181f88b..b1cf480 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -584,6 +584,7 @@ export default { }, result: { comment: 'Ergebnis von Runde {{number}} kommentiert: <1>{{comment}}', + modify: 'Ergebnis in Runde {{number}} von {{runner}} angepasst (<2>{{time}})', report: 'Ergebnis von <1>{{time}} bei Runde {{number}} eingetragen', unverify: 'Verifikation von Ergebnis in Runde {{number}} von {{runner}} (<2>{{time}}) zurückgezogen', verify: 'Ergebnis in Runde {{number}} von {{runner}} (<2>{{time}}) verifiziert', @@ -616,12 +617,18 @@ export default { createdAtFormat: '{{ date, L LT }}', comment: 'Kommentar', details: 'Details', + disqualified: 'Disqualifiziert', + disqualifiedShort: 'DQ', edit: 'Ergebnis ändern', editComment: 'Kommentar ändern', forfeit: 'Aufgegeben', + forfeitShort: 'DNF', gotSeedAt: 'Seed geladen', gotSeedAtFormat: '{{ date, L LT }}', list: 'Liste', + modify: 'Anpassen', + modifyError: 'Fehler beim Speichern', + modifySuccess: 'Ergebnis angepasst', pending: 'Ausstehend', placement: 'Platzierung', points_one: '{{ count }} Punkt', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 3b1f875..b52fbab 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -584,6 +584,7 @@ export default { }, result: { comment: 'Result of round {{number}} commented: <1>{{comment}}', + modify: 'Modified round {{number}} result of {{runner}} (<2>{{time}})', report: 'Result of <1>{{time}} reported for round {{number}}', unverify: 'Revoked verification for round {{number}} result of {{runner}} (<2>{{time}})', verify: 'Verified round {{number}} result of {{runner}} (<2>{{time}})', @@ -616,12 +617,18 @@ export default { createdAt: 'Entry from', createdAtFormat: '{{ date, L LT }}', details: 'Details', + disqualified: 'Disqualified', + disqualifiedShort: 'DQ', edit: 'Change result', editComment: 'Edit comment', forfeit: 'Forfeit', + forfeitShort: 'DNF', gotSeedAt: 'Got seed at', gotSeedAtFormat: '{{ date, L LT }}', list: 'List', + modify: 'Modify', + modifyError: 'Error saving modifications', + modifySuccess: 'Result modified', pending: 'Pending', placement: 'Placement', points_one: '{{ count }} point', diff --git a/resources/js/pages/Tournament.jsx b/resources/js/pages/Tournament.jsx index 54aa92f..a3c4170 100644 --- a/resources/js/pages/Tournament.jsx +++ b/resources/js/pages/Tournament.jsx @@ -14,6 +14,7 @@ import Dialog from '../components/techniques/Dialog'; import Detail from '../components/tournament/Detail'; import { mayEditContent, + mayModifyResults, mayUnverifyResults, mayVerifyResults, } from '../helpers/permissions'; @@ -174,6 +175,17 @@ export const Component = () => { } }, [id, t]); + const modifyResult = React.useCallback(async (result, values) => { + try { + const response = await axios.post(`/api/results/${result.id}/modify`, values); + toastr.success(t('results.modifySuccess')); + setTournament(tournament => patchResult(tournament, response.data)); + } catch (e) { + toastr.error(t('results.modifyError', e)); + throw e; + } + }, []); + const unverifyResult = React.useCallback(async (result) => { try { const response = await axios.post(`/api/results/${result.id}/unverify`); @@ -182,7 +194,7 @@ export const Component = () => { } catch (e) { toastr.error(t('results.unverifyError', e)); } - }); + }, []); const verifyResult = React.useCallback(async (result) => { try { @@ -192,7 +204,7 @@ export const Component = () => { } catch (e) { toastr.error(t('results.verifyError', e)); } - }); + }, []); const actions = React.useMemo(() => ({ addRound, @@ -200,11 +212,12 @@ export const Component = () => { setEditContent(content); setShowContentDialog(true); } : null, + modifyResult: mayModifyResults(user, tournament) ? modifyResult : null, moreRounds: canLoadMoreRounds(tournament) ? moreRounds : null, selfAssignGroups, unverifyResult: mayUnverifyResults(user, tournament) ? unverifyResult : null, verifyResult: mayVerifyResults(user, tournament) ? verifyResult : null, - }), [addRound, moreRounds, selfAssignGroups, tournament, user, verifyResult]); + }), [addRound, modifyResult, moreRounds, selfAssignGroups, tournament, unverifyResult, user, verifyResult]); useEffect(() => { const cb = (e) => { diff --git a/resources/sass/results.scss b/resources/sass/results.scss index 00659a9..e90bcca 100644 --- a/resources/sass/results.scss +++ b/resources/sass/results.scss @@ -1,4 +1,5 @@ .result-dialog { + .modification, .verification { border-top: thin solid $light; } diff --git a/routes/api.php b/routes/api.php index aacbf49..5eb9e13 100644 --- a/routes/api.php +++ b/routes/api.php @@ -85,6 +85,7 @@ Route::get('protocol/{tournament}', 'App\Http\Controllers\ProtocolController@for Route::get('protocol/{tournament}/{round}', 'App\Http\Controllers\ProtocolController@forRound'); Route::post('results', 'App\Http\Controllers\ResultController@create'); +Route::post('results/{result}/modify', 'App\Http\Controllers\ResultController@modify'); Route::post('results/{result}/verify', 'App\Http\Controllers\ResultController@verify'); Route::post('results/{result}/unverify', 'App\Http\Controllers\ResultController@unverify');