From 3a774bb649734fc3a2135ec1b52cef9a049880ee Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Sat, 9 Apr 2022 21:01:19 +0200 Subject: [PATCH] tournament application --- app/Events/ApplicationAdded.php | 40 ++++++++ app/Http/Controllers/TournamentController.php | 14 +++ app/Models/Application.php | 25 +++++ app/Models/Tournament.php | 5 + app/Models/User.php | 18 ++++ app/Policies/ApplicationPolicy.php | 97 +++++++++++++++++++ app/Policies/TournamentPolicy.php | 12 +++ ...22_04_09_120955_tournament_application.php | 45 +++++++++ resources/js/components/common/Icon.js | 1 + resources/js/components/pages/Tournament.js | 11 +++ .../js/components/tournament/ApplyButton.js | 55 +++++++++++ resources/js/components/tournament/Detail.js | 10 +- resources/js/helpers/Tournament.js | 22 +++++ resources/js/helpers/permissions.js | 19 ++++ resources/js/i18n/de.js | 6 ++ resources/js/i18n/en.js | 6 ++ resources/sass/common.scss | 13 +++ routes/api.php | 3 +- 18 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 app/Events/ApplicationAdded.php create mode 100644 app/Models/Application.php create mode 100644 app/Policies/ApplicationPolicy.php create mode 100644 database/migrations/2022_04_09_120955_tournament_application.php create mode 100644 resources/js/components/tournament/ApplyButton.js diff --git a/app/Events/ApplicationAdded.php b/app/Events/ApplicationAdded.php new file mode 100644 index 0000000..9de696e --- /dev/null +++ b/app/Events/ApplicationAdded.php @@ -0,0 +1,40 @@ +application = $application; + } + + /** + * Get the channels the event should broadcast on. + * + * @return \Illuminate\Broadcasting\Channel|array + */ + public function broadcastOn() + { + return new Channel('Tournament.'.$this->application->tournament_id); + } + + public $application; + +} diff --git a/app/Http/Controllers/TournamentController.php b/app/Http/Controllers/TournamentController.php index e563eac..9d62c23 100644 --- a/app/Http/Controllers/TournamentController.php +++ b/app/Http/Controllers/TournamentController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Events\ApplicationAdded; +use App\Models\Application; use App\Models\Tournament; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\Request; @@ -9,8 +11,20 @@ use Illuminate\Http\Request; class TournamentController extends Controller { + public function apply(Request $request, Tournament $tournament) { + $this->authorize('apply', $tournament); + $application = new Application(); + $application->tournament_id = $tournament->id; + $application->user_id = $request->user()->id; + $application->save(); + ApplicationAdded::dispatch($application); + return $tournament->toJson(); + } + public function single(Request $request, $id) { $tournament = Tournament::with( + 'applications', + 'applications.user', 'rounds', 'rounds.results', 'participants', diff --git a/app/Models/Application.php b/app/Models/Application.php new file mode 100644 index 0000000..327d3fb --- /dev/null +++ b/app/Models/Application.php @@ -0,0 +1,25 @@ +belongsTo(Tournament::class); + } + + public function user() { + return $this->belongsTo(User::class); + } + + protected $with = [ + 'user', + ]; + +} diff --git a/app/Models/Tournament.php b/app/Models/Tournament.php index 56ffe78..eea8809 100644 --- a/app/Models/Tournament.php +++ b/app/Models/Tournament.php @@ -61,6 +61,10 @@ class Tournament extends Model } + public function applications() { + return $this->hasMany(Application::class); + } + public function participants() { return $this->hasMany(Participant::class); } @@ -75,6 +79,7 @@ class Tournament extends Model protected $casts = [ + 'accept_applications' => 'boolean', 'locked' => 'boolean', 'no_record' => 'boolean', ]; diff --git a/app/Models/User.php b/app/Models/User.php index 0c2dcf7..ff45c61 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -16,6 +16,24 @@ class User extends Authenticatable return $this->role === 'admin'; } + public function isApplicant(Tournament $tournament) { + foreach ($tournament->applications as $applicant) { + if ($applicant->user_id == $this->id) { + return true; + } + } + return false; + } + + public function isDeniedApplicant(Tournament $tournament) { + foreach ($tournament->applications as $applicant) { + if ($applicant->user_id == $this->id) { + return $applicant->denied; + } + } + return false; + } + public function isParticipant(Tournament $tournament) { foreach ($tournament->participants as $participant) { if ($participant->user_id == $this->id) { diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php new file mode 100644 index 0000000..3dc9ad8 --- /dev/null +++ b/app/Policies/ApplicationPolicy.php @@ -0,0 +1,97 @@ +isAdmin(); + } + + /** + * Determine whether the user can view the model. + * + * @param \App\Models\User $user + * @param \App\Models\Application $application + * @return \Illuminate\Auth\Access\Response|bool + */ + public function view(User $user, Application $application) + { + return $user->isAdmin() + || $user->isTournamentAdmin($application->tournament) + || $user->id == $application->user->id; + } + + /** + * Determine whether the user can create models. + * + * @param \App\Models\User $user + * @return \Illuminate\Auth\Access\Response|bool + */ + public function create(User $user) + { + return false; + } + + /** + * Determine whether the user can update the model. + * + * @param \App\Models\User $user + * @param \App\Models\Application $application + * @return \Illuminate\Auth\Access\Response|bool + */ + public function update(User $user, Application $application) + { + return false; + } + + /** + * Determine whether the user can delete the model. + * + * @param \App\Models\User $user + * @param \App\Models\Application $application + * @return \Illuminate\Auth\Access\Response|bool + */ + public function delete(User $user, Application $application) + { + return false; + } + + /** + * Determine whether the user can restore the model. + * + * @param \App\Models\User $user + * @param \App\Models\Application $application + * @return \Illuminate\Auth\Access\Response|bool + */ + public function restore(User $user, Application $application) + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + * + * @param \App\Models\User $user + * @param \App\Models\Application $application + * @return \Illuminate\Auth\Access\Response|bool + */ + public function forceDelete(User $user, Application $application) + { + return false; + } + +} diff --git a/app/Policies/TournamentPolicy.php b/app/Policies/TournamentPolicy.php index 5942b7f..a95dc95 100644 --- a/app/Policies/TournamentPolicy.php +++ b/app/Policies/TournamentPolicy.php @@ -104,6 +104,18 @@ class TournamentPolicy return !$tournament->locked && ($user->isRunner($tournament) || $user->isTournamentAdmin($tournament)); } + /** + * Determine whether the user can apply to participate. + * + * @param \App\Models\User $user + * @param \App\Models\Tournament $tournament + * @return \Illuminate\Auth\Access\Response|bool + */ + public function apply(User $user, Tournament $tournament) + { + return $tournament->accept_applications && !$user->isRunner($tournament) && !$user->isApplicant($tournament); + } + /** * Determine whether the user can view the tournament protocol. * diff --git a/database/migrations/2022_04_09_120955_tournament_application.php b/database/migrations/2022_04_09_120955_tournament_application.php new file mode 100644 index 0000000..a63ff87 --- /dev/null +++ b/database/migrations/2022_04_09_120955_tournament_application.php @@ -0,0 +1,45 @@ +id(); + $table->foreignId('tournament_id')->constrained(); + $table->foreignId('user_id')->constrained(); + $table->boolean('denied')->default(false); + $table->timestamps(); + + $table->unique(['tournament_id', 'user_id']); + }); + Schema::table('tournaments', function(Blueprint $table) { + $table->boolean('accept_applications')->default(false); + }); + Schema::table('participants', function(Blueprint $table) { + $table->unique(['tournament_id', 'user_id']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('tournaments', function(Blueprint $table) { + $table->dropColumn('accept_applications'); + }); + Schema::dropIfExists('applications'); + } +}; diff --git a/resources/js/components/common/Icon.js b/resources/js/components/common/Icon.js index 7f2f93f..607deb9 100644 --- a/resources/js/components/common/Icon.js +++ b/resources/js/components/common/Icon.js @@ -58,6 +58,7 @@ const makePreset = (presetDisplayName, presetName) => { }; Icon.ADD = makePreset('AddIcon', 'circle-plus'); +Icon.APPLY = makePreset('ApplyIcon', 'right-to-bracket'); Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']); Icon.EDIT = makePreset('EditIcon', 'edit'); Icon.FINISHED = makePreset('FinishedIcon', 'square-check'); diff --git a/resources/js/components/pages/Tournament.js b/resources/js/components/pages/Tournament.js index bc1282e..440e53a 100644 --- a/resources/js/components/pages/Tournament.js +++ b/resources/js/components/pages/Tournament.js @@ -8,6 +8,7 @@ import Loading from '../common/Loading'; import NotFound from '../pages/NotFound'; import Detail from '../tournament/Detail'; import { + patchApplication, patchParticipant, patchResult, patchRound, @@ -41,6 +42,16 @@ const Tournament = () => { useEffect(() => { window.Echo.channel(`Tournament.${id}`) + .listen('ApplicationAdded', e => { + if (e.application) { + setTournament(tournament => patchApplication(tournament, e.application)); + } + }) + .listen('ApplicationChanged', e => { + if (e.application) { + setTournament(tournament => patchApplication(tournament, e.application)); + } + }) .listen('ParticipantChanged', e => { if (e.participant) { setTournament(tournament => patchParticipant(tournament, e.participant)); diff --git a/resources/js/components/tournament/ApplyButton.js b/resources/js/components/tournament/ApplyButton.js new file mode 100644 index 0000000..bd944c9 --- /dev/null +++ b/resources/js/components/tournament/ApplyButton.js @@ -0,0 +1,55 @@ +import axios from 'axios'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; +import toastr from 'toastr'; + +import Icon from '../common/Icon'; +import { isApplicant, isDeniedApplicant, mayApply } from '../../helpers/permissions'; +import { withUser } from '../../helpers/UserContext'; +import i18n from '../../i18n'; + +const apply = async tournament => { + try { + await axios.post(`/api/tournaments/${tournament.id}/apply`); + toastr.success(i18n.t('tournaments.applySuccess')); + } catch (e) { + toastr.error(i18n.t('tournaments.applyError')); + } +}; + +const getTitle = (user, tournament) => { + if (isDeniedApplicant(user, tournament)) { + return i18n.t('tournaments.applicationDenied'); + } + if (isApplicant(user, tournament)) { + return i18n.t('tournaments.applicationPending'); + } + return i18n.t('tournaments.apply'); +}; + +const ApplyButton = ({ tournament, user }) => { + if (!tournament.accept_applications) return null; + + return + + ; +}; + +ApplyButton.propTypes = { + tournament: PropTypes.shape({ + accept_applications: PropTypes.bool, + id: PropTypes.number, + }), + user: PropTypes.shape({ + }), +}; + +export default withTranslation()(withUser(ApplyButton)); diff --git a/resources/js/components/tournament/Detail.js b/resources/js/components/tournament/Detail.js index b6afe90..b20f208 100644 --- a/resources/js/components/tournament/Detail.js +++ b/resources/js/components/tournament/Detail.js @@ -3,6 +3,7 @@ import React from 'react'; import { Button, Col, Container, Row } from 'react-bootstrap'; import { withTranslation } from 'react-i18next'; +import ApplyButton from './ApplyButton'; import Scoreboard from './Scoreboard'; import Protocol from '../protocol/Protocol'; import Rounds from '../rounds/List'; @@ -44,9 +45,12 @@ const Detail = ({

{tournament.title}

- {mayViewProtocol(user, tournament) ? - - : null} +
+ + {mayViewProtocol(user, tournament) ? + + : null} +
diff --git a/resources/js/helpers/Tournament.js b/resources/js/helpers/Tournament.js index 7dbcb9b..25981d8 100644 --- a/resources/js/helpers/Tournament.js +++ b/resources/js/helpers/Tournament.js @@ -59,6 +59,28 @@ export const hasTournamentMonitors = tournament => { return getTournamentMonitors(tournament).length > 0; }; +export const patchApplication = (tournament, application) => { + if (!tournament) return tournament; + if (!tournament.applications || !tournament.applications.length) { + return { + ...tournament, + applications: [application], + }; + } + if (!tournament.applications.find(a => a.user_id == application.user_id)) { + return { + ...tournament, + applications: [...tournament.applications, application], + }; + } + return { + ...tournament, + applications: tournament.applications.map( + a => a.user_id === application.user_id ? application : a, + ), + }; +}; + export const patchParticipant = (tournament, participant) => { if (!tournament) return tournament; if (!tournament.participants || !tournament.participants.length) { diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index 8b8c596..cde0d05 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -9,6 +9,21 @@ export const isSameUser = (user, subject) => user && subject && user.id === subj // Tournaments +export const isApplicant = (user, tournament) => { + if (!user || !tournament || !tournament.applications) { + return false; + } + return tournament.applications.find(p => p.user && p.user.id == user.id); +}; + +export const isDeniedApplicant = (user, tournament) => { + if (!user || !tournament || !tournament.applications) { + return false; + } + const applicant = tournament.applications.find(p => p.user && p.user.id == user.id); + return applicant && applicant.denied; +}; + export const isParticipant = (user, tournament) => user && tournament && tournament.participants && tournament.participants.find(p => p.user && p.user.id == user.id); @@ -39,6 +54,10 @@ export const mayAddRounds = (user, tournament) => !tournament.locked && (isRunner(user, tournament) || isTournamentAdmin(user, tournament)); +export const mayApply = (user, tournament) => + user && tournament && tournament.accept_applications && + !isRunner(user, tournament) && !isApplicant(user, tournament); + export const mayLockRound = (user, tournament) => !tournament.locked && isTournamentAdmin(user, tournament); diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 322d0f2..76a5973 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -35,6 +35,7 @@ export default { }, icon: { AddIcon: 'Hinzufügen', + ApplyIcon: 'Beantragen', DiscordIcon: 'Discord', EditIcon: 'Bearbeiten', FinishedIcon: 'Abgeschlossen', @@ -187,6 +188,11 @@ export default { }, tournaments: { admins: 'Organisation', + applicationDenied: 'Antrag wurde abgelehnt', + applicationPending: 'Antrag wurde abgeschickt', + apply: 'Beitreten', + applyError: 'Fehler beim Abschicken der Anfrage', + applySuccess: 'Anfrage gestellt', monitors: 'Monitore', noRecord: 'Turnier wird nicht gewertet', scoreboard: 'Scoreboard', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index acd3c48..0b526ba 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -35,6 +35,7 @@ export default { }, icon: { AddIcon: 'Add', + ApplyIcon: 'Apply', DiscordIcon: 'Discord', EditIcon: 'Edit', FinishedIcon: 'Finished', @@ -187,6 +188,11 @@ export default { }, tournaments: { admins: 'Admins', + applicationDenied: 'Application denied', + applicationPending: 'Application pending', + apply: 'Apply', + applyError: 'Error submitting application', + applySuccess: 'Application sent', monitors: 'Monitors', noRecord: 'Tournament set to not be recorded', scoreboard: 'Scoreboard', diff --git a/resources/sass/common.scss b/resources/sass/common.scss index 6a4be9f..56129ef 100644 --- a/resources/sass/common.scss +++ b/resources/sass/common.scss @@ -11,6 +11,19 @@ h1 { margin-bottom: 2rem; } +.button-bar { + > * { + margin-left: 0.5ex; + margin-right: 0.5ex; + } + > :first-child { + margin-left: 0; + } + > :last-child { + margin-right: 0; + } +} + .spoiler { border-radius: 0.5ex; padding: 0 0.5ex; diff --git a/routes/api.php b/routes/api.php index 6f6ef24..b9a4614 100644 --- a/routes/api.php +++ b/routes/api.php @@ -15,7 +15,7 @@ use Illuminate\Support\Facades\Route; */ Route::middleware('auth:sanctum')->get('/user', function (Request $request) { - return $request->user(); + return $request->user(); }); Route::get('protocol/{tournament}', 'App\Http\Controllers\ProtocolController@forTournament'); @@ -28,6 +28,7 @@ Route::post('rounds/{round}/setSeed', 'App\Http\Controllers\RoundController@setS Route::post('rounds/{round}/unlock', 'App\Http\Controllers\RoundController@unlock'); Route::get('tournaments/{id}', 'App\Http\Controllers\TournamentController@single'); +Route::post('tournaments/{tournament}/apply', 'App\Http\Controllers\TournamentController@apply'); Route::get('users/{id}', 'App\Http\Controllers\UserController@single'); Route::post('users/set-language', 'App\Http\Controllers\UserController@setLanguage'); -- 2.39.2