Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add contest judging system #10460

Merged
merged 96 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
96 commits
Select commit Hold shift + click to select a range
d8acced
contest judge system
venix12 Aug 6, 2023
a597bf9
remove unused import
venix12 Aug 6, 2023
dd5aa78
some lint fixes
venix12 Aug 6, 2023
60304fe
Merge branch 'ppy:master' into contest-judging
venix12 Aug 6, 2023
139454b
adjust scaffolding for sanitytest
venix12 Aug 10, 2023
329fd73
clean-up
venix12 Aug 10, 2023
3f81085
script type
venix12 Aug 10, 2023
5ff3a60
remove unused column
venix12 Aug 10, 2023
194159e
remove from properties as well
venix12 Aug 10, 2023
2217768
cleaning-up
venix12 Aug 10, 2023
574a692
merge select with multiplayer-select-options
venix12 Aug 13, 2023
b2ee35e
use id from route instead
venix12 Aug 13, 2023
7af5087
remove unused css modifier
venix12 Aug 13, 2023
45b5297
translation strings
venix12 Aug 13, 2023
f8e0353
put filteredEntries into a get instead
venix12 Aug 13, 2023
733bca6
private and readonly
venix12 Aug 13, 2023
d7c85fc
computed
venix12 Aug 13, 2023
46da87d
unify contest-judge css
venix12 Aug 13, 2023
97b61dd
better naming
venix12 Aug 13, 2023
222bfe0
remove unnecessary modifiers
venix12 Aug 13, 2023
f5f69f1
page_title keys
venix12 Aug 13, 2023
83b5794
make range-input into separate component
venix12 Aug 13, 2023
0000a00
licence header
venix12 Aug 13, 2023
9489057
missing comma
venix12 Aug 13, 2023
7fb745b
remove unncecessary color css
venix12 Aug 13, 2023
58ac84b
property-read ify
venix12 Sep 20, 2023
5ea9979
use new collection property syntax
venix12 Sep 20, 2023
61fb606
Merge branch 'master' into contest-judging
venix12 Sep 20, 2023
7e9ec03
oops
venix12 Sep 20, 2023
3f285ed
unnecessary type
venix12 Sep 22, 2023
bfc348a
review fixes
venix12 Sep 22, 2023
34fd397
Merge branch 'contest-judging' of https://github.com/venix12/osu-web …
venix12 Sep 22, 2023
3bd8589
add a link to the judging panel in voting page
venix12 Sep 22, 2023
1c8275b
add category descriptions
venix12 Sep 23, 2023
7fdf7fc
css variable clean-up
venix12 Sep 23, 2023
8ec451f
alphabetize
venix12 Sep 23, 2023
4a395fe
check if judging is active on OsuAuthorize level
venix12 Sep 23, 2023
b68a1dd
Merge branch 'master' into contest-judging
venix12 Nov 30, 2023
de8e1ef
Merge branch 'master' into contest-judging
venix12 Nov 30, 2023
b9d0c43
observable readonly
venix12 Nov 30, 2023
eccd4bf
Merge branch 'contest-judging' of https://github.com/venix12/osu-web …
venix12 Nov 30, 2023
31b92c1
add ``ContestJudge`` to ``alwaysCheck`` in authorize
venix12 Dec 5, 2023
64933fe
category vote -> score
venix12 Dec 7, 2023
52722c5
proper transformer name
venix12 Dec 7, 2023
c245958
score -> total_score
venix12 Dec 7, 2023
bb6c1bf
proper collection casing
venix12 Dec 7, 2023
81dca15
default value for range input
venix12 Dec 7, 2023
b6f60e3
use observable for disabled()
venix12 Dec 7, 2023
45dc6ed
use composite primary key on contest_judges
venix12 Dec 8, 2023
5c496f6
remove unneccessary indexes
venix12 Dec 8, 2023
ee3dfb3
check for form changes in disabled()
venix12 Dec 11, 2023
4886547
minor clean-up
venix12 Dec 11, 2023
f7dffa3
move range input to entry
venix12 Dec 11, 2023
758350d
use separate block for range input
venix12 Dec 11, 2023
5a329d2
put user contest judge participation on Contest instead
venix12 Dec 11, 2023
bec5ab2
proper admin permissions
venix12 Dec 11, 2023
8cfe595
css classes naming clean-up
venix12 Dec 11, 2023
fc5b845
add voted entry indication
venix12 Dec 11, 2023
6a48ff5
rename modifier
venix12 Dec 11, 2023
4e08b5c
eslint
venix12 Dec 11, 2023
fa01f21
further fixes
venix12 Dec 11, 2023
28c60c5
check if user exists before judge check
venix12 Dec 11, 2023
980ff21
alphabetize
venix12 Dec 11, 2023
161fde9
add isJudge check on Contest
venix12 Dec 14, 2023
31d9727
judge category -> scoring category
venix12 Dec 14, 2023
6c7dc42
alphabetize
venix12 Dec 14, 2023
3cc53de
update initialVote on submit
venix12 Dec 23, 2023
1d2d35d
separate the permissions
venix12 Dec 29, 2023
4651940
faster isJudge check
venix12 Dec 29, 2023
30af04b
ContestEntriesController cleaning-up
venix12 Dec 29, 2023
48f6b51
use one query for all judge votes counts
venix12 Jan 1, 2024
ca781a8
wrap contest judegs in divs
venix12 Jan 1, 2024
1204dc4
unnecessary array on modifiers
venix12 Jan 1, 2024
7b1e5d1
use subtypes for results
venix12 Jan 2, 2024
cce6ff3
load scoring categories on results only once
venix12 Jan 2, 2024
dace2ac
unneccessary with()
venix12 Jan 2, 2024
5fd6460
calculate max total score on backend
venix12 Jan 2, 2024
6bb71a9
lint cleanups
venix12 Jan 2, 2024
ae9fc31
Merge branch 'master' into contest-judging
venix12 Jan 2, 2024
e17d159
clean-up
venix12 Jan 2, 2024
716bb92
judge -> judging
venix12 Jan 19, 2024
6d83240
update outdated type
venix12 Jan 19, 2024
83ca29a
use separate class for current_user_judge_vote
venix12 Jan 26, 2024
8c1d501
expand store
venix12 Jan 26, 2024
9ef5a07
simplify authorize check
venix12 Jan 26, 2024
2a04346
use some() for disabled check
venix12 Jan 26, 2024
acb8e11
CurrentUserJudgeVote imrpovements & fixes
venix12 Jan 26, 2024
027ab3c
entries controller improvements
venix12 Jan 26, 2024
1644f2f
update contest-judge
venix12 Jan 26, 2024
eaed244
missing comma
venix12 Jan 26, 2024
66c3ab6
Merge branch 'master' into contest-judging
venix12 Jan 26, 2024
361ce23
don't allow contest_entry to be null
venix12 Jan 26, 2024
60441c9
clean-up disabled check
venix12 Jan 30, 2024
f3b82ba
misc review fixes
venix12 Jan 30, 2024
89feb58
redate migrations
notbakaneko Jan 31, 2024
17ebb94
Merge branch 'master' into contest-judging
notbakaneko Jan 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion app/Http/Controllers/Admin/ContestsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
namespace App\Http\Controllers\Admin;

use App\Models\Contest;
use App\Models\ContestJudgeVote;
use App\Models\DeletedUser;
use App\Models\UserContestEntry;
use ZipStream\ZipStream;
Expand All @@ -21,15 +22,26 @@ public function index()

public function show($id)
{
$contest = Contest::findOrFail($id);
$contest = Contest::with('judges')
->withCount('entries')
->findOrFail($id);

$entries = UserContestEntry::withTrashed()
->where('contest_id', $id)
->with('user')
->get();

if ($contest->isJudged()) {
$judgeVoteCounts = ContestJudgeVote::whereIn('contest_entry_id', $contest->entries()->pluck('id'))
->groupBy('user_id')
->selectRaw('COUNT(*) as judge_votes_count, user_id')
->get();
}

return ext_view('admin.contests.show', [
'contest' => $contest,
'entries' => json_collection($entries, 'UserContestEntry', ['user']),
'judgeVoteCounts' => $judgeVoteCounts ??= null,
]);
}

Expand Down
94 changes: 94 additions & 0 deletions app/Http/Controllers/ContestEntriesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,114 @@

namespace App\Http\Controllers;

use App\Exceptions\InvariantException;
use App\Models\Contest;
use App\Models\ContestEntry;
use App\Models\UserContestEntry;
use App\Transformers\ContestTransformer;
use Auth;
use Ds\Set;
use Illuminate\Support\Facades\DB;
use Request;

class ContestEntriesController extends Controller
{
public function judgeResults($id)
{
$entry = ContestEntry::with('contest')->findOrFail($id);

abort_if(!$entry->contest->isJudged() || !$entry->contest->show_votes, 404);
venix12 marked this conversation as resolved.
Show resolved Hide resolved

$entry->load([
'contest.entries',
'contest.scoringCategories',
'judgeVotes.scores',
'judgeVotes.user',
'user',
])->loadSum('scores', 'value');

$contest = $entry->contest
->loadCount('judges')
->loadSum('scoringCategories', 'max_value');

$contestJson = json_item(
$contest,
new ContestTransformer(),
[
'max_judging_score',
'max_total_score',
'scoring_categories',
],
);

$entryJson = json_item($entry, 'ContestEntry', [
'judge_votes.scores',
'judge_votes.total_score',
'judge_votes.user',
'results',
'user',
]);

$entriesJson = json_collection($entry->contest->entries, 'ContestEntry');

return ext_view('contest_entries.judge-results', [
'contestJson' => $contestJson,
'entryJson' => $entryJson,
'entriesJson' => $entriesJson,
]);
}

public function judgeVote($id)
{
$entry = ContestEntry::with('contest.scoringCategories')->findOrFail($id);

priv_check('ContestJudge', $entry->contest)->ensureCan();

$params = get_params(request()->all(), null, [
'scores:array',
'comment',
], ['null_missing' => true]);

$scoresByCategoryId = collect($params['scores'])
->keyBy('contest_scoring_category_id');

$expectedCategoryIds = new Set($entry->contest->scoringCategories->pluck('id'));
$givenCategoryIds = new Set($scoresByCategoryId->keys());

if ($expectedCategoryIds->diff($givenCategoryIds)->count() > 0) {
throw new InvariantException(osu_trans('contest.judge.validation.missing_score'));
}

DB::transaction(function () use ($entry, $params, $scoresByCategoryId) {
$vote = $entry->judgeVotes()->firstOrNew(['user_id' => Auth::user()->getKey()]);
$vote->fill(['comment' => $params['comment']])->save();

foreach ($entry->contest->scoringCategories as $category) {
$score = $scoresByCategoryId[$category->getKey()];
$value = clamp($score['value'], 0, $category->max_value);

$vote->scores()->firstOrNew([
'contest_judge_vote_id' => $vote->getKey(),
'contest_scoring_category_id' => $category->getKey(),
])->fill(['value' => $value])->save();
}
});

$updatedEntry = $entry->refresh()->load('judgeVotes.scores');

return json_item($updatedEntry, 'ContestEntry', ['current_user_judge_vote.scores']);
}

public function vote($id)
{
$user = Auth::user();
$entry = ContestEntry::findOrFail($id);
$contest = Contest::with('entries')->with('entries.contest')->findOrFail($entry->contest_id);

if ($contest->isJudged()) {
throw new InvariantException(osu_trans('contest.judge.validation.contest_vote_judged'));
}

priv_check('ContestVote', $contest)->ensureCan();

$contest->vote($user, $entry);
Expand Down
22 changes: 22 additions & 0 deletions app/Http/Controllers/ContestsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

use App\Exceptions\InvariantException;
use App\Models\Contest;
use App\Transformers\ContestTransformer;
use Auth;

class ContestsController extends Controller
Expand All @@ -24,6 +25,27 @@ public function index()
]);
}

public function judge($id)
{
$contest = Contest::with('entries.judgeVotes')
->with('entries.judgeVotes.scores')
->with('scoringCategories')
->findOrFail($id);

abort_if(!$contest->isJudged(), 404);

priv_check('ContestJudgeShow', $contest)->ensureCan();

$contestJson = json_item($contest, new ContestTransformer(), [
'entries.current_user_judge_vote.scores',
'scoring_categories',
]);

return ext_view('contests.judge', [
'contestJson' => $contestJson,
]);
}

public function show($id)
{
$contest = Contest::findOrFail($id);
Expand Down
34 changes: 34 additions & 0 deletions app/Libraries/OsuAuthorize.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public static function alwaysCheck($ability)
static $set;

$set ??= new Ds\Set([
'ContestJudge',
'IsOwnClient',
'IsNotOAuth',
'IsSpecialScope',
Expand Down Expand Up @@ -1330,6 +1331,39 @@ public function checkContestEntryDestroy(?User $user, UserContestEntry $contestE
return 'ok';
}

/**
* @param User|null $user
* @param Contest $contest
* @return string
* @throws AuthorizationCheckException
*/
public function checkContestJudge(?User $user, Contest $contest): string
{
$this->ensureLoggedIn($user);

if (!$contest->isJudgingActive()) {
return 'contest.judging_not_active';
}

if (!$contest->isJudge($user)) {
return 'unauthorized';
}

return 'ok';
}

/**
* @param User|null $user
* @param Contest $contest
* @return string
* @throws AuthorizationCheckException
*/
public function checkContestJudgeShow(?User $user, Contest $contest): string
venix12 marked this conversation as resolved.
Show resolved Hide resolved
{
// so that admins can show the panel but not vote (ContestJudge is alwaysCheck)
return $this->checkContestJudge($user, $contest);
}

/**
* @param User|null $user
* @param Contest $contest
Expand Down
43 changes: 40 additions & 3 deletions app/Models/Contest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,34 @@
use App\Transformers\UserContestEntryTransformer;
use Cache;
use Exception;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

/**
* @property \Carbon\Carbon|null $created_at
* @property string $description_enter
* @property string|null $description_voting
* @property \Illuminate\Database\Eloquent\Collection $entries ContestEntry
* @property-read Collection<ContestEntry> $entries
* @property \Carbon\Carbon|null $entry_ends_at
* @property mixed $thumbnail_shape
* @property \Carbon\Carbon|null $entry_starts_at
* @property json|null $extra_options
* @property string $header_url
* @property int $id
* @property mixed $link_icon
* @property-read Collection<ContestJudge> $judges
* @property int $max_entries
* @property int $max_votes
* @property string $name
* @property bool $show_votes
* @property mixed $type
* @property mixed $unmasked
* @property-read Collection<ContestScoringCategory> $scoringCategories
* @property bool $show_names
* @property \Carbon\Carbon|null $updated_at
* @property bool $visible
* @property \Illuminate\Database\Eloquent\Collection $votes ContestVote
* @property-read Collection<ContestVote> $votes
* @property \Carbon\Carbon|null $voting_ends_at
* @property \Carbon\Carbon|null $voting_starts_at
*/
Expand All @@ -57,6 +62,11 @@ public function entries()
return $this->hasMany(ContestEntry::class);
}

public function judges(): BelongsToMany
{
return $this->belongsToMany(User::class, ContestJudge::class);
}

public function userContestEntries()
{
return $this->hasMany(UserContestEntry::class);
Expand Down Expand Up @@ -110,6 +120,23 @@ public function isBestOf(): bool
return isset($this->getExtraOptions()['best_of']);
}

public function isJudge(User $user): bool
{
$judges = $this->judges();

return $judges->where($judges->qualifyColumn('user_id'), $user->getKey())->exists();
}

public function isJudged(): bool
{
return $this->getExtraOptions()['judged'] ?? false;
}

public function isJudgingActive(): bool
{
return $this->isJudged() && $this->isVotingStarted() && !$this->show_votes;
}

public function isSubmittedBeatmaps(): bool
{
return $this->isBestOf() || ($this->getExtraOptions()['submitted_beatmaps'] ?? false);
Expand All @@ -133,6 +160,11 @@ public function isVotingStarted()
return $this->voting_starts_at !== null && $this->voting_starts_at->isPast();
}

public function scoringCategories(): HasMany
{
return $this->hasMany(ContestScoringCategory::class);
}

public function state()
{
if ($this->entry_starts_at === null || $this->entry_starts_at->isFuture()) {
Expand Down Expand Up @@ -245,16 +277,21 @@ public function entriesByType($user = null, array $preloads = [])

if ($this->show_votes) {
return Cache::remember("contest_entries_with_votes_{$this->id}", 300, function () use ($entries) {
$orderValue = 'votes_count';

if ($this->isBestOf()) {
$entries = $entries
->selectRaw('*')
->selectRaw('(SELECT FLOOR(SUM(`weight`)) FROM `contest_votes` WHERE `contest_entries`.`id` = `contest_votes`.`contest_entry_id`) AS votes_count')
->limit(50); // best of contests tend to have a _lot_ of entries...
} else if ($this->isJudged()) {
$entries = $entries->withSum('scores', 'value');
$orderValue = 'scores_sum_value';
} else {
$entries = $entries->withCount('votes');
}

return $entries->orderBy('votes_count', 'desc')->get();
return $entries->orderBy($orderValue, 'desc')->get();
});
} else {
if ($this->isBestOf()) {
Expand Down
22 changes: 19 additions & 3 deletions app/Models/ContestEntry.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,43 @@

namespace App\Models;

use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;

/**
* @property Contest $contest
* @property-read Contest $contest
* @property int $contest_id
* @property \Carbon\Carbon|null $created_at
* @property string|null $entry_url
* @property string|null $thumbnail_url
* @property int $id
* @property-read Collection<ContestJudgeVote> $judgeVotes
* @property string $masked_name
* @property string $name
* @property-read Collection<ContestJudgeScore> $scores
* @property \Carbon\Carbon|null $updated_at
* @property User $user
* @property-read User $user
* @property int|null $user_id
* @property \Illuminate\Database\Eloquent\Collection $votes ContestVote
* @property-read Collection<ContestVote> $votes
*/
class ContestEntry extends Model
{
public function scores(): HasManyThrough
{
return $this->hasManyThrough(ContestJudgeScore::class, ContestJudgeVote::class);
}

public function contest()
{
return $this->belongsTo(Contest::class);
}

public function judgeVotes(): HasMany
{
return $this->hasMany(ContestJudgeVote::class);
}

public function user()
{
return $this->belongsTo(User::class, 'user_id');
Expand Down
Loading
Loading