]> git.localhorst.tv Git - alttp.git/commitdiff
tournament application
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Sat, 9 Apr 2022 19:01:19 +0000 (21:01 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Sat, 9 Apr 2022 19:01:19 +0000 (21:01 +0200)
18 files changed:
app/Events/ApplicationAdded.php [new file with mode: 0644]
app/Http/Controllers/TournamentController.php
app/Models/Application.php [new file with mode: 0644]
app/Models/Tournament.php
app/Models/User.php
app/Policies/ApplicationPolicy.php [new file with mode: 0644]
app/Policies/TournamentPolicy.php
database/migrations/2022_04_09_120955_tournament_application.php [new file with mode: 0644]
resources/js/components/common/Icon.js
resources/js/components/pages/Tournament.js
resources/js/components/tournament/ApplyButton.js [new file with mode: 0644]
resources/js/components/tournament/Detail.js
resources/js/helpers/Tournament.js
resources/js/helpers/permissions.js
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/sass/common.scss
routes/api.php

diff --git a/app/Events/ApplicationAdded.php b/app/Events/ApplicationAdded.php
new file mode 100644 (file)
index 0000000..9de696e
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Events;
+
+use App\Models\Application;
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class ApplicationAdded implements ShouldBroadcast
+{
+       use Dispatchable, InteractsWithSockets, SerializesModels;
+
+       /**
+        * Create a new event instance.
+        *
+        * @return void
+        */
+       public function __construct(Application $application)
+       {
+               $this->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;
+
+}
index e563eac60bfb39c34a58e8da3da5a712a6ad14a6..9d62c23bac3475f2becac58caca85296756c68bc 100644 (file)
@@ -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 (file)
index 0000000..327d3fb
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class Application extends Model
+{
+
+       use HasFactory;
+
+       public function tournament() {
+               return $this->belongsTo(Tournament::class);
+       }
+
+       public function user() {
+               return $this->belongsTo(User::class);
+       }
+
+       protected $with = [
+               'user',
+       ];
+
+}
index 56ffe78f79c5dc1dc9b488f962b6c01f69bc6434..eea88096bdfc71da9a39115c2ed997a98a0afdb2 100644 (file)
@@ -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',
        ];
index 0c2dcf76b0aaaf307c4591f35dc6d2f618b9019e..ff45c6177c234acddda70e6b62493863fb48d151 100644 (file)
@@ -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 (file)
index 0000000..3dc9ad8
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+
+namespace App\Policies;
+
+use App\Models\Application;
+use App\Models\User;
+use Illuminate\Auth\Access\HandlesAuthorization;
+
+class ApplicationPolicy
+{
+       use HandlesAuthorization;
+
+       /**
+        * Determine whether the user can view any models.
+        *
+        * @param  \App\Models\User  $user
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function viewAny(User $user)
+       {
+               return $user->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;
+       }
+
+}
index 5942b7f7979c8ff215275b9e6a83d6853e8624b7..a95dc95001a5c5fe1453cf8680fe9ecb4d8cbfb8 100644 (file)
@@ -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 (file)
index 0000000..a63ff87
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+       /**
+        * Run the migrations.
+        *
+        * @return void
+        */
+       public function up()
+       {
+               Schema::create('applications', function (Blueprint $table) {
+                       $table->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');
+       }
+};
index 7f2f93f86f00d905e3b5ca62392785f3286b0408..607deb9b0af436e3525e3921bbea969163e58a6b 100644 (file)
@@ -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');
index bc1282e0586fdc351d626eb9ccd1707bdcd1ce8b..440e53af5803cddc65af01287617d47645c6383d 100644 (file)
@@ -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 (file)
index 0000000..bd944c9
--- /dev/null
@@ -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 <span className="d-inline-block" title={getTitle(user, tournament)}>
+               <Button
+                       disabled={!mayApply(user, tournament)}
+                       onClick={() => apply(tournament)}
+                       variant="primary"
+               >
+                       <Icon.APPLY title="" />
+               </Button>
+       </span>;
+};
+
+ApplyButton.propTypes = {
+       tournament: PropTypes.shape({
+               accept_applications: PropTypes.bool,
+               id: PropTypes.number,
+       }),
+       user: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(withUser(ApplyButton));
index b6afe902c5b3acc5fb0210424728e3a099602ab9..b20f2082c36ca2273584f55a1cb6890c194801fc 100644 (file)
@@ -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 = ({
                <Col lg={8} xl={9}>
                        <div className="d-flex align-items-center justify-content-between">
                                <h1>{tournament.title}</h1>
-                               {mayViewProtocol(user, tournament) ?
-                                       <Protocol id={tournament.id} />
-                               : null}
+                               <div className="button-bar">
+                                       <ApplyButton tournament={tournament} />
+                                       {mayViewProtocol(user, tournament) ?
+                                               <Protocol id={tournament.id} />
+                                       : null}
+                               </div>
                        </div>
                </Col>
        </Row>
index 7dbcb9b5b294386c8d4a725eeeacdcbb64b854da..25981d87fe0c8e11d58a82a342218d47a2dd2864 100644 (file)
@@ -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) {
index 8b8c596100270d6c32c4e2b2ff08d2220e96ba08..cde0d05d7fd293f7114b6d7553ffb1144a9ffaa6 100644 (file)
@@ -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);
 
index 322d0f2fd079765427a6dfe4049302338e9dc6a5..76a597332c2862c8b1fdb92d4be95b316785b1e7 100644 (file)
@@ -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',
index acd3c48ac532aaa0f86ee48566c14ac1885197d8..0b526ba743a2e2a010b1e386a2b963b20955d45d 100644 (file)
@@ -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',
index 6a4be9fcbc2df9f5aab09e5f0a8761435916fb34..56129efb4c6308bfbd01df8cd560d70962e0d868 100644 (file)
@@ -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;
index 6f6ef2471780b6d494c43e72aeda6c47e2021306..b9a4614eb322e8d12a629628c7c160b5dad52547 100644 (file)
@@ -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');