diff --git a/app/Http/Controllers/ContestController.php b/app/Http/Controllers/ContestController.php index d757266a..34e0fcf3 100644 --- a/app/Http/Controllers/ContestController.php +++ b/app/Http/Controllers/ContestController.php @@ -4,8 +4,12 @@ namespace App\Http\Controllers; +use App\Http\Resources\QuizResource; +use App\Http\Resources\QuizSubmissionResource; use App\Http\Resources\SchoolResource; +use App\Models\Quiz; use App\Models\School; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -18,8 +22,21 @@ public function index(): Response return Inertia::render("Home", ["schools" => SchoolResource::collection($schools)]); } - public function create(): Response + public function create(Request $request): Response { - return Inertia::render("User/Dashboard"); + $user = $request->user(); + $submissions = $user->quizSubmissions() + ->with(["answerRecords.question.answers", "quiz"]) + ->get(); + + $quizzes = Quiz::query() + ->whereNotNull("locked_at") + ->with("questions.answers") + ->get(); + + return Inertia::render("User/Dashboard", [ + "submissions" => QuizSubmissionResource::collection($submissions), + "quizzes" => QuizResource::collection($quizzes), + ]); } } diff --git a/app/Http/Controllers/QuizController.php b/app/Http/Controllers/QuizController.php index 2d52ce39..fae15ed7 100644 --- a/app/Http/Controllers/QuizController.php +++ b/app/Http/Controllers/QuizController.php @@ -85,4 +85,15 @@ public function createSubmission(Request $request, Quiz $quiz): RedirectResponse return redirect("/submissions/{$submission->id}/"); } + + public function assign(Request $request, Quiz $quiz): RedirectResponse + { + $user = $request->user(); + $quiz->assignedUsers()->attach($user); + $quiz->save(); + + return redirect() + ->back() + ->with("status", "Przypisano do testu"); + } } diff --git a/app/Http/Controllers/QuizSubmissionController.php b/app/Http/Controllers/QuizSubmissionController.php index 43b70a1b..3378cf79 100644 --- a/app/Http/Controllers/QuizSubmissionController.php +++ b/app/Http/Controllers/QuizSubmissionController.php @@ -17,4 +17,14 @@ public function show(QuizSubmission $quizSubmission): Response return Inertia::render("User/Quiz", ["submission" => QuizSubmissionResource::make($quizSubmission)]); } + + public function result(QuizSubmission $quizSubmission): Response + { + $quizSubmission->load(["answerRecords.question.answers", "quiz"]); + + return Inertia::render("User/QuizResult", [ + "submission" => QuizSubmissionResource::make($quizSubmission), + "hasRanking" => $quizSubmission->quiz->isRankingPublished, + ]); + } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 0f7bf38a..37687acd 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -34,6 +34,7 @@ public function share(Request $request): array protected function getFlashData(Request $request): Closure { return fn(): array => [ + "errors" => $request->session()->get("errors"), "status" => $request->session()->get("status"), ]; } diff --git a/app/Http/Resources/AnswerRecordResource.php b/app/Http/Resources/AnswerRecordResource.php index 8134b435..ac666847 100644 --- a/app/Http/Resources/AnswerRecordResource.php +++ b/app/Http/Resources/AnswerRecordResource.php @@ -4,30 +4,53 @@ namespace App\Http\Resources; +use App\Models\Answer; +use App\Models\Question; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Collection; class AnswerRecordResource extends JsonResource { public function toArray(Request $request): array { - $answers = collect(); - - foreach ($this->question->answers as $answer) { - $answers->add([ - "id" => $answer->id, - "text" => $answer->text, - ]); - } - return [ "id" => $this->id, "question" => $this->question->text, "createdAt" => $this->created_at, "updatedAt" => $this->updated_at, "closed" => $this->isClosed, - "answers" => $answers->shuffle(), "selected" => $this->answer_id, + "answers" => $this->questionAnswersToArray($this->question)->shuffle(), + ]; + } + + /** + * @return Collection + */ + protected function questionAnswersToArray(Question $question): Collection + { + return $question->answers->map( + fn(Answer $answer) => $question->quiz->isRankingPublished + ? $this->getFullAnswer($answer) + : $this->getMinimalAnswer($answer), + )->collect(); + } + + protected function getFullAnswer(Answer $answer): array + { + return [ + "id" => $answer->id, + "text" => $answer->text, + "correct" => $answer->isCorrect, + ]; + } + + protected function getMinimalAnswer(Answer $answer): array + { + return [ + "id" => $answer->id, + "text" => $answer->text, ]; } } diff --git a/app/Http/Resources/QuizResource.php b/app/Http/Resources/QuizResource.php index e60d5053..c8c19877 100644 --- a/app/Http/Resources/QuizResource.php +++ b/app/Http/Resources/QuizResource.php @@ -15,9 +15,13 @@ public function toArray($request): array "name" => $this->name, "createdAt" => $this->created_at, "updatedAt" => $this->updated_at, + "scheduledAt" => $this->scheduled_at, "duration" => $this->duration, - "locked" => $this->isLocked, + "state" => $this->state, + "canBeLocked" => $this->canBeLocked, + "canBeUnlocked" => $this->canBeUnlocked, "questions" => QuestionResource::collection($this->questions), + "isUserAssigned" => $this->isUserAssigned($request->user()), ]; } } diff --git a/app/Http/Resources/QuizSubmissionResource.php b/app/Http/Resources/QuizSubmissionResource.php index 18cbcedc..4bc34380 100644 --- a/app/Http/Resources/QuizSubmissionResource.php +++ b/app/Http/Resources/QuizSubmissionResource.php @@ -18,6 +18,7 @@ public function toArray(Request $request): array "updatedAt" => $this->updated_at, "closedAt" => $this->closed_at, "closed" => $this->isClosed, + "quiz" => $this->quiz_id, "answers" => AnswerRecordResource::collection($this->answerRecords->shuffle()), ]; } diff --git a/app/Models/Quiz.php b/app/Models/Quiz.php index c31bcf88..9dbb65b0 100644 --- a/app/Models/Quiz.php +++ b/app/Models/Quiz.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Support\Collection; @@ -22,12 +23,16 @@ * @property ?Carbon $locked_at * @property ?int $duration * @property bool $isLocked + * @property bool $isPublished * @property bool $canBeLocked * @property bool $canBeUnlocked + * @property string $state * @property bool $isRankingPublished * @property ?Carbon $closeAt * @property Collection $questions * @property Collection $answers + * @property Collection $assignedUsers + * @property Collection $quizSubmissions */ class Quiz extends Model { @@ -50,11 +55,38 @@ public function answers(): HasManyThrough return $this->hasManyThrough(Answer::class, Question::class); } + public function assignedUsers(): BelongsToMany + { + return $this->belongsToMany(User::class, "quiz_assignments"); + } + + public function quizSubmissions(): HasMany + { + return $this->hasMany(QuizSubmission::class); + } + public function isLocked(): Attribute { return Attribute::get(fn(): bool => $this->locked_at !== null); } + public function isPublished(): Attribute + { + return Attribute::get(fn(): bool => $this->isLocked && !$this->canBeUnlocked); + } + + public function state(): Attribute + { + return Attribute::get( + fn(): string => $this->isPublished ? "published" : ($this->isLocked ? "locked" : "unlocked"), + ); + } + + public function isUserAssigned(User $user): bool + { + return $this->assignedUsers->contains($user); + } + public function isRankingPublished(): Attribute { return Attribute::get(fn(): bool => $this->ranking_published_at !== null); @@ -110,6 +142,11 @@ public function isReadyToBePublished(): bool return $this->scheduled_at !== null && $this->duration !== null && $this->allQuestionsHaveCorrectAnswer(); } + public function hasSubmissionsFrom(User $user): bool + { + return $this->quizSubmissions->where("user_id", $user->id)->isNotEmpty(); + } + protected function allQuestionsHaveCorrectAnswer(): bool { return $this->questions->every(fn(Question $question): bool => $question->hasCorrectAnswer); diff --git a/app/Models/QuizSubmission.php b/app/Models/QuizSubmission.php index f4c9ad51..681bf45f 100644 --- a/app/Models/QuizSubmission.php +++ b/app/Models/QuizSubmission.php @@ -47,7 +47,7 @@ public function answerRecords(): HasMany public function isClosed(): Attribute { - return Attribute::get(fn(): bool => $this->closed_at !== null && $this->closed_at <= Carbon::now()); + return Attribute::get(fn(): bool => $this->closed_at <= Carbon::now()); } public function points(): Attribute diff --git a/app/Models/User.php b/app/Models/User.php index 0b78f1c4..7ad0db36 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -9,8 +9,10 @@ use Carbon\Carbon; use Illuminate\Contracts\Auth\CanResetPassword; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Spatie\Permission\Traits\HasRoles; @@ -27,6 +29,8 @@ * @property Carbon $updated_at * @property School $school * @property boolean $is_anonymized + * @property Collection $quizSubmissions + * @property Collection $assignedQuizzes */ class User extends Authenticatable implements MustVerifyEmail, CanResetPassword { @@ -60,6 +64,16 @@ public function sendPasswordResetNotification($token): void $this->notify(new SendResetPasswordEmail($token)); } + public function quizSubmissions(): HasMany + { + return $this->hasMany(QuizSubmission::class); + } + + public function assignedQuizzes() + { + return $this->belongsToMany(Quiz::class, "quiz_assignments"); + } + protected function casts(): array { return [ diff --git a/app/Policies/QuizPolicy.php b/app/Policies/QuizPolicy.php index afe3da61..7f19fe9d 100644 --- a/app/Policies/QuizPolicy.php +++ b/app/Policies/QuizPolicy.php @@ -40,6 +40,11 @@ public function unlock(User $user, Quiz $quiz): bool return $quiz->canBeUnlocked; } + public function assign(User $user, Quiz $quiz): bool + { + return $quiz->isLocked && !$quiz->isPublished && !$quiz->hasSubmissionsFrom($user); + } + public function viewAdminRanking(User $user, Quiz $quiz): Response { return ($quiz->isLocked && $user->hasRole("admin|super_admin")) ? Response::allow() : Response::deny("Nie masz uprawnień do zobaczenia rankingu."); diff --git a/app/Policies/QuizSubmissionPolicy.php b/app/Policies/QuizSubmissionPolicy.php index 0fbe652d..29d48567 100644 --- a/app/Policies/QuizSubmissionPolicy.php +++ b/app/Policies/QuizSubmissionPolicy.php @@ -13,4 +13,9 @@ public function view(User $user, QuizSubmission $quizSubmission): bool { return $user->id === $quizSubmission->user_id; } + + public function result(User $user, QuizSubmission $quizSubmission): bool + { + return $user->id === $quizSubmission->user_id && $quizSubmission->isClosed; + } } diff --git a/config/app.php b/config/app.php index 03e49b77..4f0c052e 100644 --- a/config/app.php +++ b/config/app.php @@ -7,7 +7,7 @@ "env" => env("APP_ENV", "production"), "debug" => (bool)env("APP_DEBUG", false), "url" => env("APP_URL", "http://localhost"), - "timezone" => env("APP_TIMEZONE", "UTC"), + "timezone" => env("APP_TIMEZONE", "Europe/Warsaw"), "locale" => env("APP_LOCALE", "pl"), "fallback_locale" => env("APP_FALLBACK_LOCALE", "pl"), "faker_locale" => env("APP_FAKER_LOCALE", "en_US"), diff --git a/database/migrations/2024_09_02_103155_create_quiz_assignments_table.php b/database/migrations/2024_09_02_103155_create_quiz_assignments_table.php new file mode 100644 index 00000000..d60a0daa --- /dev/null +++ b/database/migrations/2024_09_02_103155_create_quiz_assignments_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignIdFor(User::class)->constrained()->onDelete("cascade"); + $table->foreignIdFor(Quiz::class)->constrained()->onDelete("cascade"); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists("quiz_assignments"); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index cf956f95..40b638ca 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,8 +5,9 @@ namespace Database\Seeders; use App\Models\Answer; -use App\Models\AnswerRecord; +use App\Models\Question; use App\Models\Quiz; +use Carbon\Carbon; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder @@ -19,8 +20,16 @@ public function run(): void UserSeeder::class, UserQuizSeeder::class, ]); - Quiz::factory()->create(); - Answer::factory()->create(); - AnswerRecord::factory()->create(); + + Quiz::factory()->locked()->count(5)->create(["scheduled_at" => Carbon::now()->addMonth()]); + + $quiz = Quiz::factory()->locked()->create(["name" => "Test Quiz", "scheduled_at" => Carbon::now(), "duration" => 2]); + $questions = Question::factory()->count(10)->create(["quiz_id" => $quiz->id]); + + foreach ($questions as $question) { + $answers = Answer::factory()->count(4)->create(["question_id" => $question->id]); + $question->correct_answer_id = $answers[0]->id; + $question->save(); + } } } diff --git a/package-lock.json b/package-lock.json index c1c75885..677e3047 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@inertiajs/vue3": "^1.2.0", "@tailwindcss/forms": "^0.5.8", "@tailwindcss/typography": "^0.5.15", + "dayjs": "^1.11.13", "laravel-vite-plugin": "^1.0.5", "lodash": "^4.17.21", "tailwindcss": "^3.4.10", @@ -2775,6 +2776,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", diff --git a/package.json b/package.json index cd2e8096..5f1ea7b4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@inertiajs/vue3": "^1.2.0", "@tailwindcss/forms": "^0.5.8", "@tailwindcss/typography": "^0.5.15", + "dayjs": "^1.11.13", "laravel-vite-plugin": "^1.0.5", "lodash": "^4.17.21", "tailwindcss": "^3.4.10", diff --git a/resources/js/Pages/Guest/ForgotPassword.vue b/resources/js/Pages/Guest/ForgotPassword.vue index 9c8d220b..3a0ea926 100644 --- a/resources/js/Pages/Guest/ForgotPassword.vue +++ b/resources/js/Pages/Guest/ForgotPassword.vue @@ -10,8 +10,7 @@ defineProps<{ const form = useForm({ email: '' }) function submit() { - form.post('/auth/forgot-password') -} + form.post('/auth/forgot-password')}