add: upload contests

Add a modular base set-up that allows adding more different event types.
This one adds upload contests. More can be added the same way with minimal coding efforts.

Implementing a completely automated system may not be feasible, as there are too many individual variations and nuances to account for.
This commit is contained in:
Jay
2026-01-10 09:48:40 +00:00
committed by GitHub
parent 43ffc0abeb
commit a00c58200d
31 changed files with 2313 additions and 4 deletions
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Console\Commands;
use App\Notifications\NewUploadContestWinner;
use App\Models\Torrent;
use App\Models\UploadContest;
use App\Models\UploadContestWinner;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Throwable;
class AutoRewardUploadContestPrize extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'auto:reward_upload_contest_prize';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Automatically hands out rewards for upload contests';
/**
* Execute the console command.
*
* @throws Exception|Throwable If there is an error during the execution of the command.
*/
final public function handle(): void
{
// Get all active upload contests
$activeUploadContests = UploadContest::where('ends_at', '<', now()->toDateString())
->where('awarded', '=', 0)
->get();
foreach ($activeUploadContests as $activeUploadContest) {
// Get the amount of competitors to reward based on prizes for the event
$numRewards = $activeUploadContest->prizes->count();
// Get prizes ordered by position
$rewards = $activeUploadContest->prizes->sortBy('position');
// Get top N competitors
$winners = Torrent::query()
->with('user.group')
->where('anon', '=', false)
->select(DB::raw('user_id, count(*) as uploads, max(created_at) as last_upload'))
->where('created_at', '>=', $activeUploadContest->starts_at->startOfDay())
->where('created_at', '<=', $activeUploadContest->ends_at->endOfDay())
->groupBy('user_id')
->orderByDesc('uploads')
->orderBy('last_upload')
->limit($numRewards)
->get();
foreach ($winners as $i => $winner) {
$rewardsByPosition = $rewards->where('position', $i + 1);
foreach ($rewardsByPosition as $reward) {
// Reward prize
if ($reward->type === 'bon') {
$winner->user->increment('seedbonus', $reward->amount);
}
if ($reward->type === 'fl_tokens') {
$winner->user->increment('fl_tokens', $reward->amount);
}
}
// Persist the winners
UploadContestWinner::create([
'upload_contest_id' => $activeUploadContest->id,
'user_id' => $winner->user->id,
'place_number' => $i + 1,
/** @phpstan-ignore property.notFound (Uploads is not part of the torrents table.) */
'uploads' => $winner->uploads,
]);
// Send notification
$winner->user->notify(new NewUploadContestWinner($activeUploadContest));
}
// Set upload contest as awarded
$activeUploadContest->update([
'awarded' => true,
]);
}
$this->comment('Automated reward upload contest command complete');
}
}
+2
View File
@@ -41,6 +41,7 @@ use App\Console\Commands\AutoRemoveReseeds;
use App\Console\Commands\AutoRemoveTimedTorrentBuffs;
use App\Console\Commands\AutoResetUserFlushes;
use App\Console\Commands\AutoRewardResurrection;
use App\Console\Commands\AutoRewardUploadContestPrize;
use App\Console\Commands\AutoSoftDeleteDisabledUsers;
use App\Console\Commands\AutoSyncPeopleToMeilisearch;
use App\Console\Commands\AutoSyncTorrentsToMeilisearch;
@@ -109,6 +110,7 @@ class Kernel extends ConsoleKernel
$schedule->command(AutoSyncPeopleToMeilisearch::class)->daily();
$schedule->command(AutoRemoveExpiredDonors::class)->daily();
$schedule->command(AutoRemoveReseeds::class)->daily();
$schedule->command(AutoRewardUploadContestPrize::class)->daily();
// $schedule->command(AutoBanDisposableUsers::class)->weekends();
$schedule->command(CleanupCommand::class)->daily();
$schedule->command(BackupCommand::class, ['--only-db'])->daily();
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Http\Controllers\Staff;
use App\Http\Controllers\Controller;
use App\Http\Requests\Staff\StoreUploadContestRequest;
use App\Http\Requests\Staff\UpdateUploadContestRequest;
use App\Models\UploadContest;
class UploadContestController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
return view('Staff.upload-contest.index', [
'uploadContests' => UploadContest::all(),
]);
}
/**
* Show the form for creating a new resource.
*/
public function create(): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
return view('Staff.upload-contest.create');
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreUploadContestRequest $request): \Illuminate\Http\RedirectResponse
{
UploadContest::create([
'active' => 0,
'awarded' => 0,
] + $request->validated());
return to_route('staff.upload_contests.index');
}
/**
* Show the form for editing the specified resource.
*/
public function edit(UploadContest $uploadContest): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
return view('Staff.upload-contest.edit', [
'uploadContest' => $uploadContest->load('prizes'),
'prizes' => $uploadContest->prizes->sortBy('position'),
]);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateUploadContestRequest $request, UploadContest $uploadContest): \Illuminate\Http\RedirectResponse
{
$uploadContest->update($request->validated());
return to_route('staff.upload_contests.index');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(UploadContest $uploadContest): \Illuminate\Http\RedirectResponse
{
$uploadContest->delete();
return to_route('staff.upload_contests.index');
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Http\Controllers\Staff;
use App\Http\Controllers\Controller;
use App\Http\Requests\Staff\StoreUploadContestPrizeRequest;
use App\Http\Requests\Staff\UpdateUploadContestPrizeRequest;
use App\Models\UploadContest;
use App\Models\UploadContestPrize;
class UploadContestPrizeController extends Controller
{
/**
* Store a newly created resource in storage.
*/
public function store(StoreUploadContestPrizeRequest $request, UploadContest $uploadContest): \Illuminate\Http\RedirectResponse
{
$uploadContest->prizes()->create($request->validated());
return to_route('staff.upload_contests.edit', [
'uploadContest' => $uploadContest
])
->with('success', 'Prize added to upload contest.');
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateUploadContestPrizeRequest $request, UploadContest $uploadContest, UploadContestPrize $prize): \Illuminate\Http\RedirectResponse
{
$prize->update($request->validated());
return to_route('staff.upload_contests.edit', [
'uploadContest' => $uploadContest
])
->with('success', 'Prize updated.');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(UploadContest $uploadContest, UploadContestPrize $prize): \Illuminate\Http\RedirectResponse
{
$prize->delete();
return to_route('staff.upload_contests.edit', [
'uploadContest' => $uploadContest
])
->with('success', 'Prize removed from upload contest.');
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Http\Controllers;
use App\Models\Torrent;
use App\Models\UploadContest;
use App\Models\UploadContestWinner;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class UploadContestController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
return view('upload-contest.index', [
'uploadContests' => UploadContest::query()->orderBy('starts_at')->get(),
]);
}
/**
* Display the specified resource.
*/
public function show(Request $request, UploadContest $uploadContest): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
if (! $uploadContest->awarded) {
// Fetch the live data
$uploaders = Torrent::query()
->with('user.group')
->where('anon', '=', false)
->select(DB::raw('user_id, count(*) as uploads, max(created_at) as last_upload'))
->where('created_at', '>=', $uploadContest->starts_at->startOfDay())
->where('created_at', '<=', $uploadContest->ends_at->endOfDay())
->groupBy('user_id')
->orderByDesc('uploads')
->orderBy('last_upload')
->take(25)
->get();
} else {
// Fetch the persisted history winners
$uploaders = UploadContestWinner::query()
->with('user.group')
->where('upload_contest_id', $uploadContest->id)
->orderBy('place_number')
->get();
}
return view('upload-contest.show', [
'uploadContest' => $uploadContest,
'uploaders' => $uploaders,
]);
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Http\Requests\Staff;
use Illuminate\Foundation\Http\FormRequest;
class StoreUploadContestPrizeRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'type' => [
'required',
'string',
'in:bon,fl_tokens',
],
'amount' => [
'required',
'integer',
'min:0',
],
'position' => [
'required',
'integer',
'min:1',
],
];
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Http\Requests\Staff;
use Illuminate\Foundation\Http\FormRequest;
class StoreUploadContestRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
],
'description' => [
'required',
'string',
'max:65535',
],
'icon' => [
'required',
'string',
'max:255',
],
'starts_at' => [
'required',
'date',
],
'ends_at' => [
'required',
'date',
],
];
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Roardom <roardom@protonmail.com>
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Http\Requests\Staff;
use Illuminate\Foundation\Http\FormRequest;
class UpdateUploadContestPrizeRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'type' => [
'required',
'string',
'in:bon,fl_tokens',
],
'amount' => [
'required',
'integer',
'min:0',
],
'position' => [
'required',
'integer',
'min:0',
],
];
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Http\Requests\Staff;
use Illuminate\Foundation\Http\FormRequest;
class UpdateUploadContestRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
],
'description' => [
'required',
'string',
'max:65535',
],
'icon' => [
'required',
'string',
'max:255',
],
'starts_at' => [
'required',
'date',
],
'ends_at' => [
'required',
'date',
],
'active' => [
'required',
'boolean',
],
];
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Models;
use App\Traits\Auditable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use AllowDynamicProperties;
/**
* App\Models\UploadContest.
*
* @property int $id
* @property string $name
* @property string $description
* @property string $icon
* @property bool $active
* @property bool $awarded
* @property \Illuminate\Support\Carbon|null $starts_at
* @property \Illuminate\Support\Carbon|null $ends_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
*/
#[AllowDynamicProperties]
final class UploadContest extends Model
{
use Auditable;
/** @use HasFactory<\Database\Factories\UploadContestFactory> */
use HasFactory;
/**
* The attributes that aren't mass assignable.
*
* @var string[]
*/
protected $guarded = [];
/**
* Get the attributes that should be cast.
*
* @return array{starts_at: 'datetime', ends_at: 'datetime', active: 'bool'}
*/
protected function casts(): array
{
return [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'active' => 'bool',
'awarded' => 'bool',
];
}
/**
* Get the available prizes for the upload contest.
*
* @return HasMany<UploadContestPrize, $this>
*/
public function prizes(): HasMany
{
return $this->hasMany(UploadContestPrize::class);
}
/**
* Get the winners for the upload contest.
*
* @return HasMany<UploadContestWinner, $this>
*/
public function winners(): HasMany
{
return $this->hasMany(UploadContestWinner::class);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use AllowDynamicProperties;
/**
* App\Models\UploadContestPrize.
*
* @property int $id
* @property int $upload_contest_id
* @property string $type
* @property int $amount
* @property int $position
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
*/
#[AllowDynamicProperties]
final class UploadContestPrize extends Model
{
/** @use HasFactory<\Database\Factories\UploadContestPrizeFactory> */
use HasFactory;
/**
* The attributes that aren't mass assignable.
*
* @var string[]
*/
protected $guarded = [];
/**
* Get the UploadContest that owns the prize.
*
* @return BelongsTo<UploadContest, $this>
*/
public function contest(): BelongsTo
{
return $this->belongsTo(UploadContest::class);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use AllowDynamicProperties;
/**
* App\Models\UploadContestPrize.
*
* @property int $id
* @property int $upload_contest_id
* @property int $user_id
* @property int $place_number
* @property int $uploads
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
*/
#[AllowDynamicProperties]
final class UploadContestWinner extends Model
{
/** @use HasFactory<\Database\Factories\UploadContestWinnerFactory> */
use HasFactory;
/**
* The attributes that aren't mass assignable.
*
* @var string[]
*/
protected $guarded = [];
/**
* Get the UploadContest.
*
* @return BelongsTo<UploadContest, $this>
*/
public function contest(): BelongsTo
{
return $this->belongsTo(UploadContest::class);
}
/**
* Get the user.
*
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withDefault([
'username' => 'System',
'id' => User::SYSTEM_USER_ID,
]);
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Notifications;
use App\Models\User;
use App\Models\UploadContest;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class NewUploadContestWinner extends Notification implements ShouldQueue
{
use Queueable;
/**
* NewUploadContestWinner Constructor.
*/
public function __construct(public UploadContest $uploadContest)
{
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* Determine if the notification should be sent.
*/
public function shouldSend(User $notifiable): bool
{
return ! ($notifiable->notification?->block_notifications === 1)
;
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'title' => 'You have won an upload contest!',
'body' => 'Your awards have been added to your account',
'url' => route('upload_contests.show', ['uploadContest' => $this->uploadContest]),
];
}
}
+4
View File
@@ -12,6 +12,7 @@ use App\Models\Report;
use App\Models\Scopes\ApprovedScope;
use App\Models\Ticket;
use App\Models\Torrent;
use App\Models\UploadContest;
use Illuminate\View\View;
class TopNavComposer
@@ -53,6 +54,9 @@ class TopNavComposer
->where('user_id', '=', $user->id),
])
->get(),
'uploadContests' => UploadContest::query()
->where('active', '=', true)
->get(),
'donationPercentage' => value(function (): int|string {
$sum = Donation::query()
->join('donation_packages', 'donations.package_id', '=', 'donation_packages.id')
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace Database\Factories;
use App\Models\UploadContest;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<UploadContest> */
class UploadContestFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*/
protected $model = UploadContest::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'name' => $this->faker->sentence(3),
'description' => $this->faker->paragraph(),
'icon' => $this->faker->imageUrl(),
'active' => $this->faker->boolean(),
'awarded' => $this->faker->boolean(),
'starts_at' => $this->faker->dateTimeBetween('-1 month', '+1 month'),
'ends_at' => $this->faker->dateTimeBetween('+1 month', '+2 months'),
];
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author HDVinnie <hdinnovations@protonmail.com>
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace Database\Factories;
use App\Models\UploadContest;
use App\Models\UploadContestPrize;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<UploadContestPrize> */
class UploadContestPrizeFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*/
protected $model = UploadContestPrize::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
$types = ['fl_tokens', 'bon'];
return [
'upload_contest_id' => UploadContest::factory(),
'type' => $this->faker->randomElement($types),
'amount' => $this->faker->numberBetween(1, 1000),
'position' => $this->faker->numberBetween(1, 10),
];
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace Database\Factories;
use App\Models\UploadContest;
use App\Models\UploadContestWinner;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<UploadContestWinner> */
class UploadContestWinnerFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*/
protected $model = UploadContestWinner::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
$types = ['fl_tokens', 'bon'];
return [
'upload_contest_id' => UploadContest::factory(),
'user_id' => User::factory(),
'place_number' => $this->faker->numberBetween(1, 10),
'uploads' => $this->faker->numberBetween(1, 100),
];
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('upload_contests', function (Blueprint $table): void {
$table->increments('id');
$table->string('name');
$table->text('description');
$table->string('icon');
$table->boolean('active');
$table->boolean('awarded');
$table->date('starts_at');
$table->date('ends_at');
$table->timestamps();
});
Schema::create('upload_contest_prizes', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('upload_contest_id');
$table->string('type');
$table->unsignedInteger('amount');
$table->unsignedInteger('position');
$table->foreign('upload_contest_id')->references('id')->on('upload_contests');
$table->timestamps();
});
Schema::create('upload_contest_winners', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('upload_contest_id');
$table->unsignedInteger('user_id');
$table->unsignedInteger('place_number');
$table->unsignedInteger('uploads');
$table->foreign('upload_contest_id')->references('id')->on('upload_contests');
$table->foreign('user_id')->references('id')->on('users')->cascadeOnUpdate();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('upload_contest_winners');
Schema::dropIfExists('upload_contest_prizes');
Schema::dropIfExists('upload_contests');
}
};
+56 -4
View File
@@ -2246,6 +2246,57 @@ CREATE TABLE `unregistered_info_hashes` (
CONSTRAINT `unregistered_info_hashes_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `upload_contest_prizes`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `upload_contest_prizes` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`upload_contest_id` int unsigned NOT NULL,
`type` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`amount` int unsigned NOT NULL,
`position` int unsigned NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `upload_contest_prizes_upload_contest_id_foreign` (`upload_contest_id`),
CONSTRAINT `upload_contest_prizes_upload_contest_id_foreign` FOREIGN KEY (`upload_contest_id`) REFERENCES `upload_contests` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `upload_contest_winners`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `upload_contest_winners` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`upload_contest_id` int unsigned NOT NULL,
`user_id` int unsigned NOT NULL,
`place_number` int unsigned NOT NULL,
`uploads` int unsigned NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `upload_contest_winners_upload_contest_id_foreign` (`upload_contest_id`),
KEY `upload_contest_winners_user_id_foreign` (`user_id`),
CONSTRAINT `upload_contest_winners_upload_contest_id_foreign` FOREIGN KEY (`upload_contest_id`) REFERENCES `upload_contests` (`id`),
CONSTRAINT `upload_contest_winners_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `upload_contests`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `upload_contests` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`description` text COLLATE utf8mb4_unicode_ci NOT NULL,
`icon` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`active` tinyint(1) NOT NULL,
`awarded` tinyint(1) NOT NULL,
`starts_at` date NOT NULL,
`ends_at` date NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `user_audibles`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
@@ -3047,7 +3098,8 @@ INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (364,'2025_09_08_00
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (365,'2025_09_25_110038_alter_reports_create_assignee',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (366,'2025_11_08_094209_rename_warnings_torrent_to_torrent_id',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (367,'2025_11_18_080804_echoes_audibles_unique_keys',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (368,'2025_11_29_101934_update_events_rename_to_giveaways',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (369,'2026_01_06_231535_remove_unnecessary_bigints',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (370,'2026_01_07_040502_mark_columns_as_unsigned',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (371,'2026_01_09_015532_alter_table_reports_make_verdict_nullable',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (368,'2025_11_22_121612_create_upload_events_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (369,'2025_11_29_101934_update_events_rename_to_giveaways',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (370,'2026_01_06_231535_remove_unnecessary_bigints',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (371,'2026_01_07_040502_mark_columns_as_unsigned',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (372,'2026_01_09_015532_alter_table_reports_make_verdict_nullable',1);
+5
View File
@@ -34,6 +34,7 @@ return [
'article' => 'Article',
'ascending' => 'Ascending',
'author' => 'Author',
'awarded' => 'Awarded',
'balance' => 'Balance',
'blacklist' => 'Client blacklist',
'bookmarked' => 'Bookmarked',
@@ -58,6 +59,8 @@ return [
'contact' => 'Contact',
'contact-desc' => 'This contact request will be sent to the owner and will get back to you as soon as possible',
'contact-header' => 'Hello',
'contest' => 'Contest',
'contests' => 'Contests',
'create' => 'Create',
'created_at' => 'Created at',
'date' => 'Date',
@@ -80,6 +83,7 @@ return [
'email-whitelist' => 'Email whitelist',
'email-list-notactive' => 'Email whitelist / blacklist system is not activated',
'enable' => 'Enable',
'ends-at' => 'Ends at',
'enter' => 'Enter',
'error' => 'Error',
'everyday' => 'Everyday',
@@ -196,6 +200,7 @@ return [
'sponsor' => 'Become a sponsor',
'staff' => 'Staff',
'staff-tools' => 'Staff tools',
'starts-at' => 'Starts at',
'stats' => 'Stats',
'status' => 'Status',
'sticked' => 'Sticked',
@@ -168,6 +168,15 @@
{{ __('event.giveaways') }}
</a>
</p>
<p class="form__group form__group--horizontal">
<a
class="form__button form__button--text"
href="{{ route('staff.upload_contests.index') }}"
>
<i class="{{ config('other.font-awesome') }} fa-calendar-star"></i>
{{ __('common.upload') }} {{ __('common.contests') }}
</a>
</p>
<p class="form__group form__group--horizontal">
<a
class="form__button form__button--text"
@@ -0,0 +1,95 @@
@extends('layout.with-main')
@section('breadcrumbs')
<li class="breadcrumbV2">
<a href="{{ route('staff.dashboard.index') }}" class="breadcrumb__link">
{{ __('staff.staff-dashboard') }}
</a>
</li>
<li class="breadcrumbV2">
<a href="{{ route('staff.upload_contests.index') }}" class="breadcrumb__link">
{{ __('common.upload') }} {{ __('common.contests') }}
</a>
</li>
<li class="breadcrumb--active">
{{ __('common.new-adj') }}
</li>
@endsection
@section('page', 'page__staff-upload-contest--create')
@section('main')
<section class="panelV2">
<h2 class="panel__heading">{{ __('common.add') }} {{ __('common.contest') }}</h2>
<form
class="dialog__form"
method="POST"
action="{{ route('staff.upload_contests.store') }}"
>
@csrf
<p class="form__group">
<input
id="name"
class="form__text"
type="text"
autocomplete="off"
name="name"
required
/>
<label class="form__label form__label--floating" for="name">
{{ __('common.name') }}
</label>
</p>
<p class="form__group">
<textarea
id="description"
class="form__textarea"
name="description"
required
></textarea>
<label class="form__label form__label--floating" for="description">
{{ __('common.description') }}
</label>
</p>
<p class="form__group">
<input
id="icon"
class="form__text"
type="text"
autocomplete="off"
name="icon"
required
/>
<label class="form__label form__label--floating" for="icon">
{{ __('common.icon') }}
</label>
</p>
<div class="form__group--horizontal">
<p class="form__group">
<input id="starts_at" class="form__text" name="starts_at" type="date" />
<label class="form__label form__label--floating" for="starts_at">
{{ __('common.starts-at') }}
</label>
</p>
<p class="form__group">
<input id="ends_at" class="form__text" name="ends_at" type="date" />
<label class="form__label form__label--floating" for="ends_at">
{{ __('common.ends-at') }}
</label>
</p>
</div>
<p class="form__group">
<button class="form__button form__button--filled" wire:click="store">
{{ __('common.save') }}
</button>
<button
formmethod="dialog"
formnovalidate
class="form__button form__button--outlined"
>
{{ __('common.cancel') }}
</button>
</p>
</form>
</section>
@endsection
@@ -0,0 +1,365 @@
@extends('layout.with-main')
@section('breadcrumbs')
<li class="breadcrumbV2">
<a href="{{ route('staff.dashboard.index') }}" class="breadcrumb__link">
{{ __('staff.staff-dashboard') }}
</a>
</li>
<li class="breadcrumbV2">
<a href="{{ route('staff.upload_contests.index') }}" class="breadcrumb__link">
{{ __('common.upload') }} {{ __('common.contests') }}
</a>
</li>
<li class="breadcrumbV2">
{{ $uploadContest->name }}
</li>
<li class="breadcrumb--active">
{{ __('common.edit') }}
</li>
@endsection
@section('page', 'page__staff-upload-contest--edit')
@section('main')
<section class="panelV2">
<h2 class="panel__heading">{{ __('common.edit') }} {{ __('common.contest') }}</h2>
<form
class="dialog__form"
method="POST"
action="{{ route('staff.upload_contests.update', ['uploadContest' => $uploadContest]) }}"
>
@csrf
@method('PATCH')
<p class="form__group">
<input
id="name"
class="form__text"
type="text"
autocomplete="off"
name="name"
required
value="{{ $uploadContest->name }}"
/>
<label class="form__label form__label--floating" for="name">
{{ __('common.name') }}
</label>
</p>
<p class="form__group">
<textarea id="description" class="form__textarea" name="description" required>
{{ $uploadContest->description }}</textarea
>
<label class="form__label form__label--floating" for="description">
{{ __('common.description') }}
</label>
</p>
<p class="form__group">
<input
id="icon"
class="form__text"
type="text"
autocomplete="off"
name="icon"
required
value="{{ $uploadContest->icon }}"
/>
<label class="form__label form__label--floating" for="icon">
{{ __('common.icon') }}
</label>
</p>
<div class="form__group--horizontal">
<p class="form__group">
<input
id="starts_at"
class="form__text"
name="starts_at"
type="date"
value="{{ $uploadContest->starts_at->format('Y-m-d') }}"
required
/>
<label class="form__label form__label--floating" for="starts_at">
{{ __('common.starts-at') }}
</label>
</p>
<p class="form__group">
<input
id="ends_at"
class="form__text"
name="ends_at"
type="date"
value="{{ $uploadContest->ends_at->format('Y-m-d') }}"
required
/>
<label class="form__label form__label--floating" for="ends_at">
{{ __('common.ends-at') }}
</label>
</p>
</div>
<p class="form__group">
<input type="hidden" name="active" value="0" />
<input
type="checkbox"
class="form__checkbox"
id="active"
name="active"
value="1"
@checked($uploadContest->active)
/>
<label class="form__label" for="active">{{ __('common.active') }}?</label>
</p>
<p class="form__group">
<button class="form__button form__button--filled" wire:click="store">
{{ __('common.save') }}
</button>
<button
formmethod="dialog"
formnovalidate
class="form__button form__button--outlined"
>
{{ __('common.cancel') }}
</button>
</p>
</form>
</section>
<section class="panelV2">
<header class="panel__header">
<h2 class="panel__heading">{{ __('contest.prizes') }}</h2>
<div class="panel__actions">
<div class="panel__action" x-data="dialog">
<button class="form__button form__button--text" x-bind="showDialog">
{{ __('common.add') }}
</button>
<dialog class="dialog" x-bind="dialogElement">
<h3 class="dialog__heading">{{ __('event.add-prize') }}</h3>
<form
class="dialog__form"
method="POST"
action="{{ route('staff.upload_contests.prizes.store', ['uploadContest' => $uploadContest]) }}"
x-bind="dialogForm"
>
@csrf
<input
type="hidden"
name="contest_id"
value="{{ $uploadContest->id }}"
/>
<p class="form__group">
<select name="type" id="type" class="form__select" required>
<option hidden disabled selected value=""></option>
<option value="bon">{{ __('bon.bon') }}</option>
<option value="fl_tokens">{{ __('common.fl_tokens') }}</option>
</select>
<label class="form__label form__label--floating" for="type">
{{ __('common.type') }}
</label>
</p>
<p class="form__group">
<input
id="amount"
class="form__text"
inputmode="numeric"
name="amount"
pattern="[0-9]*"
placeholder=" "
required
type="text"
/>
<label class="form__label form__label--floating" for="amount">
{{ __('common.amount') }}
</label>
</p>
<p class="form__group">
<input
id="position"
class="form__text"
inputmode="numeric"
name="position"
pattern="[0-9]*"
placeholder=" "
required
type="text"
/>
<label class="form__label form__label--floating" for="position">
{{ __('common.position') }}
</label>
</p>
<p class="form__group">
<button class="form__button form__button--filled">
{{ __('common.add') }}
</button>
<button
formmethod="dialog"
formnovalidate
class="form__button form__button--outlined"
>
{{ __('common.cancel') }}
</button>
</p>
</form>
</dialog>
</div>
</div>
</header>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<th>{{ __('common.position') }}</th>
<th>{{ __('common.type') }}</th>
<th>{{ __('common.amount') }}</th>
<th>{{ __('common.actions') }}</th>
</thead>
<tbody>
@forelse ($prizes as $prize)
<tr>
<td>{{ $prize->position }}</td>
<td>
@switch($prize->type)
@case('bon')
{{ __('bon.bon') }}
@break
@case('fl_tokens')
{{ __('common.fl_tokens') }}
@break
@endswitch
</td>
<td>{{ $prize->amount }}</td>
<td>
<menu class="data-table__actions">
<li class="data-table__action" x-data="dialog">
<button
class="form__button form__button--text"
x-bind="showDialog"
>
{{ __('common.edit') }}
</button>
<dialog class="dialog" x-bind="dialogElement">
<h3 class="dialog__heading">
{{ __('event.edit-prize') }}
</h3>
<form
class="dialog__form"
method="POST"
action="{{ route('staff.upload_contests.prizes.update', ['uploadContest' => $uploadContest, 'prize' => $prize]) }}"
x-bind="dialogForm"
>
@csrf
@method('PATCH')
<input
type="hidden"
name="contest_id"
value="{{ $uploadContest->id }}"
/>
<p class="form__group">
<select
name="type"
id="type"
class="form__select"
required
>
<option
value="bon"
@selected($uploadContest->type === 'bon')
>
{{ __('bon.bon') }}
</option>
<option
value="fl_tokens"
@selected($uploadContest->type === 'fl_tokens')
>
{{ __('common.fl_tokens') }}
</option>
</select>
<label
class="form__label form__label--floating"
for="type"
>
{{ __('common.type') }}
</label>
</p>
<p class="form__group">
<input
id="amount"
class="form__text"
inputmode="numeric"
name="amount"
pattern="[0-9]*"
placeholder=" "
required
type="text"
value="{{ $prize->amount }}"
/>
<label
class="form__label form__label--floating"
for="min"
>
{{ __('common.amount') }}
</label>
</p>
<p class="form__group">
<input
id="position"
class="form__text"
inputmode="numeric"
name="position"
pattern="[0-9.]*"
placeholder=" "
required
type="text"
value="{{ $prize->position }}"
/>
<label
class="form__label form__label--floating"
for="position"
>
{{ __('common.position') }}
</label>
</p>
<p class="form__group">
<button
class="form__button form__button--filled"
>
{{ __('common.edit') }}
</button>
<button
formmethod="dialog"
formnovalidate
class="form__button form__button--outlined"
>
{{ __('common.cancel') }}
</button>
</p>
</form>
</dialog>
</li>
<li class="data-table__action">
<form
action="{{ route('staff.upload_contests.prizes.destroy', ['uploadContest' => $uploadContest, 'prize' => $prize]) }}"
method="POST"
x-data="confirmation"
>
@csrf
@method('DELETE')
<button
x-on:click.prevent="confirmAction"
class="form__button form__button--text"
data-b64-deletion-message="{{ base64_encode('Are you sure you want to remove this prize (Type: ' . $prize->type . ', Amount: ' . $prize->amount . ') from this contest (.' . $uploadContest->name . ')?') }}"
>
{{ __('common.delete') }}
</button>
</form>
</li>
</menu>
</td>
</tr>
@empty
<tr>
<td colspan="4">{{ __('event.no-prizes') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</section>
@endsection
@@ -0,0 +1,124 @@
@extends('layout.with-main')
@section('breadcrumbs')
<li class="breadcrumbV2">
<a href="{{ route('staff.dashboard.index') }}" class="breadcrumb__link">
{{ __('staff.staff-dashboard') }}
</a>
</li>
<li class="breadcrumb--active">{{ __('common.upload') }} {{ __('common.contests') }}</li>
@endsection
@section('page', 'page__staff-upload-contest--index')
@section('main')
<section class="panelV2">
<header class="panel__header">
<h2 class="panel__heading">{{ __('common.upload') }} {{ __('common.contests') }}</h2>
<div class="panel__actions">
<div class="panel__action">
<a
class="form__button form__button--text"
href="{{ route('staff.upload_contests.create') }}"
>
{{ __('common.add') }}
</a>
</div>
</div>
</header>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>{{ __('common.name') }}</th>
<th>{{ __('common.starts-at') }}</th>
<th>{{ __('common.ends-at') }}</th>
<th>{{ __('common.active') }}</th>
<th>{{ __('common.awarded') }}</th>
<th>{{ __('common.actions') }}</th>
</tr>
</thead>
<tbody>
@foreach ($uploadContests as $uploadContest)
<tr>
<td>
<a
href="{{ route('staff.upload_contests.edit', ['uploadContest' => $uploadContest]) }}"
>
{{ $uploadContest->name }}
</a>
</td>
<td>
<time
datetime="{{ $uploadContest->starts_at }}"
title="{{ $uploadContest->starts_at }}"
>
{{ $uploadContest->starts_at->format('Y-m-d') }}
</time>
</td>
<td>
<time
datetime="{{ $uploadContest->ends_at }}"
title="{{ $uploadContest->ends_at }}"
>
{{ $uploadContest->ends_at->format('Y-m-d') }}
</time>
</td>
<td>
@if ($uploadContest->active)
<i
class="{{ config('other.font-awesome') }} fa-check text-green"
></i>
@else
<i
class="{{ config('other.font-awesome') }} fa-times text-red"
></i>
@endif
</td>
<td>
@if ($uploadContest->awarded)
<i
class="{{ config('other.font-awesome') }} fa-check text-green"
></i>
@else
<i
class="{{ config('other.font-awesome') }} fa-times text-red"
></i>
@endif
</td>
<td>
<menu class="data-table__actions">
<li class="data-table__action">
<a
href="{{ route('staff.upload_contests.edit', ['uploadContest' => $uploadContest]) }}"
class="form__button form__button--text"
>
{{ __('common.edit') }}
</a>
</li>
<li class="data-table__action">
<form
action="{{ route('staff.upload_contests.destroy', ['uploadContest' => $uploadContest]) }}"
method="POST"
x-data="confirmation"
>
@csrf
@method('DELETE')
<button
x-on:click.prevent="confirmAction"
data-b64-deletion-message="{{ base64_encode('Are you sure you want to delete this upload contest: ' . $uploadContest->name . '?') }}"
class="form__button form__button--text"
>
{{ __('common.delete') }}
</button>
</form>
</li>
</menu>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</section>
@endsection
@@ -183,6 +183,19 @@
</li>
@endforeach
@foreach ($uploadContests as $uploadContest)
<li>
<a
href="{{ route('upload_contests.show', ['uploadContest' => $uploadContest]) }}"
>
<i
class="{{ config('other.font-awesome') }} {{ $uploadContest->icon }}"
></i>
{{ $uploadContest->name }}
</a>
</li>
@endforeach
<li>
<a href="{{ route('subtitles.index') }}">
<i class="{{ config('other.font-awesome') }} fa-closed-captioning"></i>
@@ -0,0 +1,75 @@
@extends('layout.with-main')
@section('breadcrumbs')
<li class="breadcrumb--active">{{ __('common.upload') }} {{ __('common.contests') }}</li>
@endsection
@section('page', 'page__upload-contest--index')
@section('main')
<section class="panelV2">
<h2 class="panel__heading">{{ __('common.upload') }} {{ __('common.contests') }}</h2>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>{{ __('common.name') }}</th>
<th>{{ __('common.starts-at') }}</th>
<th>{{ __('common.ends-at') }}</th>
<th>{{ __('common.active') }}</th>
<th>{{ __('common.awarded') }}</th>
</tr>
</thead>
<tbody>
@foreach ($uploadContests as $uploadContest)
<tr>
<td>
<a href="{{ route('events.show', ['event' => $uploadContest]) }}">
{{ $uploadContest->name }}
</a>
</td>
<td>
<time
datetime="{{ $uploadContest->starts_at }}"
title="{{ $uploadContest->starts_at }}"
>
{{ $uploadContest->starts_at->startOfDay() }}
</time>
</td>
<td>
<time
datetime="{{ $uploadContest->ends_at }}"
title="{{ $uploadContest->ends_at }}"
>
{{ $uploadContest->ends_at->endOfDay() }}
</time>
</td>
<td>
@if ($uploadContest->active)
<i
class="{{ config('other.font-awesome') }} fa-check text-green"
></i>
@else
<i
class="{{ config('other.font-awesome') }} fa-times text-red"
></i>
@endif
</td>
<td>
@if ($uploadContest->awarded)
<i
class="{{ config('other.font-awesome') }} fa-check text-green"
></i>
@else
<i
class="{{ config('other.font-awesome') }} fa-times text-red"
></i>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</section>
@endsection
@@ -0,0 +1,88 @@
@extends('layout.with-main-and-sidebar')
@section('breadcrumbs')
<li class="breadcrumbV2">
<a href="{{ route('upload_contests.index') }}" class="breadcrumb__link">
{{ __('common.upload') }} {{ __('common.contests') }}
</a>
</li>
<li class="breadcrumb--active">
{{ $uploadContest->name }}
</li>
@endsection
@section('page', 'page__upload-contest--show')
@section('main')
<section class="panelV2">
<h2 class="panel__heading">{{ $uploadContest->name }}</h2>
<div class="data-table-wrapper">
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>{{ __('common.user') }}</th>
<th>
{{ __('torrent.uploaded') }} (Non-{{ __('common.anonymous') }})
</th>
</tr>
</thead>
<tbody>
@foreach ($uploaders as $user)
<tr>
<td>{{ $loop->iteration }}</td>
<td>
<x-user-tag
:user="$user->user"
:anon="$user->user->privacy?->private_profile"
/>
</td>
<td>
{{ $user->uploads }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</section>
@endsection
@section('sidebar')
<section class="panelV2">
<h2 class="panel__heading">{{ __('common.info') }}</h2>
<dl class="key-value">
<div class="key-value__group">
<dt>{{ __('common.starts-at') }}</dt>
<dd>
<time datetime="{{ $uploadContest->starts_at->startOfDay() }}">
{{ $uploadContest->starts_at->startOfDay() }}
</time>
</dd>
</div>
<div class="key-value__group">
<dt>{{ __('common.ends-at') }}</dt>
<dd>
<time datetime="{{ $uploadContest->ends_at->endOfDay() }}">
{{ $uploadContest->ends_at->endOfDay() }}
</time>
</dd>
</div>
<div class="key-value__group">
<dt>{{ __('common.awarded') }}</dt>
<dd>
@if ($uploadContest->awarded)
<i class="{{ config('other.font-awesome') }} fa-check text-green"></i>
@else
<i class="{{ config('other.font-awesome') }} fa-times text-red"></i>
@endif
</dd>
</div>
</dl>
<div class="panel__body">
{{ $uploadContest->description }}
</div>
</section>
@endsection
+27
View File
@@ -125,6 +125,14 @@ Route::middleware('language')->group(function (): void {
});
});
// Upload Contests
Route::prefix('upload-contests')->name('upload_contests.')->group(function (): void {
Route::get('/', [App\Http\Controllers\UploadContestController::class, 'index'])->name('index');
Route::prefix('{uploadContest}')->group(function (): void {
Route::get('/', [App\Http\Controllers\UploadContestController::class, 'show'])->name('show');
});
});
// RSS System
Route::prefix('rss')->name('rss.')->group(function (): void {
Route::get('/', [App\Http\Controllers\RssController::class, 'index'])->name('index');
@@ -1070,6 +1078,25 @@ Route::middleware('language')->group(function (): void {
Route::get('/', [App\Http\Controllers\Staff\UnregisteredInfoHashController::class, 'index'])->name('index');
});
// Upload Contests
Route::prefix('upload-contests')->name('upload_contests.')->group(function (): void {
Route::get('/', [App\Http\Controllers\Staff\UploadContestController::class, 'index'])->name('index');
Route::get('/create', [App\Http\Controllers\Staff\UploadContestController::class, 'create'])->name('create');
Route::post('/', [App\Http\Controllers\Staff\UploadContestController::class, 'store'])->name('store');
Route::prefix('{uploadContest}')->group(function (): void {
Route::get('/edit', [App\Http\Controllers\Staff\UploadContestController::class, 'edit'])->name('edit');
Route::patch('/', [App\Http\Controllers\Staff\UploadContestController::class, 'update'])->name('update');
Route::delete('/', [App\Http\Controllers\Staff\UploadContestController::class, 'destroy'])->name('destroy');
// Prizes
Route::prefix('prizes')->name('prizes.')->group(function (): void {
Route::post('/', [App\Http\Controllers\Staff\UploadContestPrizeController::class, 'store'])->name('store');
Route::patch('/{prize}', [App\Http\Controllers\Staff\UploadContestPrizeController::class, 'update'])->name('update');
Route::delete('/{prize}', [App\Http\Controllers\Staff\UploadContestPrizeController::class, 'destroy'])->name('destroy');
});
});
});
// User Staff Notes
Route::prefix('notes')->name('notes.')->group(function (): void {
Route::get('/', [App\Http\Controllers\Staff\NoteController::class, 'index'])->name('index');
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
use App\Models\UploadContest;
use App\Models\Group;
use App\Models\User;
test('index upload contests returns an ok response', function (): void {
$group = Group::factory()->create([
'is_modo' => true,
]);
$user = User::factory()->create([
'group_id' => $group->id,
]);
$uploadContests = UploadContest::factory()->times(3)->create();
$response = $this->actingAs($user)->get(route('staff.upload_contests.index'));
$response->assertOk();
$response->assertViewIs('Staff.upload-contest.index');
$response->assertViewHas('uploadContests');
});
test('store a new upload contest returns an ok response', function (): void {
$group = Group::factory()->create([
'is_modo' => true,
]);
$user = User::factory()->create([
'group_id' => $group->id,
]);
$data = [
'name' => 'Test Upload Contest',
'description' => 'This is a test upload contest.',
'icon' => 'fa-gamepad',
'starts_at' => now()->subDays(7)->format('Y-m-d'),
'ends_at' => now()->addDays(14)->format('Y-m-d'),
];
$response = $this->actingAs($user)->post(route('staff.upload_contests.store'), $data);
$response->assertRedirect(route('staff.upload_contests.index'));
$this->assertDatabaseHas('upload_contests', $data);
});
test('update an upload contest returns an ok response', function (): void {
$group = Group::factory()->create([
'is_modo' => true,
]);
$user = User::factory()->create([
'group_id' => $group->id,
]);
$uploadContest = UploadContest::factory()->create();
$data = [
'name' => 'Updated Test Upload Contest',
'description' => 'This is an updated test upload contest.',
'icon' => 'fa-gamepad',
'active' => 1,
'starts_at' => now()->subDays(7)->format('Y-m-d'),
'ends_at' => now()->addDays(14)->format('Y-m-d'),
];
$response = $this->actingAs($user)->patch(route('staff.upload_contests.update', ['uploadContest' => $uploadContest]), $data);
$response->assertRedirect(route('staff.upload_contests.index'));
$this->assertDatabaseHas('upload_contests', $data);
});
test('destroy an upload contest returns an ok response', function (): void {
$group = Group::factory()->create([
'is_modo' => true,
]);
$user = User::factory()->create([
'group_id' => $group->id,
]);
$uploadContest = UploadContest::factory()->create();
$response = $this->actingAs($user)->delete(route('staff.upload_contests.destroy', $uploadContest));
$response->assertRedirect(route('staff.upload_contests.index'));
$this->assertDatabaseMissing('upload_contests', ['id' => $uploadContest->id]);
});
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
use App\Models\UploadContest;
use App\Models\Torrent;
use App\Models\User;
test('show an upload contest returns an ok response', function (): void {
$user = User::factory()->create();
$uploader1 = User::factory()->create();
$uploader2 = User::factory()->create();
$uploader3 = User::factory()->create();
$torrentsUploader1 = Torrent::factory()->times(3)->create([
'user_id' => $uploader1->id,
'anon' => false,
'created_at' => now(),
]);
$torrentsUploader2 = Torrent::factory()->times(6)->create([
'user_id' => $uploader2->id,
'anon' => false,
'created_at' => now(),
]);
$torrentsUploader3 = Torrent::factory()->times(1)->create([
'user_id' => $uploader3->id,
'anon' => false,
'created_at' => now(),
]);
$uploadContest = UploadContest::factory()->create([
'active' => true,
'awarded' => false,
'starts_at' => now()->subDays(2)->format('Y-m-d'),
'ends_at' => now()->addDays(2)->format('Y-m-d'),
]);
$response = $this->actingAs($user)->get(route('upload_contests.show', $uploadContest));
$response->assertOk();
$response->assertViewIs('upload-contest.show');
$response->assertViewHas('uploadContest', $uploadContest);
$uploaders = $response->viewData('uploaders');
$this->assertCount(3, $uploaders);
$this->assertEquals($uploader2->id, $uploaders[0]->user_id);
$this->assertEquals($uploader1->id, $uploaders[1]->user_id);
$this->assertEquals($uploader3->id, $uploaders[2]->user_id);
});
@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Obi-wana
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
use App\Console\Commands\AutoRewardUploadContestPrize;
use App\Models\UploadContest;
use App\Models\Torrent;
use App\Models\User;
/**
* @see AutoRewardUploadContestPrize
*/
it('runs successfully', function (): void {
$this->artisan(AutoRewardUploadContestPrize::class)
->assertExitCode(0)
->run();
});
it('rewards the top competitors in active upload contests', function (): void {
$uploader1 = User::factory()->create([
'seedbonus' => 0,
'fl_tokens' => 0,
]);
$uploader2 = User::factory()->create([
'seedbonus' => 0,
'fl_tokens' => 0,
]);
$uploader3 = User::factory()->create([
'seedbonus' => 0,
'fl_tokens' => 0,
]);
// Uploads for each user
$torrentsUploader1 = Torrent::factory()->times(3)->create([
'user_id' => $uploader1->id,
'anon' => false,
'created_at' => now()->subDays(3),
]);
$torrentsUploader2 = Torrent::factory()->times(6)->create([
'user_id' => $uploader2->id,
'anon' => false,
'created_at' => now()->subDays(3),
]);
$torrentsUploader3 = Torrent::factory()->times(1)->create([
'user_id' => $uploader3->id,
'anon' => false,
'created_at' => now()->subDays(3),
]);
// Contest
$uploadContest = UploadContest::factory()->create([
'active' => true,
'awarded' => false,
'starts_at' => now()->subDays(7)->format('Y-m-d'),
'ends_at' => now()->subDays(1)->format('Y-m-d'),
]);
// Prizes for 1st place
$uploadContest->prizes()->create([
'position' => 1,
'amount' => 100,
'type' => 'bon',
]);
$uploadContest->prizes()->create([
'position' => 1,
'amount' => 10,
'type' => 'fl_tokens',
]);
// Prizes for 2nd place
$uploadContest->prizes()->create([
'position' => 2,
'amount' => 5,
'type' => 'fl_tokens',
]);
// Run command
$this->artisan(AutoRewardUploadContestPrize::class)->assertExitCode(0);
// Assert
$this->assertDatabaseHas('upload_contests', [
'id' => $uploadContest->id,
'awarded' => 1,
]);
$this->assertDatabaseHas('upload_contest_winners', [
'upload_contest_id' => $uploadContest->id,
'user_id' => $uploader2->id,
'place_number' => 1,
'uploads' => 6,
]);
$this->assertDatabaseHas('upload_contest_winners', [
'upload_contest_id' => $uploadContest->id,
'user_id' => $uploader1->id,
'place_number' => 2,
'uploads' => 3,
]);
$this->assertDatabaseHas('upload_contest_winners', [
'upload_contest_id' => $uploadContest->id,
'user_id' => $uploader3->id,
'place_number' => 3,
'uploads' => 1,
]);
$this->assertDatabaseHas('users', [
'id' => $uploader1->id,
'seedbonus' => 0,
'fl_tokens' => 5,
]);
$this->assertDatabaseHas('users', [
'id' => $uploader2->id,
'seedbonus' => 100,
'fl_tokens' => 10,
]);
$this->assertDatabaseHas('users', [
'id' => $uploader3->id,
'seedbonus' => 0,
'fl_tokens' => 0,
]);
});
it('handles ties between competitors in active upload contests', function (): void {
$uploader1 = User::factory()->create([
'seedbonus' => 0,
'fl_tokens' => 0,
]);
$uploader2 = User::factory()->create([
'seedbonus' => 0,
'fl_tokens' => 0,
]);
$uploader3 = User::factory()->create([
'seedbonus' => 0,
'fl_tokens' => 0,
]);
$uploader4 = User::factory()->create([
'seedbonus' => 0,
'fl_tokens' => 0,
]);
// Uploads for each user
$torrentsUploader1 = Torrent::factory()->times(6)->create([
'user_id' => $uploader1->id,
'anon' => false,
'created_at' => now()->subDays(3),
]);
$torrentsUploader2 = Torrent::factory()->times(6)->create([
'user_id' => $uploader2->id,
'anon' => false,
'created_at' => now()->subDays(4),
]);
$torrentsUploader3 = Torrent::factory()->times(1)->create([
'user_id' => $uploader3->id,
'anon' => false,
'created_at' => now()->subDays(4),
]);
$torrentsUploader4 = Torrent::factory()->times(1)->create([
'user_id' => $uploader4->id,
'anon' => false,
'created_at' => now()->subDays(3),
]);
// Contest
$uploadContest = UploadContest::factory()->create([
'active' => true,
'awarded' => false,
'starts_at' => now()->subDays(7)->format('Y-m-d'),
'ends_at' => now()->subDays(1)->format('Y-m-d'),
]);
// Prizes for 1st place
$uploadContest->prizes()->create([
'position' => 1,
'amount' => 100,
'type' => 'bon',
]);
$uploadContest->prizes()->create([
'position' => 1,
'amount' => 10,
'type' => 'fl_tokens',
]);
// Prizes for 2nd place
$uploadContest->prizes()->create([
'position' => 2,
'amount' => 5,
'type' => 'fl_tokens',
]);
// Prizes for 3rd place
$uploadContest->prizes()->create([
'position' => 3,
'amount' => 1,
'type' => 'fl_tokens',
]);
// Run command
$this->artisan(AutoRewardUploadContestPrize::class)->assertExitCode(0);
// Assert
$this->assertDatabaseHas('upload_contests', [
'id' => $uploadContest->id,
'awarded' => 1,
]);
$this->assertDatabaseHas('upload_contest_winners', [
'upload_contest_id' => $uploadContest->id,
'user_id' => $uploader2->id,
'place_number' => 1,
'uploads' => 6,
]);
$this->assertDatabaseHas('upload_contest_winners', [
'upload_contest_id' => $uploadContest->id,
'user_id' => $uploader1->id,
'place_number' => 2,
'uploads' => 6,
]);
$this->assertDatabaseHas('upload_contest_winners', [
'upload_contest_id' => $uploadContest->id,
'user_id' => $uploader3->id,
'place_number' => 3,
'uploads' => 1,
]);
$this->assertDatabaseHas('users', [
'id' => $uploader1->id,
'seedbonus' => 0,
'fl_tokens' => 5,
]);
$this->assertDatabaseHas('users', [
'id' => $uploader2->id,
'seedbonus' => 100,
'fl_tokens' => 10,
]);
$this->assertDatabaseHas('users', [
'id' => $uploader3->id,
'seedbonus' => 0,
'fl_tokens' => 1,
]);
$this->assertDatabaseHas('users', [
'id' => $uploader4->id,
'seedbonus' => 0,
'fl_tokens' => 0,
]);
});