diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20b9e7f..0348fe9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ permissions: contents: write env: - UPGRADE_BASE_TAG: v2.1.3 + UPGRADE_BASE_TAG: v2.1.4 jobs: verify-release: diff --git a/CHANGELOG.md b/CHANGELOG.md index f46ce0e..9c654f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,53 @@ The format is intentionally lightweight and release-friendly so entries can be r ## Unreleased +## [v2.2.0] - 2026-07-28 + +### Added + +- Added the `modern_img` participant template with calm image-first styling and adaptive one- or two-column answer cards based on answer length and screen size. +- Teachers can now use pre-registered bulk exam slots and the Public Anonymous Pool, subject to their configured participant limit per quiz. +- Public Anonymous Pool quizzes can now export a printable invitation PDF with quiz details, the shared signed link, and its QR Code. + +### Changed + +- Template visibility and user assignments are now enforced from the database, including for templates with built-in view files. +- Re-running the core template seeder no longer overwrites administrator-managed visibility settings. +- Image-capable templates now use a neutral shared view layer, removing the direct coupling between `default_img` and `modern_img`. +- Administrators can now identify each quiz creator directly in the shared quiz collection, while teachers continue to see only their own quizzes. +- Guest-access links now present the read-only URL and copy action as one responsive, accessible control. +- Pre-registered exam-slot PDFs now provide handwritten name lines, and the consolidated student register uses the modern printable layout. +- Pre-registered bulk-exam participants are now presented as numbered exam slots (for example, `Exam Slot 0001`), keeping name-to-slot matching outside the application and distinct from the public anonymous pool. +- Participant limits now apply consistently to named participants, pre-registered exam slots, and completed or active Public Anonymous Pool positions. +- The Public Anonymous Pool link, copy action, and invitation download now share one compact responsive row. +- The release workflow now builds the incremental upgrade package from `v2.1.4` to `v2.2.0`. + +### Fixed + +- Completed Public Anonymous Pool submissions are now persisted reliably even when a browser does not preserve the final-submit button action; genuinely abandoned sessions remain temporary. +- Certificates for anonymous participants now provide a blank handwritten name line while named-participant certificates remain unchanged. +- Result PDFs for anonymous participants now leave the participant-name field blank for handwritten completion. +- Participant access PDFs now show the QR access caption only beneath the QR Code, removing the duplicate heading text. +- Anonymous exam cards, participant registers, certificates, and result PDFs now use consistent printable layouts and blank handwritten name fields where appropriate. +- Public Anonymous Pool quota checks now reserve positions transactionally and prevent concurrent requests from exceeding the owner’s configured limit. + +### Security + +- Template visibility and assignment are enforced server-side, preventing unassigned teachers from viewing or saving restricted templates. +- Bulk-exam and Public Anonymous Pool actions enforce quiz ownership, administrator privileges where required, signed-link validation, and participant quotas on the server. +- The Public Anonymous Pool invitation PDF is available only to the quiz owner or an administrator and reuses the exact signed public URL shown in the interface. +- Updated Dompdf to `v3.1.6`, resolving the known SVG, bitmap resource-exhaustion, local-file disclosure, and chroot-bypass advisories affecting earlier releases. + +### Removed + +- Removed the retired `retroAXD3_img` template. Existing quizzes using it are migrated to `default_img`. +- Removed the PayPal sponsorship page and its navigation links from the public documentation. + +### Upgrade Notes + +- Run `php artisan migrate` so quizzes that still reference `retroAXD3_img` are moved safely to `default_img`. +- Run `php artisan optimize:clear` after deploying the updated application files. + ## [v2.1.4] - 2026-07-06 ### Changed diff --git a/README.md b/README.md index d613ce9..4f2da24 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,6 @@ | Εγκατάσταση | [Άνοιγμα](https://labschool-gr.github.io/Exams/Installation-and-Setup.html) | | Αναβάθμιση | [Άνοιγμα](https://labschool-gr.github.io/Exams/upgrade-packages.html) | | Ασφάλεια και απόρρητο | [Άνοιγμα](https://labschool-gr.github.io/Exams/Security-Privacy-and-Compliance.html) | -| Υποστήριξη έργου | [Άνοιγμα](https://labschool-gr.github.io/Exams/sponsor.html) | --- @@ -71,7 +70,6 @@ Participants do not need an account. They can join with a temporary PIN, a perso | Installation | [Open](https://labschool-gr.github.io/Exams/Installation-and-Setup.html) | | Upgrade packages | [Open](https://labschool-gr.github.io/Exams/upgrade-packages.html) | | Security and privacy | [Open](https://labschool-gr.github.io/Exams/Security-Privacy-and-Compliance.html) | -| Support the project | [Open](https://labschool-gr.github.io/Exams/sponsor.html) | --- diff --git a/app/Http/Controllers/Concerns/HandlesQuizParticipantPublicPool.php b/app/Http/Controllers/Concerns/HandlesQuizParticipantPublicPool.php index 301db0a..b0e136f 100644 --- a/app/Http/Controllers/Concerns/HandlesQuizParticipantPublicPool.php +++ b/app/Http/Controllers/Concerns/HandlesQuizParticipantPublicPool.php @@ -98,15 +98,19 @@ private function claimPublicAnonymousPoolReservation(Quiz $quiz, Request $reques $expiresAt = now()->addMinutes($this->publicAnonymousPoolReservationTtlMinutes($quiz)); return DB::transaction(function () use ($quiz, $sessionId, $expiresAt) { - Quiz::query()->whereKey($quiz->id)->lockForUpdate()->first(); + $lockedQuiz = Quiz::query() + ->with('creator') + ->whereKey($quiz->id) + ->lockForUpdate() + ->firstOrFail(); QuizAnonymousPoolReservation::query() - ->where('quiz_id', $quiz->id) + ->where('quiz_id', $lockedQuiz->id) ->where('expires_at', '<=', now()) ->delete(); $existing = QuizAnonymousPoolReservation::query() - ->where('quiz_id', $quiz->id) + ->where('quiz_id', $lockedQuiz->id) ->where('session_id', $sessionId) ->lockForUpdate() ->first(); @@ -119,26 +123,22 @@ private function claimPublicAnonymousPoolReservation(Quiz $quiz, Request $reques return $existing->fresh(); } - $capacity = max(1, (int) ($quiz->anonymous_pool_capacity ?? 0)); - $completedCount = QuizAttempt::query() - ->where('quiz_id', $quiz->id) - ->whereNotNull('submitted_at') - ->whereHas('student', function ($query) { - $query->where('is_anonymous', true); - }) + $capacity = $lockedQuiz->effectiveAnonymousPoolCapacity(); + $registeredParticipantCount = QuizStudent::query() + ->where('quiz_id', $lockedQuiz->id) ->count(); $activeReservations = QuizAnonymousPoolReservation::query() - ->where('quiz_id', $quiz->id) + ->where('quiz_id', $lockedQuiz->id) ->lockForUpdate() ->get(); - if (($completedCount + $activeReservations->count()) >= $capacity) { + if (($registeredParticipantCount + $activeReservations->count()) >= $capacity) { return null; } $slotCode = $this->nextAvailableAnonymousSlotCode( - $quiz, + $lockedQuiz, $activeReservations->pluck('slot_code')->all() ); @@ -147,7 +147,7 @@ private function claimPublicAnonymousPoolReservation(Quiz $quiz, Request $reques } return QuizAnonymousPoolReservation::create([ - 'quiz_id' => $quiz->id, + 'quiz_id' => $lockedQuiz->id, 'session_id' => $sessionId, 'slot_code' => $slotCode, 'expires_at' => $expiresAt, diff --git a/app/Http/Controllers/QuizAttemptController.php b/app/Http/Controllers/QuizAttemptController.php index e45707b..a0d64e7 100644 --- a/app/Http/Controllers/QuizAttemptController.php +++ b/app/Http/Controllers/QuizAttemptController.php @@ -28,6 +28,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Session; +use Illuminate\Support\Facades\URL; use Illuminate\Support\Str; use Illuminate\View\View; use Maatwebsite\Excel\Concerns\FromCollection; @@ -101,19 +102,13 @@ private function canRegisterMoreStudents(Quiz $quiz): bool private function remainingStudentCapacity(Quiz $quiz): ?int { - /** @var User|null $user */ - $user = Auth::user(); + $participantLimit = $quiz->participantCapacityLimit(); - if (! $user || $user->isAdmin()) { + if ($participantLimit === null) { return null; } - return max(0, (int) $user->max_students_per_quiz - QuizStudent::where('quiz_id', $quiz->id)->count()); - } - - private function anonymousStudentDisplayName(): string - { - return __('controllers.anonymous_student_name'); + return max(0, $participantLimit - QuizStudent::where('quiz_id', $quiz->id)->count()); } private function nextAvailableStudentCodes(Quiz $quiz, int $count): array @@ -224,9 +219,9 @@ public function registerStudents(Quiz $quiz): View }); $canRegisterStudents = $this->canRegisterMoreStudents($quiz); - /** @var User|null $user */ - $user = Auth::user(); - $studentLimit = $user?->max_students_per_quiz; + $remainingStudentCapacity = $this->remainingStudentCapacity($quiz); + $studentLimit = $quiz->participantCapacityLimit(); + $effectiveAnonymousPoolCapacity = $quiz->effectiveAnonymousPoolCapacity(); $publicAnonymousPoolCompletedCount = QuizAttempt::query() ->where('quiz_id', $quiz->id) ->whereNotNull('submitted_at') @@ -234,6 +229,17 @@ public function registerStudents(Quiz $quiz): View $query->where('is_anonymous', true); }) ->count(); + $publicLinkTtlMinutes = (int) config('security.signed_urls.public_link_ttl_minutes', 10080); + $publicLinkExpiresAt = $publicLinkTtlMinutes > 0 + ? now()->addMinutes($publicLinkTtlMinutes) + : null; + $guestUrl = $quiz->publicAccessUrl($publicLinkExpiresAt); + $publicPoolInvitationPdfUrl = $quiz->is_public_anonymous_pool_mode && $guestUrl + ? URL::signedRoute('quiz_attempts.public_pool_invitation_pdf', [ + 'quiz' => $quiz, + 'link_expires' => $publicLinkExpiresAt?->getTimestamp() ?? 0, + ]) + : null; $displaySessionsByStudentId = QuizDisplaySession::query() ->where('quiz_id', $quiz->id) @@ -248,7 +254,7 @@ public function registerStudents(Quiz $quiz): View ->unique('quiz_student_id') ->keyBy('quiz_student_id'); - return view('quiz_attempts.register_students', compact('quiz', 'students', 'attemptsGrouped', 'canRegisterStudents', 'studentLimit', 'publicAnonymousPoolCompletedCount', 'displaySessionsByStudentId')); + return view('quiz_attempts.register_students', compact('quiz', 'students', 'attemptsGrouped', 'canRegisterStudents', 'remainingStudentCapacity', 'studentLimit', 'publicAnonymousPoolCompletedCount', 'effectiveAnonymousPoolCapacity', 'guestUrl', 'publicPoolInvitationPdfUrl', 'displaySessionsByStudentId')); } /** @@ -318,11 +324,6 @@ public function storeAnonymousStudents(Request $request, Quiz $quiz): RedirectRe ->with('error', __('controllers.anonymous_bulk_mode_disabled')); } - if (! $this->canRegisterMoreStudents($quiz)) { - return redirect()->route('quiz_attempts.register_students', $quiz) - ->with('error', __('controllers.student_limit_reached')); - } - $remainingCapacity = $this->remainingStudentCapacity($quiz); $requestedSlotsLimit = $remainingCapacity ?? 9999; @@ -333,27 +334,47 @@ public function storeAnonymousStudents(Request $request, Quiz $quiz): RedirectRe $slotCount = (int) $request->input('anonymous_slots_count'); $maxAttempts = (int) $request->input('anonymous_max_attempts'); - $codes = $this->nextAvailableStudentCodes($quiz, $slotCount); + $created = DB::transaction(function () use ($quiz, $slotCount, $maxAttempts): bool { + $lockedQuiz = Quiz::query() + ->with('creator') + ->whereKey($quiz->id) + ->lockForUpdate() + ->firstOrFail(); + $participantLimit = $lockedQuiz->participantCapacityLimit(); + $existingCount = QuizStudent::query() + ->where('quiz_id', $lockedQuiz->id) + ->count(); + + if ($participantLimit !== null && ($existingCount + $slotCount) > $participantLimit) { + return false; + } - if (count($codes) !== $slotCount) { - return redirect()->route('quiz_attempts.register_students', $quiz) - ->with('error', __('controllers.anonymous_slots_unavailable')); - } + $codes = $this->nextAvailableStudentCodes($lockedQuiz, $slotCount); + + if (count($codes) !== $slotCount) { + return false; + } - DB::transaction(function () use ($quiz, $codes, $maxAttempts): void { foreach ($codes as $code) { QuizStudent::create([ - 'quiz_id' => $quiz->id, + 'quiz_id' => $lockedQuiz->id, 'student_code' => $code, - 'student_name' => $this->anonymousStudentDisplayName(), + 'student_name' => QuizStudent::examSlotName($code), 'max_attempts' => $maxAttempts, 'is_anonymous' => true, 'access_token' => null, 'access_token_hash' => QuizStudent::generateLinkTokenHash(), ]); } + + return true; }); + if (! $created) { + return redirect()->route('quiz_attempts.register_students', $quiz) + ->with('error', __('controllers.student_limit_reached')); + } + return redirect()->route('quiz_attempts.register_students', $quiz) ->with('success', __('controllers.anonymous_slots_created', ['count' => $slotCount])); } @@ -574,7 +595,7 @@ public function downloadPdf(Quiz $quiz, QuizAttempt $quizAttempt): Response|Bina $this->ensureAttemptBelongsToQuiz($quiz, $quizAttempt); $quiz->load(['creator', 'questions.answers']); - $quizAttempt->load(['answers.answer', 'answers.question']); + $quizAttempt->load(['answers.answer', 'answers.question', 'student']); App::setLocale($quiz->resolvedLocale(config('app.locale'))); @@ -628,6 +649,7 @@ public function downloadPdf(Quiz $quiz, QuizAttempt $quizAttempt): Response|Bina 'questionResults' => $questionResults, 'correctAnswersMap' => $correctAnswersMap, 'scorePercentage' => $scorePercentage, + 'isAnonymousParticipant' => (bool) $quizAttempt->student?->is_anonymous, ]; $pdf = Pdf::loadView('quiz_attempts.result_pdf', $pdfData); @@ -646,19 +668,21 @@ public function studentsReportPdf(Quiz $quiz): \Symfony\Component\HttpFoundation { $this->authorizeQuizAccess($quiz); - // Load all registered students + App::setLocale($quiz->resolvedLocale(config('app.locale'))); + $quiz->load('creator'); + $students = QuizStudent::query() ->where('quiz_id', $quiz->id) ->orderBy('student_code') ->orderBy('id') - ->get(['student_name', 'student_code', 'max_attempts']); + ->get(['student_name', 'student_code', 'max_attempts', 'is_anonymous']); - // Prepare data (simplified) - $reportData = $students->map(function ($student) { + $reportData = $students->map(function (QuizStudent $student): array { return [ 'name' => $student->student_name, 'code' => $student->student_code, 'max_attempts' => $student->max_attempts, + 'is_anonymous' => (bool) $student->is_anonymous, ]; }); @@ -667,6 +691,8 @@ public function studentsReportPdf(Quiz $quiz): \Symfony\Component\HttpFoundation 'data' => $reportData, ]); + $this->addCenteredPdfPageFooter($pdf, __('pdfexp.page_footer')); + return $pdf->download('students_report_'.$quiz->id.'.pdf'); } @@ -698,7 +724,6 @@ public function downloadAnonymousCardsPdf(Quiz $quiz): Response|BinaryFileRespon return [ 'student_code' => $student->student_code, - 'student_name' => $this->anonymousStudentDisplayName(), 'max_attempts' => $student->max_attempts, 'student_url' => $url, 'qr_svg' => $url @@ -712,7 +737,66 @@ public function downloadAnonymousCardsPdf(Quiz $quiz): Response|BinaryFileRespon 'cards' => $cards, ]); - return $pdf->download('anonymous_cards_'.$quiz->id.'.pdf'); + return $pdf->download('exam_slot_cards_'.$quiz->id.'.pdf'); + } + + /** + * Download a printable invitation using the exact shared public link shown to the teacher. + */ + public function downloadPublicPoolInvitationPdf(Quiz $quiz, Request $request): Response|BinaryFileResponse|RedirectResponse + { + $this->authorizeQuizAccess($quiz); + + if (! $quiz->is_public_anonymous_pool_mode) { + return redirect()->route('quiz_attempts.register_students', $quiz) + ->with('error', __('controllers.public_anonymous_pool_mode_disabled')); + } + + if ($quiz->status !== 'active') { + return redirect()->route('quiz_attempts.register_students', $quiz) + ->with('error', __('controllers.public_anonymous_pool_invitation_inactive')); + } + + $validated = $request->validate([ + 'link_expires' => ['required', 'integer', 'min:0'], + ]); + $expirationTimestamp = (int) $validated['link_expires']; + $expiresAt = $expirationTimestamp > 0 + ? Carbon::createFromTimestamp($expirationTimestamp) + : null; + + if ($expiresAt?->isPast()) { + return redirect()->route('quiz_attempts.register_students', $quiz) + ->with('error', __('controllers.public_anonymous_pool_invitation_expired')); + } + + $publicUrl = $expiresAt + ? $quiz->publicAccessUrl($expiresAt) + : URL::signedRoute('quizzes.public.start', [ + 'quiz' => $quiz->id, + 'key' => $quiz->publicLinkFingerprint(), + ]); + + if (! $publicUrl) { + return redirect()->route('quiz_attempts.register_students', $quiz) + ->with('error', __('controllers.public_anonymous_pool_link_unavailable')); + } + + App::setLocale($quiz->resolvedLocale(config('app.locale'))); + $quiz->load('creator'); + $qrSvg = base64_encode( + QrCode::format('svg')->size(260)->margin(1)->generate($publicUrl) + ); + + $pdf = Pdf::loadView('quiz_attempts.public_pool_invitation_pdf', [ + 'quiz' => $quiz, + 'publicUrl' => $publicUrl, + 'qrSvg' => $qrSvg, + 'expiresAt' => $expiresAt, + 'effectiveCapacity' => $quiz->effectiveAnonymousPoolCapacity(), + ]); + + return $pdf->download('quiz_invitation_'.$quiz->id.'.pdf'); } /** @@ -804,6 +888,7 @@ public function downloadCertificate(QuizAttempt $attempt): Response|BinaryFileRe $this->authorizeQuizAccess($quiz); App::setLocale($quiz->resolvedLocale(config('app.locale'))); + $attempt->loadMissing('student'); // User is not eligible for certificate if ($attempt->score < $quiz->pass_percentage) { @@ -816,6 +901,7 @@ public function downloadCertificate(QuizAttempt $attempt): Response|BinaryFileRe $pdf = Pdf::loadView('quiz_attempts.certificate', [ 'attempt' => $attempt, 'quiz' => $quiz, + 'isAnonymousParticipant' => (bool) $attempt->student?->is_anonymous, ])->setPaper('A4', 'landscape'); return $pdf->download('certificate_'.$attempt->id.'.pdf'); diff --git a/app/Http/Controllers/QuizController.php b/app/Http/Controllers/QuizController.php index db12e08..1e6ace2 100644 --- a/app/Http/Controllers/QuizController.php +++ b/app/Http/Controllers/QuizController.php @@ -29,6 +29,20 @@ class QuizController extends Controller { + /** + * Legacy core templates remain usable before the installer seeds their records. + * + * @var list + */ + private const LEGACY_COMMON_TEMPLATE_CODES = ['default', 'default_img']; + + /** + * Internal view layers are implementation details and cannot be selected as templates. + * + * @var list + */ + private const INTERNAL_TEMPLATE_CODES = ['shared_img']; + use AuthorizesQuizOwnership; private function currentUser(): ?User @@ -90,7 +104,7 @@ private function updateLockedQuizStatusOnly(Request $request, Quiz $quiz): Redir 'notify_creator_on_pass' => $request->boolean('notify_creator_on_pass'), ]; - if ($this->currentUserCanManageSpecialModes()) { + if ($this->currentUserCanManageAdminModes()) { $data['is_certificate_verification_enabled'] = $request->boolean('is_certificate_verification_enabled'); $data['is_second_screen_enabled'] = $request->boolean('is_second_screen_enabled'); } @@ -102,11 +116,37 @@ private function updateLockedQuizStatusOnly(Request $request, Quiz $quiz): Redir ->with('success', __('controllers.quiz_updated')); } - private function currentUserCanManageSpecialModes(): bool + private function currentUserCanManageAdminModes(): bool { return $this->currentUser()?->isAdmin() ?? false; } + private function currentUserParticipantLimit(): ?int + { + $user = $this->currentUser(); + + if (! $user || $user->isAdmin()) { + return null; + } + + return max(1, (int) $user->max_students_per_quiz); + } + + private function anonymousPoolCapacityRule(?Quiz $quiz = null): string + { + $participantLimit = $quiz?->participantCapacityLimit() + ?? $this->currentUserParticipantLimit(); + + return 'nullable|integer|min:1|max:'.($participantLimit ?? 9999); + } + + private function defaultAnonymousPoolCapacity(): int + { + $participantLimit = $this->currentUserParticipantLimit(); + + return $participantLimit === null ? 100 : min(100, $participantLimit); + } + private function availableTemplatesQuery(?User $user) { $query = QuizTemplate::query(); @@ -132,6 +172,10 @@ private function builtInTemplateViewsExist(?string $templateCode): bool return false; } + if (in_array($templateCode, self::INTERNAL_TEMPLATE_CODES, true)) { + return false; + } + foreach (['start', 'student', 'question', 'result'] as $screen) { if (! view()->exists('quiz.templates.'.$templateCode.'.'.$screen)) { return false; @@ -145,17 +189,20 @@ private function availableTemplatesForUser(?User $user) { $databaseTemplates = $this->availableTemplatesQuery($user) ->orderBy('name') - ->get(['code', 'name']); + ->get(['code', 'name']) + ->filter(fn (QuizTemplate $template) => $this->builtInTemplateViewsExist($template->code)); - $existingCodes = $databaseTemplates + // A registered template must always respect its database visibility rules. + // Filesystem discovery is kept only as a backwards-compatible fallback for + // legacy built-in templates that have no database record at all. + $registeredCodes = QuizTemplate::query() ->pluck('code') ->filter() ->all(); - $builtInTemplates = collect(glob(resource_path('views/quiz/templates/*'), GLOB_ONLYDIR) ?: []) - ->map(fn (string $path) => basename($path)) + $builtInTemplates = collect(self::LEGACY_COMMON_TEMPLATE_CODES) ->filter(fn (string $code) => $this->builtInTemplateViewsExist($code)) - ->reject(fn (string $code) => in_array($code, $existingCodes, true)) + ->reject(fn (string $code) => in_array($code, $registeredCodes, true)) ->map(fn (string $code) => (object) [ 'code' => $code, 'name' => Str::headline(str_replace('_', ' ', $code)), @@ -173,8 +220,16 @@ private function userCanUseTemplateCode(?string $templateCode, ?User $user): boo return false; } - if ($this->builtInTemplateViewsExist($templateCode)) { - return true; + if (! $this->builtInTemplateViewsExist($templateCode)) { + return false; + } + + $isRegistered = QuizTemplate::query() + ->where('code', $templateCode) + ->exists(); + + if (! $isRegistered) { + return in_array($templateCode, self::LEGACY_COMMON_TEMPLATE_CODES, true); } return $this->availableTemplatesQuery($user) @@ -208,9 +263,12 @@ public function index(): View $quizzesQuery = Quiz::query() ->where('is_system_example', false) - ->with('category'); + ->with([ + 'category', + 'creator:id,name', + ]); - if ($user->role === 'teacher') { + if (! $user->isAdmin()) { $quizzesQuery->where('creator_id', $user->id); } @@ -235,8 +293,10 @@ public function create(): View|RedirectResponse $categories = Category::orderBy('name')->get(); $templates = $this->availableTemplatesForUser($this->currentUser()); + $participantLimit = $this->currentUserParticipantLimit(); + $defaultAnonymousPoolCapacity = $this->defaultAnonymousPoolCapacity(); - return view('quizzes.create', compact('categories', 'templates')); + return view('quizzes.create', compact('categories', 'templates', 'participantLimit', 'defaultAnonymousPoolCapacity')); } /** @@ -250,8 +310,9 @@ public function edit(Quiz $quiz): View $questionCount = $quiz->questions()->count(); $templates = $this->availableTemplatesForUser($this->currentUser()); $isContentLocked = $quiz->hasLockedContent(); + $participantLimit = $quiz->participantCapacityLimit(); - return view('quizzes.edit', compact('quiz', 'categories', 'questionCount', 'templates', 'isContentLocked')); + return view('quizzes.edit', compact('quiz', 'categories', 'questionCount', 'templates', 'isContentLocked', 'participantLimit')); } /** @@ -310,22 +371,23 @@ function (string $attribute, mixed $value, \Closure $fail): void { 'notify_creator_on_pass' => 'boolean', 'is_anonymous_bulk_mode' => 'boolean', 'is_public_anonymous_pool_mode' => 'boolean', - 'anonymous_pool_capacity' => 'nullable|integer|min:1|max:9999', + 'anonymous_pool_capacity' => $this->anonymousPoolCapacityRule(), 'student_access_policy' => ['required', 'string', Rule::in(Quiz::studentAccessPolicies())], 'status' => 'required|in:active,inactive', 'language' => 'required|in:el,en,auto', 'image' => 'nullable|image|max:150', ], [ 'image.max' => __('controllers.quiz_image_max'), + 'anonymous_pool_capacity.max' => __('controllers.anonymous_pool_capacity_limit'), ]); - $canManageSpecialModes = $this->currentUserCanManageSpecialModes(); - $isAnonymousBulkMode = $canManageSpecialModes ? $request->boolean('is_anonymous_bulk_mode') : false; - $isPublicAnonymousPoolMode = $canManageSpecialModes ? $request->boolean('is_public_anonymous_pool_mode') : false; - $isCertificateVerificationEnabled = $canManageSpecialModes + $canManageAdminModes = $this->currentUserCanManageAdminModes(); + $isAnonymousBulkMode = $request->boolean('is_anonymous_bulk_mode'); + $isPublicAnonymousPoolMode = $request->boolean('is_public_anonymous_pool_mode'); + $isCertificateVerificationEnabled = $canManageAdminModes ? $request->boolean('is_certificate_verification_enabled') : false; - $isSecondScreenEnabled = $canManageSpecialModes + $isSecondScreenEnabled = $canManageAdminModes ? $request->boolean('is_second_screen_enabled') : false; @@ -348,7 +410,7 @@ function (string $attribute, mixed $value, \Closure $fail): void { } $anonymousPoolCapacity = $isPublicAnonymousPoolMode - ? max(1, (int) $request->integer('anonymous_pool_capacity', 100)) + ? max(1, (int) $request->integer('anonymous_pool_capacity', $this->defaultAnonymousPoolCapacity())) : null; if ($isAnonymousBulkMode) { @@ -468,7 +530,7 @@ function (string $attribute, mixed $value, \Closure $fail): void { 'notify_creator_on_pass' => 'boolean', 'is_anonymous_bulk_mode' => 'boolean', 'is_public_anonymous_pool_mode' => 'boolean', - 'anonymous_pool_capacity' => 'nullable|integer|min:1|max:9999', + 'anonymous_pool_capacity' => $this->anonymousPoolCapacityRule($quiz), 'student_access_policy' => ['required', 'string', Rule::in(Quiz::studentAccessPolicies())], 'status' => 'required|in:active,inactive', 'language' => 'required|in:el,en,auto', @@ -482,19 +544,16 @@ function (string $attribute, mixed $value, \Closure $fail): void { $request->validate($rules, [ 'image.max' => __('controllers.quiz_image_max'), + 'anonymous_pool_capacity.max' => __('controllers.anonymous_pool_capacity_limit'), ]); - $canManageSpecialModes = $this->currentUserCanManageSpecialModes(); - $isAnonymousBulkMode = $canManageSpecialModes - ? $request->boolean('is_anonymous_bulk_mode') - : (bool) $quiz->is_anonymous_bulk_mode; - $isPublicAnonymousPoolMode = $canManageSpecialModes - ? $request->boolean('is_public_anonymous_pool_mode') - : (bool) $quiz->is_public_anonymous_pool_mode; - $isCertificateVerificationEnabled = $canManageSpecialModes + $canManageAdminModes = $this->currentUserCanManageAdminModes(); + $isAnonymousBulkMode = $request->boolean('is_anonymous_bulk_mode'); + $isPublicAnonymousPoolMode = $request->boolean('is_public_anonymous_pool_mode'); + $isCertificateVerificationEnabled = $canManageAdminModes ? $request->boolean('is_certificate_verification_enabled') : $quiz->usesCertificateVerification(); - $isSecondScreenEnabled = $canManageSpecialModes + $isSecondScreenEnabled = $canManageAdminModes ? $request->boolean('is_second_screen_enabled') : $quiz->usesSecondScreenMode(); @@ -516,12 +575,17 @@ function (string $attribute, mixed $value, \Closure $fail): void { ]); } + $participationModeChanged = $isAnonymousBulkMode !== (bool) $quiz->is_anonymous_bulk_mode + || $isPublicAnonymousPoolMode !== (bool) $quiz->is_public_anonymous_pool_mode; + + if ($participationModeChanged && $quiz->students()->exists()) { + return back()->withInput()->withErrors([ + 'is_anonymous_bulk_mode' => __('controllers.participation_mode_has_students'), + ]); + } + $anonymousPoolCapacity = $isPublicAnonymousPoolMode - ? ( - $canManageSpecialModes - ? max(1, (int) $request->integer('anonymous_pool_capacity', (int) ($quiz->anonymous_pool_capacity ?: 100))) - : $quiz->anonymous_pool_capacity - ) + ? max(1, (int) $request->integer('anonymous_pool_capacity', (int) ($quiz->anonymous_pool_capacity ?: 100))) : null; if ($isAnonymousBulkMode) { @@ -664,6 +728,17 @@ public function duplicate(Quiz $quiz): RedirectResponse $questionView = $this->userCanUseTemplateCode($quiz->question_view, $owner) ? $quiz->question_view : 'default'; + $participantLimit = $owner->isAdmin() + ? null + : max(1, (int) $owner->max_students_per_quiz); + $isAnonymousBulkMode = (bool) $quiz->is_anonymous_bulk_mode; + $isPublicAnonymousPoolMode = (bool) $quiz->is_public_anonymous_pool_mode; + $anonymousPoolCapacity = $isPublicAnonymousPoolMode + ? min( + max(1, (int) ($quiz->anonymous_pool_capacity ?? 1)), + $participantLimit ?? 9999 + ) + : null; $copiedQuiz = Quiz::create([ 'title' => $quiz->title, @@ -692,13 +767,13 @@ public function duplicate(Quiz $quiz): RedirectResponse 'status' => 'inactive', 'questions_limit' => $quiz->questions_limit, 'public_token' => null, - 'public_token_hash' => (($quiz->allow_guest && $quiz->is_public) || $quiz->is_public_anonymous_pool_mode) + 'public_token_hash' => (($quiz->allow_guest && $quiz->is_public) || $isPublicAnonymousPoolMode) ? Quiz::generateLinkTokenHash() : null, 'is_public' => (bool) $quiz->is_public, - 'is_anonymous_bulk_mode' => $owner->isAdmin() ? (bool) $quiz->is_anonymous_bulk_mode : false, - 'is_public_anonymous_pool_mode' => $owner->isAdmin() ? (bool) $quiz->is_public_anonymous_pool_mode : false, - 'anonymous_pool_capacity' => $owner->isAdmin() ? $quiz->anonymous_pool_capacity : null, + 'is_anonymous_bulk_mode' => $isAnonymousBulkMode, + 'is_public_anonymous_pool_mode' => $isPublicAnonymousPoolMode, + 'anonymous_pool_capacity' => $anonymousPoolCapacity, 'student_access_policy' => $quiz->studentAccessPolicy(), 'language' => $quiz->language ?: 'auto', 'image' => $this->duplicateStoredImage($quiz->image, 'quizzes_images'), diff --git a/app/Http/Controllers/QuizParticipantController.php b/app/Http/Controllers/QuizParticipantController.php index df73028..25e24ca 100644 --- a/app/Http/Controllers/QuizParticipantController.php +++ b/app/Http/Controllers/QuizParticipantController.php @@ -187,8 +187,10 @@ public function validateStudentCode(Request $request): RedirectResponse ->with('error', __('join.student_not_found')); } + $studentDisplayName = $student->displayName($quiz); + if ($quiz->usesLearningMode()) { - $this->syncSessionForLearningMode($quiz, $student->student_code, $student->student_name); + $this->syncSessionForLearningMode($quiz, $student->student_code, $studentDisplayName); return redirect()->route('quiz.start') ->with('success', __('join.learning_mode_start')); @@ -226,7 +228,7 @@ public function validateStudentCode(Request $request): RedirectResponse $this->resetQuizRuntimeState(); Session::put('attempt_id', $ongoing->id); Session::put('student_code', $ongoing->student_code); - Session::put('student_name', $ongoing->student_name); + Session::put('student_name', $studentDisplayName); Session::put('quiz_id', $quiz->id); $this->ensureQuizRouteToken($quiz); $this->syncSessionFromAttempt($ongoing); @@ -238,7 +240,7 @@ public function validateStudentCode(Request $request): RedirectResponse $newAttempt = $quiz->attempts()->create([ 'quiz_student_id' => $student->id, 'student_code' => $request->student_code, - 'student_name' => $student->student_name, + 'student_name' => $studentDisplayName, 'max_attempts' => $maxAllowed, 'score' => 0, 'status' => QuizAttempt::STATUS_IN_PROGRESS, @@ -1003,6 +1005,33 @@ public function endQuiz(string $quizKey): View|RedirectResponse $questionOrder = Session::get('question_order', []); $totalQuestions = count($questionOrder); + if ( + $attemptId === 'guest' + && $this->sessionUsesPublicAnonymousPool($quiz) + && $questionOrder !== [] + && $this->getExpectedQuestionId($questionOrder) === null + ) { + $persistedAttempt = $this->persistPublicAnonymousPoolSubmission($quiz); + + if (! $persistedAttempt) { + $this->resetQuizRuntimeState(); + + return redirect()->route('quiz.join') + ->with('error', __('join.public_pool_slot_expired')); + } + + Session::put('attempt_id', $persistedAttempt->id); + Session::put('student_code', $persistedAttempt->student_code); + Session::put('student_name', $persistedAttempt->student_name); + Session::forget([ + 'public_anonymous_pool_reservation_id', + 'public_anonymous_pool_slot_code', + 'public_anonymous_pool_active', + ]); + $this->syncSessionFromAttempt($persistedAttempt); + $attemptId = $persistedAttempt->id; + } + $questions = $quiz->questions->keyBy('id'); $correctCount = 0; $scorePercentage = 0; @@ -1137,6 +1166,7 @@ public function studentLink(Request $request, QuizStudent $student): View|Redire App::setLocale($quiz->resolvedLocale(config('app.locale'))); $quizRouteKey = $this->ensureQuizRouteToken($quiz); + $studentDisplayName = $student->displayName($quiz); $isBot = $this->isLinkPreviewBot($request); @@ -1150,13 +1180,13 @@ public function studentLink(Request $request, QuizStudent $student): View|Redire } if ($isBot) { - $student_name = $student->student_name; + $student_name = $studentDisplayName; return view('quiz.templates.default.start', compact('quiz', 'student_name', 'quizRouteKey')); } if ($quiz->usesLearningMode()) { - $this->syncSessionForLearningMode($quiz, $student->student_code, $student->student_name); + $this->syncSessionForLearningMode($quiz, $student->student_code, $studentDisplayName); return redirect()->route('quiz.start') ->with('success', __('join.learning_mode_start')); @@ -1196,7 +1226,7 @@ public function studentLink(Request $request, QuizStudent $student): View|Redire Session::put('quiz_id', $quiz->id); Session::put('attempt_id', $existing->id); Session::put('student_code', $student->student_code); - Session::put('student_name', $student->student_name); + Session::put('student_name', $studentDisplayName); $this->ensureQuizRouteToken($quiz); $this->syncSessionFromAttempt($existing); @@ -1207,7 +1237,7 @@ public function studentLink(Request $request, QuizStudent $student): View|Redire $attempt = $quiz->attempts()->create([ 'quiz_student_id' => $student->id, 'student_code' => $student->student_code, - 'student_name' => $student->student_name, + 'student_name' => $studentDisplayName, 'max_attempts' => $student->max_attempts, 'score' => 0, 'status' => QuizAttempt::STATUS_IN_PROGRESS, @@ -1217,7 +1247,7 @@ public function studentLink(Request $request, QuizStudent $student): View|Redire Session::put('quiz_id', $quiz->id); Session::put('attempt_id', $attempt->id); Session::put('student_code', $student->student_code); - Session::put('student_name', $student->student_name); + Session::put('student_name', $studentDisplayName); $this->ensureQuizRouteToken($quiz); return redirect()->route('quiz.start') diff --git a/app/Http/Controllers/QuizTemplateController.php b/app/Http/Controllers/QuizTemplateController.php index 0f2b0ec..5af3dfb 100644 --- a/app/Http/Controllers/QuizTemplateController.php +++ b/app/Http/Controllers/QuizTemplateController.php @@ -58,7 +58,7 @@ public function create() public function store(Request $request) { $data = $request->validate([ - 'code' => 'required|alpha_dash|unique:quiz_templates,code', + 'code' => 'required|alpha_dash|not_in:shared_img|unique:quiz_templates,code', 'name' => 'required|string|max:255', 'description' => 'nullable|string', 'is_common' => 'boolean', diff --git a/app/Models/Quiz.php b/app/Models/Quiz.php index d4c4b53..b3c2304 100644 --- a/app/Models/Quiz.php +++ b/app/Models/Quiz.php @@ -281,6 +281,35 @@ public function hasLockedContent(): bool return $this->attempts()->exists(); } + /** + * Return the creator's participant quota, or null when the creator is an administrator. + */ + public function participantCapacityLimit(): ?int + { + $creator = $this->relationLoaded('creator') + ? $this->getRelation('creator') + : $this->creator()->first(); + + if (! $creator || $creator->isAdmin()) { + return null; + } + + return max(1, (int) $creator->max_students_per_quiz); + } + + /** + * Apply the creator quota to the configured public anonymous pool capacity. + */ + public function effectiveAnonymousPoolCapacity(): int + { + $configuredCapacity = max(1, (int) ($this->anonymous_pool_capacity ?? 1)); + $participantLimit = $this->participantCapacityLimit(); + + return $participantLimit === null + ? $configuredCapacity + : min($configuredCapacity, $participantLimit); + } + /** * Get the category associated with the quiz. */ diff --git a/app/Models/QuizStudent.php b/app/Models/QuizStudent.php index 1f64b6f..79043f6 100644 --- a/app/Models/QuizStudent.php +++ b/app/Models/QuizStudent.php @@ -56,6 +56,26 @@ protected static function booted(): void }); } + /** + * Build the neutral label used by pre-registered bulk-exam positions. + */ + public static function examSlotName(string $studentCode): string + { + return __('controllers.exam_slot_name', ['code' => $studentCode]); + } + + /** + * Present legacy bulk-exam placeholders with the current position terminology. + */ + public function displayName(?Quiz $quiz = null): string + { + if ($this->is_anonymous && $quiz?->is_anonymous_bulk_mode) { + return self::examSlotName($this->student_code); + } + + return $this->student_name; + } + /** * Generate a one-way hash for participant access links without persisting a raw token. */ diff --git a/composer.lock b/composer.lock index 617ba66..05862ce 100644 --- a/composer.lock +++ b/composer.lock @@ -713,16 +713,16 @@ }, { "name": "dompdf/dompdf", - "version": "v3.1.5", + "version": "v3.1.6", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", - "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005", "shasum": "" }, "require": { @@ -771,9 +771,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.6" }, - "time": "2026-03-03T13:54:37+00:00" + "time": "2026-07-20T12:29:38+00:00" }, { "name": "dompdf/php-font-lib", diff --git a/database/migrations/2026_07_27_120000_remove_retro_axd3_img_template.php b/database/migrations/2026_07_27_120000_remove_retro_axd3_img_template.php new file mode 100644 index 0000000..eef2a6b --- /dev/null +++ b/database/migrations/2026_07_27_120000_remove_retro_axd3_img_template.php @@ -0,0 +1,28 @@ +where('question_view', 'retroAXD3_img') + ->update(['question_view' => 'default_img']); + } + + if (Schema::hasTable('quiz_templates')) { + DB::table('quiz_templates') + ->where('code', 'retroAXD3_img') + ->delete(); + } + } + + public function down(): void + { + // The retired template and its executable views are intentionally not restored. + } +}; diff --git a/database/seeders/QuizTemplateSeeder.php b/database/seeders/QuizTemplateSeeder.php index d560e8b..f5a7ca8 100644 --- a/database/seeders/QuizTemplateSeeder.php +++ b/database/seeders/QuizTemplateSeeder.php @@ -22,17 +22,29 @@ public function run(): void 'description' => 'Είναι το βασικό πρότυπο με τη δυνατότητα προσθήκης εικόνων στις ερωτήσεις.', 'is_common' => true, ], + [ + 'code' => 'modern_img', + 'name' => '03.Σύγχρονο Πρότυπο Με Εικόνες και Προσαρμοζόμενες Απαντήσεις', + 'description' => 'Σύγχρονο και ήρεμο πρότυπο με εικόνες και αυτόματη διάταξη απαντήσεων ανάλογα με το μήκος τους.', + 'is_common' => false, + ], ]; foreach ($templates as $template) { - QuizTemplate::query()->updateOrCreate( - ['code' => $template['code']], - [ - 'name' => $template['name'], - 'description' => $template['description'], - 'is_common' => $template['is_common'], - ] - ); + $storedTemplate = QuizTemplate::query()->firstOrNew([ + 'code' => $template['code'], + ]); + + $storedTemplate->name = $template['name']; + $storedTemplate->description = $template['description']; + + // Seed defaults only on first installation. Re-running the seeder + // must not override visibility or user assignments chosen by admins. + if (! $storedTemplate->exists) { + $storedTemplate->is_common = $template['is_common']; + } + + $storedTemplate->save(); } } } diff --git a/docs/Installation-and-Setup.html b/docs/Installation-and-Setup.html index a8baff4..6872dfc 100644 --- a/docs/Installation-and-Setup.html +++ b/docs/Installation-and-Setup.html @@ -5,7 +5,6 @@ Installation and Setup - diff --git a/docs/Security-Privacy-and-Compliance.html b/docs/Security-Privacy-and-Compliance.html index 7ea6aaa..41318f9 100644 --- a/docs/Security-Privacy-and-Compliance.html +++ b/docs/Security-Privacy-and-Compliance.html @@ -5,7 +5,6 @@ Security, Privacy and Compliance - diff --git a/docs/admin-options.html b/docs/admin-options.html index 9548a76..414b609 100644 --- a/docs/admin-options.html +++ b/docs/admin-options.html @@ -5,7 +5,6 @@ Administrator Options - @@ -68,16 +67,16 @@

What the administrator role means

-

Administrator-only quiz settings

+

Administrator-only and quota-limited quiz settings

- Some quiz options are visible only to administrators because they affect security, public access, anonymity, or special execution flows. These include certificate verification, second-screen mode, anonymous bulk examination, and public anonymous pools. + Certificate verification and second-screen mode remain visible only to administrators. Teachers may use bulk examination with pre-registered slots and public anonymous pools in their own quizzes.

- These options are powerful and should be enabled only when the teacher understands the flow. For example, public anonymous access changes how participants enter and how results are stored. + Both teacher-accessible bulk modes enforce the account limit for participants per quiz. Active public-pool reservations count toward the limit, and the two modes cannot be enabled at the same time.

Administrator-only quiz settings -
Administrator-only quiz options control advanced public, anonymous, verification, and display-mode behavior.
+
Administrative verification/display settings remain separate from teacher-accessible, quota-limited bulk participation modes.
@@ -186,10 +185,10 @@

Responsible administrator access

roleStepTwoText: "Check access, roles, public links, and sensitive settings.", roleStepThreeTitle: "Continuity", roleStepThreeText: "Keep categories, templates, updates, and user limits consistent.", - quizTitle: "Administrator-only quiz settings", - quizTextOne: "Some quiz options are visible only to administrators because they affect security, public access, anonymity, or special execution flows. These include certificate verification, second-screen mode, anonymous bulk examination, and public anonymous pools.", - quizTextTwo: "These options are powerful and should be enabled only when the teacher understands the flow. For example, public anonymous access changes how participants enter and how results are stored.", - quizCaption: "Administrator-only quiz options control advanced public, anonymous, verification, and display-mode behavior.", + quizTitle: "Administrator-only and quota-limited quiz settings", + quizTextOne: "Certificate verification and second-screen mode remain visible only to administrators. Teachers may use bulk examination with pre-registered slots and public anonymous pools in their own quizzes.", + quizTextTwo: "Both teacher-accessible bulk modes enforce the account limit for participants per quiz. Active public-pool reservations count toward the limit, and the two modes cannot be enabled at the same time.", + quizCaption: "Administrative verification/display settings remain separate from teacher-accessible, quota-limited bulk participation modes.", categoriesTitle: "Managing categories", categoriesTextOne: "Categories help organize quizzes into clear thematic groups. Administrators can create, edit, and delete categories so the quiz collection remains easy to search and consistent across users.", categoriesTextTwo: "Before deleting a category, the administrator should check whether quizzes already use it. A category is not only a label; it helps teachers keep their material organized.", @@ -230,10 +229,10 @@

Responsible administrator access

roleStepTwoText: "Έλεγχος πρόσβασης, ρόλων, δημόσιων συνδέσμων και ευαίσθητων ρυθμίσεων.", roleStepThreeTitle: "Συνέχεια λειτουργίας", roleStepThreeText: "Συντήρηση κατηγοριών, προτύπων, ενημερώσεων και ορίων χρήσης.", - quizTitle: "Ρυθμίσεις quiz μόνο για διαχειριστές", - quizTextOne: "Ορισμένες επιλογές quiz εμφανίζονται μόνο στους διαχειριστές επειδή επηρεάζουν την ασφάλεια, τη δημόσια πρόσβαση, την ανωνυμία ή ειδικές ροές εκτέλεσης. Σε αυτές περιλαμβάνονται η δημόσια επαλήθευση βεβαιώσεων, η λειτουργία δεύτερης οθόνης, η ανώνυμη μαζική εξέταση και το δημόσιο ανώνυμο σύνολο.", - quizTextTwo: "Οι επιλογές αυτές είναι ισχυρές και πρέπει να ενεργοποιούνται μόνο όταν ο εκπαιδευτικός κατανοεί τη ροή. Για παράδειγμα, η δημόσια ανώνυμη πρόσβαση αλλάζει τον τρόπο εισόδου των συμμετεχόντων και τον τρόπο αποθήκευσης των αποτελεσμάτων.", - quizCaption: "Οι επιλογές quiz μόνο για διαχειριστές ελέγχουν προηγμένη δημόσια, ανώνυμη, επαληθεύσιμη και display-mode συμπεριφορά.", + quizTitle: "Διαχειριστικές και οριοθετημένες ρυθμίσεις quiz", + quizTextOne: "Η δημόσια επαλήθευση βεβαιώσεων και η λειτουργία δεύτερης οθόνης παραμένουν διαθέσιμες μόνο στους διαχειριστές. Οι εκπαιδευτικοί μπορούν να χρησιμοποιούν στα δικά τους quiz τη μαζική εξέταση με προκαταχωρισμένες θέσεις και το δημόσιο ανώνυμο σύνολο.", + quizTextTwo: "Και οι δύο μαζικές λειτουργίες εφαρμόζουν το όριο εξεταζομένων ανά quiz του λογαριασμού. Οι ενεργές προσωρινές θέσεις του δημόσιου συνόλου προσμετρώνται και οι δύο λειτουργίες δεν ενεργοποιούνται ταυτόχρονα.", + quizCaption: "Οι διαχειριστικές ρυθμίσεις επαλήθευσης και προβολής παραμένουν χωριστές από τις μαζικές λειτουργίες συμμετοχής των εκπαιδευτικών.", categoriesTitle: "Διαχείριση κατηγοριών", categoriesTextOne: "Οι κατηγορίες βοηθούν στην οργάνωση των quiz σε καθαρές θεματικές ομάδες. Οι διαχειριστές μπορούν να δημιουργούν, να επεξεργάζονται και να διαγράφουν κατηγορίες, ώστε η συλλογή quiz να παραμένει εύκολη στην αναζήτηση και συνεπής για όλους τους χρήστες.", categoriesTextTwo: "Πριν από τη διαγραφή μιας κατηγορίας, ο διαχειριστής πρέπει να ελέγχει αν χρησιμοποιείται ήδη από quiz. Η κατηγορία δεν είναι απλώς μια ετικέτα· βοηθά τους εκπαιδευτικούς να οργανώνουν το υλικό τους.", diff --git a/docs/assets/pages.css b/docs/assets/pages.css index 11ac0b8..0286c19 100644 --- a/docs/assets/pages.css +++ b/docs/assets/pages.css @@ -8,9 +8,6 @@ --accent-dark: #0b4d69; --accent-soft: rgba(15, 95, 128, 0.1); --green: #1f8f69; - --paypal-blue: #003087; - --paypal-light: #009cde; - --paypal-gold: #ffc439; --shadow: 0 18px 42px rgba(14, 41, 68, 0.1); } @@ -194,8 +191,7 @@ h1 { .button-guide, .button-demo, -.button-sample, -.button-support { +.button-sample { border-color: transparent; color: #fff; } @@ -212,10 +208,6 @@ h1 { background: #b56a18; } -.button-support { - background: #5f4bb6; -} - .button:hover, .tile:hover { border-color: rgba(15, 95, 128, 0.5); @@ -391,52 +383,6 @@ h1 { color: var(--ink); } -.paypal-card { - position: relative; - overflow: hidden; - padding: 24px; -} - -.paypal-card::before { - content: ""; - position: absolute; - inset: 0 0 auto; - height: 8px; - background: linear-gradient(90deg, var(--paypal-blue), var(--paypal-light), var(--paypal-gold)); -} - -.paypal-logo { - display: inline-flex; - align-items: baseline; - gap: 0; - margin-bottom: 16px; - color: var(--paypal-blue); - font-size: 30px; - font-weight: 900; - letter-spacing: 0; -} - -.paypal-logo span { - color: var(--paypal-light); -} - -.amount { - display: flex; - align-items: baseline; - gap: 8px; - margin: 14px 0 8px; - color: var(--ink); - font-size: 52px; - font-weight: 900; - line-height: 1; -} - -.amount small { - color: var(--muted); - font-size: 18px; - font-weight: 800; -} - .note { margin: 18px 0 0; color: var(--muted); diff --git a/docs/assets/pages.js b/docs/assets/pages.js index abdebf7..9bf0f44 100644 --- a/docs/assets/pages.js +++ b/docs/assets/pages.js @@ -7,8 +7,6 @@ navHomeShort: "Home", navLearn: "User Guide", navLearnShort: "Guide", - navSponsor: "Support", - navSponsorShort: "Support", backToTop: "Back to top", returnHome: "Home Page", returnHomeShort: "Back", @@ -20,8 +18,6 @@ navHomeShort: "Αρχική", navLearn: "Οδηγός Χρήσης", navLearnShort: "Οδηγός", - navSponsor: "Υποστήριξη Έργου", - navSponsorShort: "Υποστήριξη", backToTop: "Επιστροφή πάνω", returnHome: "Αρχική Σελίδα", returnHomeShort: "Επιστροφή", @@ -31,8 +27,7 @@ const navigation = { home: "index.html", - learn: "learn.html", - sponsor: "sponsor.html" + learn: "learn.html" }; function renderLayout() { @@ -54,10 +49,6 @@ User Guide Guide - - Support - Support -
diff --git a/docs/index.html b/docs/index.html index ce6dff7..ff2b651 100644 --- a/docs/index.html +++ b/docs/index.html @@ -5,7 +5,6 @@ LabSchool Exams - @@ -44,7 +43,6 @@

LabSchool Exams

User Guide Try the application Sample Quiz - Support Project
@@ -56,7 +54,7 @@

01. Installation

02. Upgrade

-

Follow the professional upgrade path from v2.0.0 to v2.1.0.

+

Follow the professional upgrade path from v2.1.4 to v2.2.0.

@@ -81,11 +79,10 @@

03. Security

learnLink: "User Guide", tryAppLink: "Try the application", tryQuizLink: "Sample Quiz", - sponsorLink: "Support Project", installTitle: "01. Installation", installText: "Set up EXAMS, configure the environment, and prepare the application for use.", upgradeTitle: "02. Upgrade", - upgradeText: "Follow the professional upgrade path from v2.0.0 to v2.1.0.", + upgradeText: "Follow the professional upgrade path from v2.1.4 to v2.2.0.", securityTitle: "03. Security", securityText: "Security, privacy, and compliance notes for operating the application responsibly." }, @@ -99,11 +96,10 @@

03. Security

learnLink: "Οδηγός Χρήσης", tryAppLink: "Δοκιμή Εφαρμογής", tryQuizLink: "Παράδειγμα Δοκιμασίας", - sponsorLink: "Υποστήριξη Έργου", installTitle: "01. Εγκατάσταση", installText: "Ρυθμίστε το EXAMS, προετοιμάστε το περιβάλλον και ετοιμάστε την εφαρμογή για χρήση.", upgradeTitle: "02. Αναβάθμιση", - upgradeText: "Ακολουθήστε τη διαδρομή αναβάθμισης από v2.0.0 σε v2.1.0.", + upgradeText: "Ακολουθήστε τη διαδρομή αναβάθμισης από v2.1.4 σε v2.2.0.", securityTitle: "03. Ασφάλεια", securityText: "Σημειώσεις ασφάλειας, απορρήτου και συμμόρφωσης για υπεύθυνη λειτουργία της εφαρμογής." } @@ -111,14 +107,12 @@

03. Security

links: { en: { learn: "learn.html", - sponsor: "sponsor.html", install: "Installation-and-Setup.html", upgrade: "upgrade-packages.html", security: "Security-Privacy-and-Compliance.html" }, el: { learn: "learn.html", - sponsor: "sponsor.html", install: "Installation-and-Setup.html", upgrade: "upgrade-packages.html", security: "Security-Privacy-and-Compliance.html" diff --git a/docs/learn.html b/docs/learn.html index c8dc279..7138363 100644 --- a/docs/learn.html +++ b/docs/learn.html @@ -5,7 +5,6 @@ LabSchool Exams User Guide - @@ -44,7 +43,6 @@

LabSchool Exams

Home Page Try the application Sample Quiz - Support Project @@ -111,7 +109,6 @@

09. Teacher Options

homeLink: "Home Page", tryAppLink: "Try the application", tryQuizLink: "Sample Quiz", - sponsorLink: "Support Project", authTitle: "01. Teacher Registration", authText: "Create a teacher account, sign in securely, verify access, recover passwords, and enter the application workspace.", quizEditorTitle: "02. Quiz Creation", @@ -141,7 +138,6 @@

09. Teacher Options

homeLink: "Αρχική Σελίδα", tryAppLink: "Δοκιμή Εφαρμογής", tryQuizLink: "Παράδειγμα Δοκιμασίας", - sponsorLink: "Υποστήριξη Έργου", authTitle: "01. Εγγραφή Εκπαιδευτικών", authText: "Δημιουργία λογαριασμού εκπαιδευτικού, ασφαλής σύνδεση, επαλήθευση πρόσβασης, ανάκτηση κωδικού και είσοδος στον χώρο εργασίας.", quizEditorTitle: "02. Δημιουργία Quiz", @@ -165,7 +161,6 @@

09. Teacher Options

links: { en: { home: "index.html", - sponsor: "sponsor.html", registerLogin: "register-login.html", quizCreateEdit: "quiz-create-edit.html", quizQuestionsAnswers: "quiz-questions-answers.html", @@ -178,7 +173,6 @@

09. Teacher Options

}, el: { home: "index.html", - sponsor: "sponsor.html", registerLogin: "register-login.html", quizCreateEdit: "quiz-create-edit.html", quizQuestionsAnswers: "quiz-questions-answers.html", diff --git a/docs/quiz-create-edit.html b/docs/quiz-create-edit.html index 627fde9..39104d0 100644 --- a/docs/quiz-create-edit.html +++ b/docs/quiz-create-edit.html @@ -4,8 +4,7 @@ Quiz Creation and Editing - - + @@ -15,7 +14,7 @@ - + @@ -25,7 +24,7 @@ - + @@ -158,9 +157,9 @@

Editing an existing quiz

-

Administrator experimental options

+

Bulk participation and administrator options

- Administrators see an extra group of experimental options. These are powerful modes for special scenarios, so they are intentionally not shown to regular teachers. + Teachers can choose either bulk examination with pre-registered slots or a public anonymous pool. Both modes enforce the account participant limit and cannot be combined.

@@ -172,16 +171,16 @@

Administrator experimental options

Starts a registered examinee on a shared screen while answers are given from the connected mobile controller.

- Anonymous modes -

Anonymous bulk mode creates anonymous positions. Public anonymous pool mode uses one public link with a defined capacity.

+ Special participation modes +

Bulk examination creates pre-registered exam slots with unique PINs and QR Codes. Public anonymous pool mode uses one public link with a defined capacity.

-

Important: anonymous bulk mode and public anonymous pool mode cannot be enabled together. Learning mode cannot be combined with the anonymous modes. Second-screen mode also cannot be combined with learning mode or anonymous modes.

+

Important: bulk examination with pre-registered slots and public anonymous pool mode cannot be enabled together. Learning and second-screen modes cannot be combined with either of these special participation modes.

- Administrator experimental quiz options -
Experimental administrator-only options and public anonymous pool capacity.
+ Bulk participation and administrator quiz options +
Quota-limited bulk participation is available to teachers, while certificate verification and second-screen mode remain administrator-only.
@@ -194,7 +193,7 @@

Administrator experimental options

translations: { en: { pageTitle: "Quiz Creation and Editing", - description: "How educators create and edit quizzes in LabSchool Exams, including teacher options and administrator experimental modes.", + description: "How educators create and edit quizzes in LabSchool Exams, including quota-limited bulk participation and administrator-only controls.", eyebrow: "Second guide", title: "Quiz Creation and Editing", lead: "This page explains how a teacher creates a quiz, what each group of options controls, and which extra experimental modes are available only to administrators.", @@ -234,16 +233,16 @@

Administrator experimental options

editTextOne: "Editing uses almost the same form as creation. The teacher can update the title, description, category, timing, template, language, status, access rules, and image.", editTextTwo: "If a quiz already has submitted or locked content, the application may restrict content changes. In that case, only safer operational settings, such as status and selected administrator controls, remain editable.", editCaption: "The edit page keeps the same structure so the teacher can find the same settings again.", - adminTitle: "Administrator experimental options", - adminTextOne: "Administrators see an extra group of experimental options. These are powerful modes for special scenarios, so they are intentionally not shown to regular teachers.", + adminTitle: "Bulk participation and administrator options", + adminTextOne: "Teachers can choose either bulk examination with pre-registered slots or a public anonymous pool. Both modes enforce the account participant limit and cannot be combined.", certificateTitle: "Public certificate verification", certificateText: "Adds QR/public verification for certificates. The document remains evidence of assessment, not an official certification.", secondScreenTitle: "Second screen and mobile controller", secondScreenText: "Starts a registered examinee on a shared screen while answers are given from the connected mobile controller.", - anonymousTitle: "Anonymous modes", - anonymousText: "Anonymous bulk mode creates anonymous positions. Public anonymous pool mode uses one public link with a defined capacity.", - adminNote: "Important: anonymous bulk mode and public anonymous pool mode cannot be enabled together. Learning mode cannot be combined with the anonymous modes. Second-screen mode also cannot be combined with learning mode or anonymous modes.", - adminCaption: "Experimental administrator-only options and public anonymous pool capacity." + anonymousTitle: "Special participation modes", + anonymousText: "Bulk examination creates pre-registered exam slots with unique PINs and QR Codes. Public anonymous pool mode uses one public link with a defined capacity.", + adminNote: "Important: bulk examination with pre-registered slots and public anonymous pool mode cannot be enabled together. Learning and second-screen modes cannot be combined with either of these special participation modes.", + adminCaption: "Quota-limited bulk participation is available to teachers, while certificate verification and second-screen mode remain administrator-only." }, el: { pageTitle: "Δημιουργία και Επεξεργασία Quiz", @@ -287,16 +286,16 @@

Administrator experimental options

editTextOne: "Η επεξεργασία χρησιμοποιεί σχεδόν την ίδια φόρμα με τη δημιουργία. Ο εκπαιδευτικός μπορεί να αλλάξει τίτλο, περιγραφή, κατηγορία, χρόνο, πρότυπο, γλώσσα, κατάσταση, κανόνες πρόσβασης και εικόνα.", editTextTwo: "Αν ένα quiz έχει ήδη υποβολές ή κλειδωμένο περιεχόμενο, η εφαρμογή μπορεί να περιορίσει τις αλλαγές περιεχομένου. Τότε παραμένουν διαθέσιμες κυρίως ασφαλέστερες λειτουργικές ρυθμίσεις, όπως η κατάσταση και ορισμένες επιλογές διαχειριστή.", editCaption: "Η σελίδα επεξεργασίας κρατά την ίδια δομή, ώστε ο εκπαιδευτικός να βρίσκει ξανά τις ίδιες ρυθμίσεις.", - adminTitle: "Πειραματικές επιλογές διαχειριστή", - adminTextOne: "Οι διαχειριστές βλέπουν μια επιπλέον ομάδα πειραματικών επιλογών. Είναι ισχυρές λειτουργίες για ειδικά σενάρια, γι' αυτό δεν εμφανίζονται στους απλούς εκπαιδευτικούς.", + adminTitle: "Μαζική συμμετοχή και επιλογές διαχειριστή", + adminTextOne: "Οι εκπαιδευτικοί μπορούν να επιλέξουν είτε μαζική εξέταση με προκαταχωρισμένες θέσεις είτε δημόσιο ανώνυμο σύνολο. Και οι δύο λειτουργίες εφαρμόζουν το όριο εξεταζομένων του λογαριασμού και δεν συνδυάζονται.", certificateTitle: "Δημόσια επαλήθευση βεβαίωσης", certificateText: "Προσθέτει QR και δημόσιο έλεγχο γνησιότητας για βεβαιώσεις. Το έγγραφο παραμένει βεβαίωση αξιολόγησης και όχι επίσημη πιστοποίηση.", secondScreenTitle: "Τηλεόραση και χειριστήριο κινητού", secondScreenText: "Ξεκινά έναν καταχωρημένο εξεταζόμενο σε κοινή οθόνη, ενώ οι απαντήσεις δίνονται από συνδεδεμένο κινητό χειριστήριο.", - anonymousTitle: "Ανώνυμες λειτουργίες", - anonymousText: "Η ανώνυμη μαζική εξέταση δημιουργεί ανώνυμες θέσεις. Το δημόσιο ανώνυμο σύνολο χρησιμοποιεί έναν δημόσιο σύνδεσμο με συγκεκριμένη χωρητικότητα.", - adminNote: "Σημαντικό: η ανώνυμη μαζική εξέταση και το δημόσιο ανώνυμο σύνολο δεν μπορούν να ενεργοποιηθούν μαζί. Η λειτουργία εκμάθησης δεν συνδυάζεται με τις ανώνυμες λειτουργίες. Η λειτουργία δεύτερης οθόνης επίσης δεν συνδυάζεται με learning mode ή ανώνυμες λειτουργίες.", - adminCaption: "Πειραματικές επιλογές μόνο για διαχειριστές και χωρητικότητα δημόσιου ανώνυμου συνόλου." + anonymousTitle: "Ειδικές λειτουργίες συμμετοχής", + anonymousText: "Η μαζική εξέταση δημιουργεί προκαταχωρισμένες θέσεις με μοναδικά PIN και QR Codes. Το δημόσιο ανώνυμο σύνολο χρησιμοποιεί έναν δημόσιο σύνδεσμο με συγκεκριμένη χωρητικότητα.", + adminNote: "Σημαντικό: η μαζική εξέταση με προκαταχωρισμένες θέσεις και το δημόσιο ανώνυμο σύνολο δεν μπορούν να ενεργοποιηθούν μαζί. Η λειτουργία εκμάθησης και η λειτουργία δεύτερης οθόνης δεν συνδυάζονται με καμία από αυτές τις ειδικές λειτουργίες συμμετοχής.", + adminCaption: "Η μαζική συμμετοχή με όριο είναι διαθέσιμη στους εκπαιδευτικούς, ενώ η επαλήθευση βεβαιώσεων και η δεύτερη οθόνη παραμένουν μόνο για διαχειριστές." } } }; diff --git a/docs/quiz-flow.html b/docs/quiz-flow.html index 0d29b40..d652751 100644 --- a/docs/quiz-flow.html +++ b/docs/quiz-flow.html @@ -5,7 +5,6 @@ Quiz Execution and Flow - diff --git a/docs/quiz-questions-answers.html b/docs/quiz-questions-answers.html index fff79c8..91dee15 100644 --- a/docs/quiz-questions-answers.html +++ b/docs/quiz-questions-answers.html @@ -5,7 +5,6 @@ Quiz Questions and Answers - diff --git a/docs/quiz-results.html b/docs/quiz-results.html index 8a99140..a943f26 100644 --- a/docs/quiz-results.html +++ b/docs/quiz-results.html @@ -5,7 +5,6 @@ Results and Statistics - diff --git a/docs/quiz-template-smoke-checklist.md b/docs/quiz-template-smoke-checklist.md index 74d04f6..1166235 100644 --- a/docs/quiz-template-smoke-checklist.md +++ b/docs/quiz-template-smoke-checklist.md @@ -6,11 +6,7 @@ Use this checklist after template, CSP, or runtime-JS changes. - `default` - `default_img` -- `newdefault` -- `alexpolis_img` -- `exakoustou_img` -- `retropc_img` -- `uni_img` +- `modern_img` ## Entry Flow Per Template @@ -53,4 +49,4 @@ For each template family: - Confirm no page uses inline ` - - - diff --git a/docs/teacher-options.html b/docs/teacher-options.html index c4296a3..7303010 100644 --- a/docs/teacher-options.html +++ b/docs/teacher-options.html @@ -5,7 +5,6 @@ Teacher Options - diff --git a/docs/upgrade-packages.html b/docs/upgrade-packages.html index 14eb641..e0ed15d 100644 --- a/docs/upgrade-packages.html +++ b/docs/upgrade-packages.html @@ -5,7 +5,6 @@ Upgrade Packages - @@ -46,17 +45,17 @@

Upgrade Packages

What the upgrade system provides

LabSchool Exams releases can provide two package types. The full package is used for a fresh installation or full application replacement. The incremental upgrade package is smaller and is used only when the installed version matches the supported starting version.

-

The current supported incremental path is v2.1.3 to v2.1.4.

+

The current supported incremental path is v2.1.4 to v2.2.0.

-

Packages in a v2.1.4 release

-

For v2.1.4, the release workflow publishes these files.

-
labschool-exams-v2.1.4-full.zip
-labschool-exams-v2.1.4-full.zip.sha256
-labschool-exams-v2.1.3-to-v2.1.4-upgrade.zip
-labschool-exams-v2.1.3-to-v2.1.4-upgrade.zip.sha256
+                

Packages in a v2.2.0 release

+

For v2.2.0, the release workflow publishes these files.

+
labschool-exams-v2.2.0-full.zip
+labschool-exams-v2.2.0-full.zip.sha256
+labschool-exams-v2.1.4-to-v2.2.0-upgrade.zip
+labschool-exams-v2.1.4-to-v2.2.0-upgrade.zip.sha256
 update.json
  • Use the full package when you install from scratch or when no matching incremental package exists.
  • @@ -85,7 +84,7 @@

    A. Before upgrading

    Treat every upgrade like a short maintenance procedure. Do not start by copying files. First confirm the version, take backups, and make sure you can return to the previous state if something unexpected happens.

    1. Check the installed application version from the footer, the Update Center, APP_VERSION, or the packaged VERSION file.
    2. -
    3. Confirm that the upgrade package starts from that exact version. For example, use v2.1.3-to-v2.1.4 only on an installed v2.1.3 instance.
    4. +
    5. Confirm that the upgrade package starts from that exact version. For example, use v2.1.4-to-v2.2.0 only on an installed v2.1.4 instance.
    6. Back up the database before changing files or running migrations.
    7. Back up the application files, especially .env, storage/, and public/storage.
    8. Choose a quiet time window, because users should not be taking quizzes while the update is being applied.
    9. @@ -164,9 +163,9 @@

      E. Verification after the upgrade

      verifyButton: "Verification", whatTitle: "What the upgrade system provides", whatText: "LabSchool Exams releases can provide two package types. The full package is used for a fresh installation or full application replacement. The incremental upgrade package is smaller and is used only when the installed version matches the supported starting version.", - firstPath: "The current supported incremental path is v2.1.3 to v2.1.4.", - packagesTitle: "Packages in a v2.1.4 release", - packagesIntro: "For v2.1.4, the release workflow publishes these files.", + firstPath: "The current supported incremental path is v2.1.4 to v2.2.0.", + packagesTitle: "Packages in a v2.2.0 release", + packagesIntro: "For v2.2.0, the release workflow publishes these files.", packageFull: "Use the full package when you install from scratch or when no matching incremental package exists.", packageUpgrade: "Use the upgrade package only when the currently installed version is exactly the package from_version.", packageHash: "Use the SHA-256 checksum file to confirm that the downloaded package was not corrupted.", @@ -181,7 +180,7 @@

      E. Verification after the upgrade

      beforeTitle: "A. Before upgrading", beforeIntro: "Treat every upgrade like a short maintenance procedure. Do not start by copying files. First confirm the version, take backups, and make sure you can return to the previous state if something unexpected happens.", beforeVersion: "Check the installed application version from the footer, the Update Center, APP_VERSION, or the packaged VERSION file.", - beforeMatch: "Confirm that the upgrade package starts from that exact version. For example, use v2.1.3-to-v2.1.4 only on an installed v2.1.3 instance.", + beforeMatch: "Confirm that the upgrade package starts from that exact version. For example, use v2.1.4-to-v2.2.0 only on an installed v2.1.4 instance.", beforeBackupDb: "Back up the database before changing files or running migrations.", beforeBackupFiles: "Back up the application files, especially .env, storage/, and public/storage.", beforeWindow: "Choose a quiet time window, because users should not be taking quizzes while the update is being applied.", @@ -222,9 +221,9 @@

      E. Verification after the upgrade

      verifyButton: "Έλεγχος", whatTitle: "Τι προσφέρει το σύστημα αναβάθμισης;", whatText: "Οι εκδόσεις του LabSchool Exams μπορούν να παρέχουν δύο τύπους πακέτων. Το πλήρες πακέτο χρησιμοποιείται για νέα εγκατάσταση ή πλήρη αντικατάσταση της εφαρμογής. Το incremental πακέτο είναι μικρότερο και χρησιμοποιείται μόνο όταν η εγκατεστημένη έκδοση ταιριάζει με την υποστηριζόμενη αρχική έκδοση.", - firstPath: "Η τρέχουσα υποστηριζόμενη incremental διαδρομή είναι από v2.1.3 σε v2.1.4.", - packagesTitle: "Πακέτα σε έκδοση v2.1.4", - packagesIntro: "Για την v2.1.4, το release workflow δημοσιεύει τα παρακάτω αρχεία.", + firstPath: "Η τρέχουσα υποστηριζόμενη incremental διαδρομή είναι από v2.1.4 σε v2.2.0.", + packagesTitle: "Πακέτα σε έκδοση v2.2.0", + packagesIntro: "Για την v2.2.0, το release workflow δημοσιεύει τα παρακάτω αρχεία.", packageFull: "Χρησιμοποιήστε το πλήρες πακέτο όταν εγκαθιστάτε από την αρχή ή όταν δεν υπάρχει incremental πακέτο που να ταιριάζει.", packageUpgrade: "Χρησιμοποιήστε το πακέτο αναβάθμισης μόνο όταν η τρέχουσα εγκατεστημένη έκδοση είναι ακριβώς το from_version του πακέτου.", packageHash: "Χρησιμοποιήστε το SHA-256 checksum για να επιβεβαιώσετε ότι το αρχείο που κατεβάσατε δεν αλλοιώθηκε.", @@ -239,7 +238,7 @@

      E. Verification after the upgrade

      beforeTitle: "Α. Πριν την αναβάθμιση", beforeIntro: "Αντιμετωπίστε κάθε αναβάθμιση σαν σύντομη διαδικασία συντήρησης. Μην ξεκινάτε αντιγράφοντας αρχεία. Πρώτα επιβεβαιώστε την έκδοση, πάρτε αντίγραφα ασφαλείας και βεβαιωθείτε ότι μπορείτε να επιστρέψετε στην προηγούμενη κατάσταση αν συμβεί κάτι απρόβλεπτο.", beforeVersion: "Ελέγξτε την εγκατεστημένη έκδοση από το footer, το Update Center, το APP_VERSION ή το αρχείο VERSION του πακέτου.", - beforeMatch: "Επιβεβαιώστε ότι το πακέτο αναβάθμισης ξεκινά από αυτή ακριβώς την έκδοση. Για παράδειγμα, χρησιμοποιήστε v2.1.3-to-v2.1.4 μόνο σε εγκατάσταση v2.1.3.", + beforeMatch: "Επιβεβαιώστε ότι το πακέτο αναβάθμισης ξεκινά από αυτή ακριβώς την έκδοση. Για παράδειγμα, χρησιμοποιήστε v2.1.4-to-v2.2.0 μόνο σε εγκατάσταση v2.1.4.", beforeBackupDb: "Πάρτε αντίγραφο ασφαλείας της βάσης πριν αλλάξετε αρχεία ή τρέξετε migrations.", beforeBackupFiles: "Πάρτε αντίγραφο ασφαλείας των αρχείων της εφαρμογής, ειδικά των .env, storage/ και public/storage.", beforeWindow: "Επιλέξτε ήσυχο χρονικό παράθυρο, γιατί οι χρήστες δεν πρέπει να κάνουν δοκιμασίες ενώ εφαρμόζεται η ενημέρωση.", diff --git a/docs/upgrade-packages.md b/docs/upgrade-packages.md index b2d2735..6961b9c 100644 --- a/docs/upgrade-packages.md +++ b/docs/upgrade-packages.md @@ -8,18 +8,18 @@ EXAMS releases can ship two package types: The current supported incremental path is: ```text -v2.1.3 -> v2.1.4 +v2.1.4 -> v2.2.0 ``` ## Package Names -For `v2.1.4`, the release workflow publishes: +For `v2.2.0`, the release workflow publishes: ```text -labschool-exams-v2.1.4-full.zip -labschool-exams-v2.1.4-full.zip.sha256 -labschool-exams-v2.1.3-to-v2.1.4-upgrade.zip -labschool-exams-v2.1.3-to-v2.1.4-upgrade.zip.sha256 +labschool-exams-v2.2.0-full.zip +labschool-exams-v2.2.0-full.zip.sha256 +labschool-exams-v2.1.4-to-v2.2.0-upgrade.zip +labschool-exams-v2.1.4-to-v2.2.0-upgrade.zip.sha256 update.json ``` @@ -27,7 +27,7 @@ update.json The incremental package contains: -- files added, copied, renamed, or modified between `v2.1.3` and `v2.1.4` +- files added, copied, renamed, or modified between `v2.1.4` and `v2.2.0` - built frontend assets under `public/build` - a top-level `VERSION` file for the target version - `upgrade-manifest.json` diff --git a/resources/css/app.css b/resources/css/app.css index dbcbf78..f63cd3a 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -933,6 +933,27 @@ h1, h2, h3, h4, h5, h6 { box-shadow: 0 0 0 0.25rem rgba(26, 106, 143, 0.12); } +.dashboard-copy-control { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: stretch; + gap: 0.75rem; +} + +.dashboard-copy-control--with-actions { + grid-template-columns: minmax(0, 1fr) auto auto; +} + +.dashboard-copy-control .dashboard-form-control { + min-width: 0; +} + +.dashboard-copy-control__button { + min-width: 9rem; + min-height: 3.25rem; + white-space: nowrap; +} + .dashboard-form-check { padding: 0.95rem 1rem 0.95rem 2.5rem; border: 1px solid rgba(24, 49, 83, 0.08); @@ -1632,6 +1653,18 @@ h1, h2, h3, h4, h5, h6 { padding: 1.1rem; } + .dashboard-copy-control { + grid-template-columns: minmax(0, 1fr); + } + + .dashboard-copy-control--with-actions { + grid-template-columns: minmax(0, 1fr); + } + + .dashboard-copy-control__button { + width: 100%; + } + .dashboard-page-header--update-center .dashboard-page-header__top { flex-direction: column; align-items: flex-start; diff --git a/resources/js/app.js b/resources/js/app.js index 75e25a8..a9bd2a9 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1017,6 +1017,10 @@ function initQuizQuestionRuntime() { }; const submitQuizForm = (event) => { + const submitter = event?.submitter instanceof HTMLElement + ? event.submitter + : document.activeElement; + if (event) { event.preventDefault(); } @@ -1031,9 +1035,8 @@ function initQuizQuestionRuntime() { button.disabled = true; }); - const activeButton = document.activeElement; - if (activeButton instanceof HTMLElement && activeButton.hasAttribute('formaction')) { - form.setAttribute('action', activeButton.getAttribute('formaction') ?? form.action); + if (submitter instanceof HTMLElement && submitter.hasAttribute('formaction')) { + form.setAttribute('action', submitter.getAttribute('formaction') ?? form.action); } if (isFirefox && lastQuestion) { @@ -1658,68 +1661,6 @@ function initQuizDisplayController() { }); } -function initRetroScreenTransitions() { - document.querySelectorAll('.retro-screen').forEach((screen) => { - if (screen.dataset.retroTransitionsBound === 'true') { - return; - } - - const beginExit = () => { - if (screen.classList.contains('retro-screen--exiting')) { - return; - } - - screen.classList.add('retro-screen--exiting'); - }; - - screen.dataset.retroTransitionsBound = 'true'; - - screen.querySelectorAll('a[href]').forEach((anchor) => { - anchor.addEventListener('click', (event) => { - if ( - event.defaultPrevented || - anchor.target === '_blank' || - anchor.hasAttribute('download') || - anchor.getAttribute('href')?.startsWith('#') || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey || - event.button !== 0 - ) { - return; - } - - event.preventDefault(); - beginExit(); - window.setTimeout(() => { - window.location.href = anchor.href; - }, 170); - }); - }); - - screen.querySelectorAll('form').forEach((form) => { - if ( - form.matches('[data-quiz-answer-form], [data-quiz-skip-form]') || - form.closest('[data-quiz-question-runtime]') - ) { - return; - } - - form.addEventListener('submit', (event) => { - if (form.dataset.retroSubmitting === 'true') { - return; - } - - event.preventDefault(); - form.dataset.retroSubmitting = 'true'; - beginExit(); - window.setTimeout(() => form.submit(), 170); - }); - }); - }); -} - document.addEventListener('DOMContentLoaded', () => { initQuizParticipantDisclaimer(); initToasts(); @@ -1745,5 +1686,4 @@ document.addEventListener('DOMContentLoaded', () => { initQuizQuestionRuntime(); initQuizDisplayScreen(); initQuizDisplayController(); - initRetroScreenTransitions(); }); diff --git a/resources/lang/el/about.php b/resources/lang/el/about.php index a8a66e6..0fbf42f 100644 --- a/resources/lang/el/about.php +++ b/resources/lang/el/about.php @@ -42,7 +42,7 @@
    10. Συμμετοχή επισκέπτη χωρίς λογαριασμό όπου αυτό επιτρέπεται
    11. Δημιουργία προσωποποιημένων συνδέσμων συμμετοχής για συγκεκριμένους εξεταζόμενους
    12. Δημόσια quizzes μέσω καταλόγου και άμεση συμμετοχή από επισκέπτες
    13. -
    14. Ανώνυμη μαζική εξέταση με αυτόματη δημιουργία θέσεων και τετραψήφιων κωδικών
    15. +
    16. Μαζική εξέταση με προκαταχωρισμένες θέσεις, μοναδικά PIN και QR Codes
    17. Δημόσιο ανώνυμο pool με ελεγχόμενη χωρητικότητα για ανοιχτές συμμετοχές
    18. Λειτουργία εκμάθησης χωρίς μόνιμη καταγραφή προσπαθειών και βαθμολογιών
diff --git a/resources/lang/el/controllers.php b/resources/lang/el/controllers.php index c51a244..c3b03bf 100644 --- a/resources/lang/el/controllers.php +++ b/resources/lang/el/controllers.php @@ -19,13 +19,20 @@ 'student_old_attempts_deleted' => 'Οι προηγούμενες προσπάθειες του/της εξεταζόμενου/ης διαγράφηκαν.', 'student_completely_deleted' => 'Ο/Η εξεταζόμενος/η και όλες οι σχετικές προσπάθειες διαγράφηκαν.', 'anonymous_student_name' => 'Ανώνυμος/η εξεταζόμενος/η', - 'anonymous_slots_created' => 'Δημιουργήθηκαν επιτυχώς :count ανώνυμες θέσεις εξεταζομένων.', - 'anonymous_slots_unavailable' => 'Δεν υπάρχουν αρκετοί διαθέσιμοι μοναδικοί τετραψήφιοι κωδικοί για να δημιουργηθούν οι ζητούμενες ανώνυμες θέσεις.', - 'anonymous_bulk_mode_manual_disabled' => 'Αυτό το quiz χρησιμοποιεί ανώνυμη μαζική εξέταση. Δημιουργήστε ανώνυμες θέσεις αντί για ονομαστικές εγγραφές.', - 'anonymous_bulk_mode_disabled' => 'Η ανώνυμη μαζική εξέταση δεν είναι ενεργή για αυτό το quiz.', + 'exam_slot_name' => 'Θέση Εξέτασης :code', + 'anonymous_slots_created' => 'Δημιουργήθηκαν επιτυχώς :count προκαταχωρισμένες θέσεις εξέτασης.', + 'anonymous_slots_unavailable' => 'Δεν υπάρχουν αρκετοί διαθέσιμοι μοναδικοί τετραψήφιοι κωδικοί για να δημιουργηθούν οι ζητούμενες θέσεις εξέτασης.', + 'anonymous_bulk_mode_manual_disabled' => 'Αυτό το quiz χρησιμοποιεί μαζική εξέταση με προκαταχωρισμένες θέσεις. Δημιουργήστε θέσεις εξέτασης αντί για ονομαστικές εγγραφές.', + 'anonymous_bulk_mode_disabled' => 'Η μαζική εξέταση με προκαταχωρισμένες θέσεις δεν είναι ενεργή για αυτό το quiz.', 'public_anonymous_pool_manual_disabled' => 'Αυτό το quiz χρησιμοποιεί Δημόσιο Ανώνυμο Σύνολο Εξεταζόμενων. Οι εγγραφές εξεταζομένων δημιουργούνται αυτόματα μόνο μετά την τελική υποβολή.', - 'special_modes_conflict' => 'Επιλέξτε είτε Ανώνυμη Μαζική Εξέταση είτε Δημόσιο Ανώνυμο Σύνολο Εξεταζόμενων, όχι και τα δύο μαζί.', - 'learning_mode_special_modes_conflict' => 'Η λειτουργία εκμάθησης δεν μπορεί να συνδυαστεί με Ανώνυμη Μαζική Εξέταση ή Δημόσιο Ανώνυμο Σύνολο Εξεταζόμενων.', + 'public_anonymous_pool_mode_disabled' => 'Η πρόσκληση PDF είναι διαθέσιμη μόνο για quiz με ενεργοποιημένο Δημόσιο Ανώνυμο Σύνολο Εξεταζόμενων.', + 'public_anonymous_pool_invitation_inactive' => 'Ενεργοποιήστε πρώτα το quiz για να δημιουργήσετε πρόσκληση συμμετοχής.', + 'public_anonymous_pool_invitation_expired' => 'Ο δημόσιος σύνδεσμος αυτής της πρόσκλησης έχει λήξει. Ανανεώστε τη σελίδα και δημιουργήστε νέο PDF.', + 'public_anonymous_pool_link_unavailable' => 'Δεν ήταν δυνατή η δημιουργία του δημόσιου συνδέσμου συμμετοχής.', + 'special_modes_conflict' => 'Επιλέξτε είτε Μαζική Εξέταση με Προκαταχωρισμένες Θέσεις είτε Δημόσιο Ανώνυμο Σύνολο Εξεταζόμενων, όχι και τα δύο μαζί.', + 'learning_mode_special_modes_conflict' => 'Η λειτουργία εκμάθησης δεν μπορεί να συνδυαστεί με Μαζική Εξέταση με Προκαταχωρισμένες Θέσεις ή Δημόσιο Ανώνυμο Σύνολο Εξεταζόμενων.', + 'anonymous_pool_capacity_limit' => 'Η χωρητικότητα δεν μπορεί να υπερβαίνει το όριο των :max εξεταζομένων ανά quiz του λογαριασμού σας.', + 'participation_mode_has_students' => 'Ο τρόπος μαζικής συμμετοχής δεν μπορεί να αλλάξει όσο υπάρχουν καταχωρισμένες θέσεις ή εξεταζόμενοι. Αφαιρέστε πρώτα τις εγγραφές.', 'quiz_content_locked' => 'Αυτό το quiz έχει ήδη καταγεγραμμένες προσπάθειες. Διαγράψτε όλες τις προσπάθειες πριν από οποιαδήποτε αλλαγή περιεχομένου.', 'quiz_content_locked_status_only' => 'Αυτό το quiz έχει ήδη καταγεγραμμένες προσπάθειες. Μέχρι να διαγραφούν όλες, μπορεί να αλλάξει μόνο η κατάστασή του.', @@ -80,7 +87,7 @@ 'user_deleted_successfully' => 'Ο χρήστης διαγράφηκε επιτυχώς.', 'user_created_successfully' => 'Ο χρήστης δημιουργήθηκε επιτυχώς.', 'quiz_duplicated' => 'Το quiz αντιγράφηκε στη συλλογή σας ως νέο πρόχειρο quiz.', - 'second_screen_conflict' => 'Η λειτουργία Τηλεόρασης + Χειριστήριο Κινητού δεν μπορεί να συνδυαστεί με λειτουργία εκμάθησης, Ανώνυμη Μαζική Εξέταση ή Δημόσιο Ανώνυμο Σύνολο.', + 'second_screen_conflict' => 'Η λειτουργία Τηλεόρασης + Χειριστήριο Κινητού δεν μπορεί να συνδυαστεί με λειτουργία εκμάθησης, Μαζική Εξέταση με Προκαταχωρισμένες Θέσεις ή Δημόσιο Ανώνυμο Σύνολο.', 'second_screen_disabled' => 'Η λειτουργία Τηλεόρασης + Χειριστήριο Κινητού δεν είναι ενεργή για αυτό το quiz.', 'second_screen_registered_only' => 'Η λειτουργία Τηλεόρασης + Χειριστήριο Κινητού είναι διαθέσιμη μόνο για καταχωρημένους ονομαστικούς εξεταζόμενους.', 'second_screen_attempt_limit_reached' => 'Ο εξεταζόμενος έχει ήδη φτάσει το επιτρεπόμενο πλήθος προσπαθειών για το TV mode.', diff --git a/resources/lang/el/dashboard.php b/resources/lang/el/dashboard.php index 7cbf2bb..18453c3 100644 --- a/resources/lang/el/dashboard.php +++ b/resources/lang/el/dashboard.php @@ -10,6 +10,7 @@ 'subtitle' => 'LabSchool Exams', 'overview_text' => 'Ένας γρήγορος τρόπος για να δείτε τη δραστηριότητα των δοκιμασιών που δημιουργήσατε με την εφαρμογή μας.', 'my_active_quizzes' => 'Προσωπική Συλλογή Quizzes', + 'all_quizzes_collection' => 'Συλλογή Όλων των Quizzes', 'table_created_at' => 'Ημ/νία Δημιουργίας', 'table_title' => 'Τίτλος Δοκιμασίας Αξιολόγησης (Quiz)', 'table_students' => 'Σύνολο Εξεταζομένων', @@ -32,6 +33,7 @@ 'total_questions' => 'Σύνολο Ερωτήσεων', 'total_students' => 'Σύνολο Εξεταζομένων', 'quiz_collection_intro' => 'Η προσωπική σας συλλογή με τα βασικά στοιχεία κάθε quiz, ώστε να έχετε άμεση εικόνα της δραστηριότητας.', + 'all_quizzes_collection_intro' => 'Όλα τα quiz της εγκατάστασης, μαζί με τον δημιουργό και τα βασικά στοιχεία διαχείρισής τους.', 'updates_intro' => 'Πρόσφατες ενημερώσεις της εφαρμογής, ανακοινώσεις και χρήσιμες αναφορές για τον εκπαιδευτικό.', 'resources_eyebrow' => 'Υποστήριξη και Υλικό', 'resources_title' => 'Οδηγός Εκπαιδευτικού και Αναφορά', diff --git a/resources/lang/el/join.php b/resources/lang/el/join.php index cb73551..cb4f2ea 100644 --- a/resources/lang/el/join.php +++ b/resources/lang/el/join.php @@ -98,4 +98,6 @@ 'participant_notice_guest' => 'Το περιεχόμενο έχει δημιουργηθεί από τον κάτοχο της δοκιμασίας. Για την ασφαλή λειτουργία της εξέτασης χρησιμοποιούνται απαραίτητα cookies.', 'participant_notice_aria' => 'Ενημέρωση συμμετοχής στη δοκιμασία', 'participant_notice_dismiss' => 'Απόκρυψη ενημέρωσης συμμετοχής', + 'template_media_notice' => 'Οι εικόνες και τα στιγμιότυπα προβάλλονται σε χαμηλή ανάλυση υπό το πρίσμα της «εύλογης χρήσης» (fair use)', + 'template_media_notice_aria' => 'Ενημέρωση για τη χρήση εικόνων', ]; diff --git a/resources/lang/el/pdfexp.php b/resources/lang/el/pdfexp.php index 7d78f0b..87361d2 100644 --- a/resources/lang/el/pdfexp.php +++ b/resources/lang/el/pdfexp.php @@ -82,4 +82,5 @@ 'students_list_title' => 'Κατάλογος Εξεταζόμενων', 'student_list' => 'Πληροφορίες Εξεταζόμενων', 'issue_date' => 'Ημερομηνία έκδοσης:', + 'registered_students_count' => 'Σύνολο εξεταζόμενων', ]; diff --git a/resources/lang/el/public_pool_invitation.php b/resources/lang/el/public_pool_invitation.php new file mode 100644 index 0000000..e14ec05 --- /dev/null +++ b/resources/lang/el/public_pool_invitation.php @@ -0,0 +1,15 @@ + 'Πρόσκληση Συμμετοχής', + 'eyebrow' => 'Ανώνυμη εξέταση αξιολόγησης γνώσεων', + 'educator' => 'Εκπαιδευτικός', + 'duration' => 'Διάρκεια', + 'minutes' => ':count λεπτά', + 'capacity' => 'Μέγιστος αριθμός συμμετοχών', + 'instruction' => 'Σαρώστε το QR Code ή ανοίξτε τον παρακάτω σύνδεσμο', + 'anonymous_notice' => 'Η συμμετοχή πραγματοποιείται χωρίς καταχώριση ονοματεπωνύμου. Το σύστημα εκχωρεί προσωρινή ανώνυμη θέση και αποθηκεύει το αποτέλεσμα μόνο μετά την τελική υποβολή.', + 'expires_at' => 'Ο σύνδεσμος είναι διαθέσιμος έως :date.', + 'no_expiration' => 'Ο σύνδεσμος δεν έχει προκαθορισμένη ημερομηνία λήξης.', + 'qr_alt' => 'QR Code συμμετοχής στην εξέταση', +]; diff --git a/resources/lang/el/quizzes.php b/resources/lang/el/quizzes.php index a5ce79f..f7b431a 100644 --- a/resources/lang/el/quizzes.php +++ b/resources/lang/el/quizzes.php @@ -130,7 +130,7 @@ 'answers_of_question' => 'Απαντήσεις Ερώτησης Δοκιμασίας (Quiz)', 'download_csv_template' => 'Λήψη προτύπου αρχείου CSV ερωτήσεων', 'download_students_csv_template' => 'Λήψη προτύπου αρχείου CSV μαθητών', - 'print_students_list' => 'Εκτύπωση καταλόγου Εξαταζόμενων', + 'print_students_list' => 'Εκτύπωση καταλόγου Εξεταζόμενων', 'category_description' => 'Διαχείριση θεματικών κατηγοριών', 'quiz_limit_reached_button' => 'Έχει καλυφθεί το όριο Quiz', 'request_more_quizzes_button' => 'Αίτημα για περισσότερα Quiz', @@ -139,22 +139,24 @@ 'student_limit_status' => 'Καταχωρημένοι εξεταζόμενοι: :count / :limit', 'request_more_answers_button' => 'Αίτημα για περισσότερες απαντήσεις', 'request_more_students_button' => 'Αίτημα για περισσότερους εξεταζόμενους', - 'anonymous_bulk_mode_hint' => 'Η ανώνυμη μαζική εξέταση δημιουργεί ξεχωριστές ανώνυμες θέσεις με μοναδικά QR/personal links χωρίς πραγματικά ονόματα. Η πρόσβαση επισκέπτη και η δημόσια πρόσβαση απενεργοποιούνται αυτόματα ώστε τα στατιστικά να μένουν καθαρά.', - 'create_anonymous_slots' => 'Δημιουργία Ανώνυμων Θέσεων', - 'anonymous_slots_count' => 'Ανώνυμες Θέσεις', - 'anonymous_slots_count_hint' => 'Δημιούργησε όσες ανώνυμες θέσεις χρειάζεσαι για αυτό το quiz.', - 'anonymous_max_attempts_hint' => 'Κάθε ανώνυμη θέση έχει δικό της όριο από 1 έως 5 προσπάθειες.', - 'generate_anonymous_slots' => 'Δημιουργία Ανώνυμων Θέσεων', - 'download_anonymous_cards_pdf' => 'Κάρτες QR Ανωνύμων', - 'anonymous_cards_pdf_title' => 'Κάρτες Ανώνυμων Εξεταζομένων', - 'anonymous_cards_pdf_hint' => 'Μοίρασε αυτές τις κάρτες για το quiz «:quiz». Κάθε QR code ανοίγει μία ανεξάρτητη ανώνυμη θέση.', + 'anonymous_bulk_mode_hint' => 'Δημιουργεί προκαταχωρισμένες θέσεις εξέτασης με μοναδικό PIN και QR Code για συμμετέχοντες των οποίων τα στοιχεία δεν είναι γνωστά εκ των προτέρων. Κατά τη διανομή της κάρτας, κάθε εξεταζόμενος συμπληρώνει το ονοματεπώνυμό του.', + 'create_anonymous_slots' => 'Δημιουργία Θέσεων Εξέτασης', + 'anonymous_slots_count' => 'Θέσεις Εξέτασης', + 'anonymous_slots_count_hint' => 'Δημιούργησε όσες προκαταχωρισμένες θέσεις χρειάζεσαι για αυτό το quiz.', + 'anonymous_max_attempts_hint' => 'Κάθε θέση εξέτασης έχει δικό της όριο από 1 έως 5 προσπάθειες.', + 'generate_anonymous_slots' => 'Δημιουργία Θέσεων Εξέτασης', + 'download_anonymous_cards_pdf' => 'Κάρτες QR Θέσεων', + 'anonymous_cards_pdf_title' => 'Κάρτες Θέσεων Εξέτασης', + 'anonymous_cards_pdf_hint' => 'Μοίρασε αυτές τις κάρτες για το quiz «:quiz». Κάθε QR Code ανοίγει μία ανεξάρτητη θέση εξέτασης και ο εξεταζόμενος συμπληρώνει το ονοματεπώνυμό του στην κενή γραμμή.', 'public_anonymous_pool_hint' => 'Η λειτουργία Δημόσιο Ανώνυμο Σύνολο Εξεταζόμενων χρησιμοποιεί ένα κοινό δημόσιο QR/link και περιορισμένο πλήθος προσωρινών ανώνυμων θέσεων. Τα αποτελέσματα αποθηκεύονται μόνο μετά την τελική υποβολή.', 'public_anonymous_pool_mode_label' => 'Δημόσιο Ανώνυμο Σύνολο Εξεταζόμενων', 'public_anonymous_pool_link' => 'Κοινό Δημόσιο QR/Link', + 'download_public_pool_invitation_pdf' => 'Λήψη PDF Πρόσκλησης', 'public_anonymous_pool_link_hint' => 'Όλοι οι εξεταζόμενοι ανοίγουν το ίδιο link. Το σύστημα εκχωρεί μία προσωρινή ανώνυμη θέση και αποθηκεύει αποτέλεσμα μόνο στην τελική υποβολή.', 'public_anonymous_pool_dashboard_hint' => 'Ολοκληρωμένες ανώνυμες υποβολές: :count / :capacity. Οι εγκαταλειμμένες προσωρινές συνεδρίες δεν αποθηκεύονται μόνιμα.', 'anonymous_pool_capacity' => 'Χωρητικότητα Δημόσιου Ανώνυμου Συνόλου Εξεταζομένων', 'anonymous_pool_capacity_hint' => 'Μέγιστος αριθμός ανώνυμων υποβολών που μπορούν να ολοκληρωθούν μέσω του κοινού δημόσιου QR/link.', + 'anonymous_pool_account_limit' => 'Το ανώτατο όριο του λογαριασμού σας είναι :limit εξεταζόμενοι ανά quiz.', 'platform_examples_title' => 'Κοινό Παράδειγμα', 'platform_examples_heading' => 'Παράδειγμα Δοκιμασίας της Πλατφόρμας', 'platform_examples_intro' => 'Ένα read-only παράδειγμα δοκιμασίας που παρέχεται από την πλατφόρμα, ώστε οι εκπαιδευτικοί να ξεκινούν πιο γρήγορα με έτοιμη δομή και ιδέες.', @@ -166,4 +168,7 @@ 'try_as_guest' => 'Δοκιμή ως Επισκέπτης', 'copy_as_new_quiz' => 'Αντιγραφή ως Νέο Quiz', 'no_personal_quizzes' => 'Δεν έχετε ακόμη προσωπικές δοκιμασίες. Μπορείτε να ξεκινήσετε από το κοινό παράδειγμα ή να δημιουργήσετε μια νέα.', + 'creator_label' => 'Δημιουργός', + 'creator_you' => 'Εσείς', + 'creator_unavailable' => 'Μη διαθέσιμος χρήστης', ]; diff --git a/resources/lang/el/quizzes_cards.php b/resources/lang/el/quizzes_cards.php index f4160a5..cedf190 100644 --- a/resources/lang/el/quizzes_cards.php +++ b/resources/lang/el/quizzes_cards.php @@ -116,10 +116,12 @@ 'quiz_image' => 'Εικόνα Quiz', 'quiz_image_hint' => '📌 Μέγιστο μέγεθος: 150 KB. Ανέβασε μια προαιρετική εικόνα για τη Δοκιμασία (Quiz).', 'delete_image' => 'Διαγραφή υπάρχουσας εικόνας', - 'anonymous_bulk_mode' => 'Ανώνυμη Μαζική Εξέταση', - 'anonymous_bulk_mode_notice' => 'Δημιουργεί ανώνυμες θέσεις εξεταζομένων με μοναδικά QR/personal links. Η πρόσβαση επισκέπτη και η δημόσια πρόσβαση απενεργοποιούνται αυτόματα.', + 'anonymous_bulk_mode' => 'Μαζική Εξέταση με Προκαταχωρισμένες Θέσεις', + 'anonymous_bulk_mode_notice' => 'Δημιουργεί προκαταχωρισμένες θέσεις εξέτασης με μοναδικό PIN και QR Code για συμμετέχοντες των οποίων τα στοιχεία δεν είναι γνωστά εκ των προτέρων. Κατά τη διανομή της κάρτας, κάθε εξεταζόμενος συμπληρώνει το ονοματεπώνυμό του.', 'public_anonymous_pool_mode' => 'Δημόσιο Ανώνυμο Σύνολο Εξεταζόμενων', 'public_anonymous_pool_mode_notice' => 'Χρησιμοποιεί ένα κοινό δημόσιο QR/link, εκχωρεί προσωρινές ανώνυμες θέσεις και αποθηκεύει αποτελέσματα μόνο μετά την τελική υποβολή.', + 'participation_modes_section' => 'Τρόπος μαζικής συμμετοχής', + 'participation_modes_notice' => 'Οι δύο λειτουργίες είναι αμοιβαία αποκλειόμενες και υπόκεινται στο όριο εξεταζομένων ανά quiz του λογαριασμού σας.', 'student_access_policy' => 'Πρόσβαση καταχωρημένων εξεταζομένων', 'student_access_policy_hint' => 'Επίλεξε αν οι καταχωρημένοι εξεταζόμενοι θα μπαίνουν με PIN εξέτασης, με προσωποποιημένους συνδέσμους ή και με τα δύο. Δεν επηρεάζονται οι δημόσιες και ανώνυμες ροές.', 'student_access_policy_pin_and_links' => 'PIN + προσωποποιημένοι σύνδεσμοι', diff --git a/resources/lang/en/about.php b/resources/lang/en/about.php index 4416e43..dcf1ecd 100644 --- a/resources/lang/en/about.php +++ b/resources/lang/en/about.php @@ -42,7 +42,7 @@
  • Guest participation without requiring an account, where enabled
  • Create personalised access links for specific participants
  • Publish public quizzes in a catalogue for direct guest participation
  • -
  • Run anonymous bulk-exam sessions with generated slots and four-digit codes
  • +
  • Run bulk examinations with pre-registered slots, unique PINs, and QR Codes
  • Offer a public anonymous pool with controlled capacity for open participation
  • Enable learning mode without permanently storing attempts or scores
  • diff --git a/resources/lang/en/controllers.php b/resources/lang/en/controllers.php index 2423afc..4ed3551 100644 --- a/resources/lang/en/controllers.php +++ b/resources/lang/en/controllers.php @@ -20,14 +20,21 @@ 'student_old_attempts_deleted' => 'Previous attempts of the participant have been deleted.', 'student_completely_deleted' => 'The participant and all associated attempts have been deleted.', 'anonymous_student_name' => 'Anonymous Participant', - 'anonymous_slots_created' => 'Created :count anonymous participant slots successfully.', - 'anonymous_slots_unavailable' => 'There are not enough available unique 4-digit codes to create the requested anonymous slots.', - 'anonymous_bulk_mode_manual_disabled' => 'This quiz uses anonymous bulk mode. Generate anonymous slots instead of registering named participants.', - 'anonymous_bulk_mode_disabled' => 'Anonymous bulk mode is not enabled for this quiz.', + 'exam_slot_name' => 'Exam Slot :code', + 'anonymous_slots_created' => 'Created :count pre-registered exam slots successfully.', + 'anonymous_slots_unavailable' => 'There are not enough available unique 4-digit codes to create the requested exam slots.', + 'anonymous_bulk_mode_manual_disabled' => 'This quiz uses bulk examination with pre-registered slots. Generate exam slots instead of registering named participants.', + 'anonymous_bulk_mode_disabled' => 'Bulk examination with pre-registered slots is not enabled for this quiz.', 'public_anonymous_pool_manual_disabled' => 'This quiz uses Public Anonymous Pool mode. Participant records are created automatically only after final submission.', - 'special_modes_conflict' => 'Choose either Anonymous Bulk mode or Public Anonymous Pool mode, not both at the same time.', - 'learning_mode_special_modes_conflict' => 'Learning mode cannot be combined with Anonymous Bulk mode or Public Anonymous Pool mode.', - 'second_screen_conflict' => 'TV + Mobile Controller mode cannot be combined with Learning mode, Anonymous Bulk mode, or Public Anonymous Pool mode.', + 'public_anonymous_pool_mode_disabled' => 'The invitation PDF is available only for quizzes using Public Anonymous Pool mode.', + 'public_anonymous_pool_invitation_inactive' => 'Activate the quiz before creating a participation invitation.', + 'public_anonymous_pool_invitation_expired' => 'This invitation public link has expired. Refresh the page and create a new PDF.', + 'public_anonymous_pool_link_unavailable' => 'The public participation link could not be generated.', + 'special_modes_conflict' => 'Choose either Bulk Exam with Pre-registered Slots or Public Anonymous Pool mode, not both at the same time.', + 'learning_mode_special_modes_conflict' => 'Learning mode cannot be combined with Bulk Exam with Pre-registered Slots or Public Anonymous Pool mode.', + 'anonymous_pool_capacity_limit' => 'Capacity cannot exceed your account limit of :max participants per quiz.', + 'participation_mode_has_students' => 'The bulk participation mode cannot be changed while registered slots or participants exist. Remove those records first.', + 'second_screen_conflict' => 'TV + Mobile Controller mode cannot be combined with Learning mode, Bulk Exam with Pre-registered Slots, or Public Anonymous Pool mode.', 'second_screen_disabled' => 'TV + Mobile Controller mode is not enabled for this quiz.', 'second_screen_registered_only' => 'TV + Mobile Controller mode is available only for registered named participants.', 'second_screen_attempt_limit_reached' => 'This participant has already reached the allowed number of attempts for TV mode.', diff --git a/resources/lang/en/dashboard.php b/resources/lang/en/dashboard.php index f18ef61..d9a35a2 100644 --- a/resources/lang/en/dashboard.php +++ b/resources/lang/en/dashboard.php @@ -10,6 +10,7 @@ 'subtitle' => 'Knowledge Assessment Quiz Dashboard', 'overview_text' => 'A quick way to review the activity of the quizzes you have created with our application.', 'my_active_quizzes' => 'My Quiz Collection', + 'all_quizzes_collection' => 'All Quiz Collection', 'table_created_at' => 'Created At', 'table_title' => 'Knowledge Assessment Quiz Title', 'table_students' => 'Total Participants', @@ -32,6 +33,7 @@ 'total_questions' => 'Total Questions', 'total_students' => 'Total Participants', 'quiz_collection_intro' => 'Your personal collection with the key details of each quiz, so you can quickly monitor activity.', + 'all_quizzes_collection_intro' => 'All quizzes in this installation, together with their creator and key management details.', 'updates_intro' => 'Recent application updates, announcements, and useful references for educators.', 'resources_eyebrow' => 'Support and Resources', 'resources_title' => 'Teacher Guide and Reference', diff --git a/resources/lang/en/join.php b/resources/lang/en/join.php index 2f76ea3..8088418 100644 --- a/resources/lang/en/join.php +++ b/resources/lang/en/join.php @@ -104,4 +104,6 @@ 'participant_notice_guest' => 'This content was created by the quiz owner. Essential cookies are used for the secure operation of the examination process.', 'participant_notice_aria' => 'Quiz participation notice', 'participant_notice_dismiss' => 'Hide participation notice', + 'template_media_notice' => 'Images and screenshots are displayed at low resolution under the principle of fair use.', + 'template_media_notice_aria' => 'Image usage notice', ]; diff --git a/resources/lang/en/pdfexp.php b/resources/lang/en/pdfexp.php index 9635984..6056234 100644 --- a/resources/lang/en/pdfexp.php +++ b/resources/lang/en/pdfexp.php @@ -81,4 +81,5 @@ 'students_list_title' => 'Student Register', 'student_list' => 'Registered Students', 'issue_date' => 'Issue Date:', + 'registered_students_count' => 'Total students', ]; diff --git a/resources/lang/en/public_pool_invitation.php b/resources/lang/en/public_pool_invitation.php new file mode 100644 index 0000000..5b98890 --- /dev/null +++ b/resources/lang/en/public_pool_invitation.php @@ -0,0 +1,15 @@ + 'Participation Invitation', + 'eyebrow' => 'Anonymous knowledge assessment', + 'educator' => 'Educator', + 'duration' => 'Duration', + 'minutes' => ':count minutes', + 'capacity' => 'Maximum participants', + 'instruction' => 'Scan the QR Code or open the link below', + 'anonymous_notice' => 'Participation does not require a registered name. The system assigns a temporary anonymous slot and stores the result only after final submission.', + 'expires_at' => 'The link is available until :date.', + 'no_expiration' => 'The link has no predefined expiration date.', + 'qr_alt' => 'QR Code for exam participation', +]; diff --git a/resources/lang/en/quizzes.php b/resources/lang/en/quizzes.php index 67eb262..ae42d0e 100644 --- a/resources/lang/en/quizzes.php +++ b/resources/lang/en/quizzes.php @@ -139,22 +139,24 @@ 'student_limit_status' => 'Registered participants: :count / :limit', 'request_more_answers_button' => 'Request More Answers', 'request_more_students_button' => 'Request More Participants', - 'anonymous_bulk_mode_hint' => 'Anonymous bulk exam mode creates separate anonymous slots with unique QR or personal links and no real names. Guest access and public access are disabled automatically so statistics remain clean.', - 'create_anonymous_slots' => 'Create Anonymous Slots', - 'anonymous_slots_count' => 'Anonymous Slots', - 'anonymous_slots_count_hint' => 'Create as many anonymous slots as you need for this quiz.', - 'anonymous_max_attempts_hint' => 'Each anonymous slot has its own limit from 1 to 5 attempts.', - 'generate_anonymous_slots' => 'Generate Anonymous Slots', - 'download_anonymous_cards_pdf' => 'Anonymous QR Cards', - 'anonymous_cards_pdf_title' => 'Anonymous Participant Cards', - 'anonymous_cards_pdf_hint' => 'Distribute these cards for the quiz ":quiz". Each QR code opens one independent anonymous slot.', + 'anonymous_bulk_mode_hint' => 'Creates pre-registered exam slots with a unique PIN and QR Code for participants whose details are not known in advance. When the card is distributed, each participant writes their full name.', + 'create_anonymous_slots' => 'Create Exam Slots', + 'anonymous_slots_count' => 'Exam Slots', + 'anonymous_slots_count_hint' => 'Create as many pre-registered exam slots as you need for this quiz.', + 'anonymous_max_attempts_hint' => 'Each exam slot has its own limit from 1 to 5 attempts.', + 'generate_anonymous_slots' => 'Create Exam Slots', + 'download_anonymous_cards_pdf' => 'Exam Slot QR Cards', + 'anonymous_cards_pdf_title' => 'Exam Slot Cards', + 'anonymous_cards_pdf_hint' => 'Distribute these cards for the quiz ":quiz". Each QR Code opens one independent exam slot, and the participant writes their full name on the blank line.', 'public_anonymous_pool_hint' => 'Public Anonymous Pool mode uses one shared public QR or link and a limited number of temporary anonymous slots. Results are stored only after final submission.', 'public_anonymous_pool_mode_label' => 'Public Anonymous Pool of Participants', 'public_anonymous_pool_link' => 'Shared Public QR / Link', + 'download_public_pool_invitation_pdf' => 'Download Invitation PDF', 'public_anonymous_pool_link_hint' => 'All participants open the same link. The system assigns a temporary anonymous slot and stores the result only on final submission.', 'public_anonymous_pool_dashboard_hint' => 'Completed anonymous submissions: :count / :capacity. Abandoned temporary sessions are not stored permanently.', 'anonymous_pool_capacity' => 'Public Anonymous Pool Capacity', 'anonymous_pool_capacity_hint' => 'Maximum number of anonymous submissions that can be completed through the shared public QR or link.', + 'anonymous_pool_account_limit' => 'Your account limit is :limit participants per quiz.', 'platform_examples_title' => 'Shared Example', 'platform_examples_heading' => 'Platform Example Quiz', 'platform_examples_intro' => 'A read-only example quiz provided by the platform to showcase a polished structure and help teachers start faster.', @@ -166,4 +168,7 @@ 'try_as_guest' => 'Try as Guest', 'copy_as_new_quiz' => 'Copy as New Quiz', 'no_personal_quizzes' => 'You do not have any personal quizzes yet. You can start from the platform example or create a new one.', + 'creator_label' => 'Creator', + 'creator_you' => 'You', + 'creator_unavailable' => 'Unavailable user', ]; diff --git a/resources/lang/en/quizzes_cards.php b/resources/lang/en/quizzes_cards.php index 340be64..b5facf8 100644 --- a/resources/lang/en/quizzes_cards.php +++ b/resources/lang/en/quizzes_cards.php @@ -109,10 +109,12 @@ 'questions_limit_hint' => 'Number of questions that will be selected at random when random order is enabled.', 'quiz_image' => 'Quiz Image', 'quiz_image_hint' => 'Max size: 150 KB. Upload an optional image for the quiz.', - 'anonymous_bulk_mode' => 'Anonymous Bulk Exam', - 'anonymous_bulk_mode_notice' => 'Creates anonymous participant slots with unique QR or personal links. Guest and public access are disabled automatically.', + 'anonymous_bulk_mode' => 'Bulk Exam with Pre-registered Slots', + 'anonymous_bulk_mode_notice' => 'Creates pre-registered exam slots with a unique PIN and QR Code for participants whose details are not known in advance. When the card is distributed, each participant writes their full name.', 'public_anonymous_pool_mode' => 'Public Anonymous Pool of Participants', 'public_anonymous_pool_mode_notice' => 'Uses one shared public QR or link, assigns temporary anonymous slots, and stores results only after final submission.', + 'participation_modes_section' => 'Bulk participation mode', + 'participation_modes_notice' => 'These two modes are mutually exclusive and are subject to your account participant limit per quiz.', 'student_access_policy' => 'Registered Participant Access', 'student_access_policy_hint' => 'Choose whether registered participants may enter using the exam PIN, personal links, or both. Public and anonymous flows are not affected.', 'student_access_policy_pin_and_links' => 'PIN + Personal links', diff --git a/resources/views/quiz/templates/default_img/question.blade.php b/resources/views/quiz/templates/default_img/question.blade.php index 73f004a..b971a28 100644 --- a/resources/views/quiz/templates/default_img/question.blade.php +++ b/resources/views/quiz/templates/default_img/question.blade.php @@ -1,708 +1 @@ -@extends('layouts.quiz_guest') - -@section('content') - - -@php - $correctCount = $question->answers->where('is_correct', true)->count(); - $instructionText = trans_choice('join.select_instruction', $correctCount, ['count' => $correctCount]); - $showLearningFeedback = $showLearningFeedback ?? false; - $isLearningMode = $isLearningMode ?? false; - $safeTimeRemaining = max(0, (int) $timeRemaining); - $formattedTimeRemaining = sprintf('%02d:%02d', intdiv($safeTimeRemaining, 60), $safeTimeRemaining % 60); -@endphp - -@if(!$allowDisplay) -
    -
    -
    -
    -
    - {{ __('join.access_denied') }} -
    -
    -
    - @php return; @endphp -@endif - -
    - -
    -
    -
    -

    {{ $quiz->title }}

    - -
    -

    {{ $questionProgressLabel }}

    -
    - - @if($showLearningFeedback) -
    -
    -

    {{ $question->text }}

    -
    - -
    - @if($question->image) -
    - {{ __('join.question_image_alt') }} -
    - @endif - -
    - @include('quiz.partials.learning-feedback-state') -
    -
    - - @if($question->image) - - @endif -
    - @else - @php - $showSkipButton = !$isLastQuestion && !$isReviewPass; - @endphp - -
    -
    -

    {{ $question->text }}

    -
    - -
    - @if($question->image) -
    - {{ __('join.question_image_alt') }} -
    - @endif - -
    - @csrf - - -
    - @foreach($question->answers as $answerIndex => $answer) - - @endforeach -
    -
    - -
    - - @if($question->image) - - @endif - -
    -

    -

    -
    - -
    -
    - @if($isLastQuestion && !$isLearningMode) - - @else - - @endif -
    - - @if($showSkipButton) -
    - @csrf - - -
    - @endif -
    -
    - @endif - - @if($quiz->has_timer) -
    -
    -
    -
    -

    - {{ __('join.time_remaining') }} {{ $formattedTimeRemaining }} -

    -
    - @endif -
    -
    -
    - - -@endsection +@include('quiz.templates.shared_img.question') diff --git a/resources/views/quiz/templates/default_img/result.blade.php b/resources/views/quiz/templates/default_img/result.blade.php index ae6a8fb..92f540d 100644 --- a/resources/views/quiz/templates/default_img/result.blade.php +++ b/resources/views/quiz/templates/default_img/result.blade.php @@ -1,465 +1 @@ -@extends('layouts.quiz_guest') - -@section('content') - - -@php - $isLearningModeResult = $isLearningModeResult ?? false; - $passedQuiz = !$isLearningModeResult && $scorePercentage >= $quiz->pass_percentage; - $roundedScorePercentage = (int) round($scorePercentage); - $isGuestAttempt = $attempt->student_code === '0000'; - $participantRoleLabel = $isGuestAttempt ? __('join.guest_name') : rtrim(__('join.student'), ':'); - $correctAnswersLabel = \Illuminate\Support\Str::before(__('join.correct_answers', ['correct' => 0, 'total' => 0]), ': 0'); - $scorePercentageLabel = \Illuminate\Support\Str::before(__('join.score_percentage', ['score' => 0]), ': 0'); -@endphp - -
    - -
    -
    -
    - @if($quiz->image) -
    - {{ $quiz->title }} -
    - @endif - -
    - {{ __('join.results') }} -
    - -

    {{ __('join.quiz_completed') }}

    -
    - {{ $participantRoleLabel }}: {{ $attempt->student_name }} -
    - - @if($isLearningModeResult) -
    - {{ __('join.learning_mode_result_message') }} -
    - @elseif($passedQuiz) -
    - {{ __('join.passed', ['score' => $roundedScorePercentage, 'required' => $quiz->pass_percentage]) }} -
    - @else -
    - {{ __('join.failed', ['score' => $roundedScorePercentage, 'required' => $quiz->pass_percentage]) }} -
    - @endif - - @unless($isLearningModeResult) -
    -
    - {{ $correctAnswersLabel }} -
    {{ $correctCount }} / {{ $totalQuestions }}
    -
    {{ __('join.total_questions_note', ['total' => $totalQuestions]) }}
    -
    - -
    - {{ $scorePercentageLabel }} -
    {{ $roundedScorePercentage }}%
    -
    {{ __('join.pass_threshold_note', ['required' => (int) $quiz->pass_percentage]) }}
    -
    -
    - @else -

    {{ __('join.learning_mode_result_message') }}

    - @endunless - -
    - @unless($isLearningModeResult) - @if($isGuestAttempt) -
    - -

    {{ __('join.pdf_note') }}

    -
    - @else - - {{ __('join.download_pdf') }} - - @endif - - @php - $canRetryAsGuest = $attempt->student_code === '0000' - && $scorePercentage < $quiz->pass_percentage - && $quiz->is_public - && $quiz->allow_guest; - - $canRetryAsStudent = $attempt->student_code !== '0000' - && $scorePercentage < $quiz->pass_percentage - && $remainingAttempts > 0 - && $quiz->supportsStudentPersonalLinks(); - - $retryUrl = null; - - if ($canRetryAsGuest) { - $retryUrl = $quiz->publicAccessUrl(); - } elseif ($canRetryAsStudent && !empty($attempt->student_code)) { - $student = $attempt->student; - - if (!$student) { - $student = \App\Models\QuizStudent::where('quiz_id', $quiz->id) - ->where('student_code', $attempt->student_code) - ->first(); - } - - if ($student) { - $retryUrl = $student->accessLinkUrl(); - } - } - @endphp - - @if($retryUrl) - - {{ __('join.retry_quiz') }} - - @endif - @endunless - - - {{ __('join.back_to_home') }} - -
    -
    -
    -
    -@endsection +@include('quiz.templates.shared_img.result') diff --git a/resources/views/quiz/templates/default_img/start.blade.php b/resources/views/quiz/templates/default_img/start.blade.php index 7009a61..620a4e0 100644 --- a/resources/views/quiz/templates/default_img/start.blade.php +++ b/resources/views/quiz/templates/default_img/start.blade.php @@ -1,409 +1 @@ -@extends('layouts.quiz_guest') - -@section('meta') -@endsection - -@section('content') - - -
    - -
    -
    -
    -
    -
    - @if($quiz->image) -
    - {{ $quiz->title }} -
    - @endif - -
    - {{ __('join.app_title') }} -
    - -

    - {{ __('join.for_quiz') }} -

    - -

    - {{ $quiz->title }} -

    - -
    - {{ __('join.student') }} {{ session('student_name') }} -
    - -
    -
    - - - - {{ __('join.starting_shortly_in', ['seconds' => 10]) }} - -
    -
    - -

    - {{ __('join.get_ready') }} -

    -
    -
    -
    -
    -
    - -{{-- Redirect automatically after the countdown so all templates start the attempt consistently. --}} -@endsection +@include('quiz.templates.shared_img.start') diff --git a/resources/views/quiz/templates/default_img/student.blade.php b/resources/views/quiz/templates/default_img/student.blade.php index 191f58f..f6e5ae5 100644 --- a/resources/views/quiz/templates/default_img/student.blade.php +++ b/resources/views/quiz/templates/default_img/student.blade.php @@ -1,354 +1 @@ -@extends('layouts.quiz_guest') - -@section('content') - - -
    - -
    -
    -
    -
    - @if($quiz->image) -
    -
    - {{ $quiz->title }} -
    -
    - @endif - -
    -
    - - {{ __('join.app_title') }} -
    - -

    - {{ __('join.for_quiz') }} -

    - -

    - {{ $quiz->title }} -

    - - @if($quiz->description) -
    - - {{ __('join.description_label') }} - -
    {{ $quiz->description }}
    -
    - @endif - - @if(session('error')) -
    - {{ session('error') }} -
    - @endif - -
    - @csrf - -
    - -
    - - -
    -
    - - -
    -
    -
    -
    -
    -
    - -{{-- Keep the student-code reveal toggle explicit for shared kiosk or classroom devices. --}} -@endsection +@include('quiz.templates.shared_img.student') diff --git a/resources/views/quiz/templates/modern_img/partials/media-notice.blade.php b/resources/views/quiz/templates/modern_img/partials/media-notice.blade.php new file mode 100644 index 0000000..8bf2bfe --- /dev/null +++ b/resources/views/quiz/templates/modern_img/partials/media-notice.blade.php @@ -0,0 +1,4 @@ + diff --git a/resources/views/quiz/templates/modern_img/partials/theme.blade.php b/resources/views/quiz/templates/modern_img/partials/theme.blade.php new file mode 100644 index 0000000..e5e2db0 --- /dev/null +++ b/resources/views/quiz/templates/modern_img/partials/theme.blade.php @@ -0,0 +1,487 @@ + diff --git a/resources/views/quiz/templates/modern_img/question.blade.php b/resources/views/quiz/templates/modern_img/question.blade.php new file mode 100644 index 0000000..12be8ab --- /dev/null +++ b/resources/views/quiz/templates/modern_img/question.blade.php @@ -0,0 +1,5 @@ +@include('quiz.templates.shared_img.question', [ + 'adaptiveAnswerLayout' => true, + 'imageThemePartial' => 'quiz.templates.modern_img.partials.theme', + 'templateScreen' => 'question', +]) diff --git a/resources/views/quiz/templates/modern_img/result.blade.php b/resources/views/quiz/templates/modern_img/result.blade.php new file mode 100644 index 0000000..fef9b79 --- /dev/null +++ b/resources/views/quiz/templates/modern_img/result.blade.php @@ -0,0 +1,4 @@ +@include('quiz.templates.shared_img.result', [ + 'imageThemePartial' => 'quiz.templates.modern_img.partials.theme', + 'templateScreen' => 'result', +]) diff --git a/resources/views/quiz/templates/modern_img/start.blade.php b/resources/views/quiz/templates/modern_img/start.blade.php new file mode 100644 index 0000000..ac9afac --- /dev/null +++ b/resources/views/quiz/templates/modern_img/start.blade.php @@ -0,0 +1,5 @@ +@include('quiz.templates.shared_img.start', [ + 'imageThemePartial' => 'quiz.templates.modern_img.partials.theme', + 'imageStartNoticePartial' => 'quiz.templates.modern_img.partials.media-notice', + 'templateScreen' => 'start', +]) diff --git a/resources/views/quiz/templates/modern_img/student.blade.php b/resources/views/quiz/templates/modern_img/student.blade.php new file mode 100644 index 0000000..62c6f2d --- /dev/null +++ b/resources/views/quiz/templates/modern_img/student.blade.php @@ -0,0 +1,4 @@ +@include('quiz.templates.shared_img.student', [ + 'imageThemePartial' => 'quiz.templates.modern_img.partials.theme', + 'templateScreen' => 'student', +]) diff --git a/resources/views/quiz/templates/shared_img/question.blade.php b/resources/views/quiz/templates/shared_img/question.blade.php new file mode 100644 index 0000000..af17d08 --- /dev/null +++ b/resources/views/quiz/templates/shared_img/question.blade.php @@ -0,0 +1,750 @@ +@extends('layouts.quiz_guest') + +@section('content') + + +@if(! empty($imageThemePartial)) + @include($imageThemePartial, ['templateScreen' => $templateScreen ?? 'question']) +@endif + +@php + $correctCount = $question->answers->where('is_correct', true)->count(); + $instructionText = trans_choice('join.select_instruction', $correctCount, ['count' => $correctCount]); + $showLearningFeedback = $showLearningFeedback ?? false; + $isLearningMode = $isLearningMode ?? false; + $safeTimeRemaining = max(0, (int) $timeRemaining); + $formattedTimeRemaining = sprintf('%02d:%02d', intdiv($safeTimeRemaining, 60), $safeTimeRemaining % 60); + + $compactAnswerLayout = false; + if ($adaptiveAnswerLayout ?? false) { + $answerTextMetrics = $question->answers->map(function ($answer): array { + $plainText = trim(strip_tags((string) $answer->text)); + $words = preg_split('/\s+/u', $plainText, -1, PREG_SPLIT_NO_EMPTY) ?: []; + + return [ + 'characters' => mb_strlen($plainText), + 'words' => count($words), + ]; + }); + + $compactCharacterLimit = $question->image ? 30 : 42; + $compactWordLimit = $question->image ? 5 : 7; + $compactAnswerLayout = $question->answers->count() <= 6 + && (int) $answerTextMetrics->max('characters') <= $compactCharacterLimit + && (int) $answerTextMetrics->max('words') <= $compactWordLimit; + } +@endphp + +@if(!$allowDisplay) +
    +
    +
    +
    +
    + {{ __('join.access_denied') }} +
    +
    +
    + @php return; @endphp +@endif + +
    + +
    +
    +
    +

    {{ $quiz->title }}

    + +
    +

    {{ $questionProgressLabel }}

    +
    + + @if($showLearningFeedback) +
    +
    +

    {{ $question->text }}

    +
    + +
    + @if($question->image) +
    + {{ __('join.question_image_alt') }} +
    + @endif + +
    + @include('quiz.partials.learning-feedback-state') +
    +
    + + @if($question->image) + + @endif +
    + @else + @php + $showSkipButton = !$isLastQuestion && !$isReviewPass; + @endphp + +
    +
    +

    {{ $question->text }}

    +
    + +
    + @if($question->image) +
    + {{ __('join.question_image_alt') }} +
    + @endif + +
    + @csrf + + +
    + @foreach($question->answers as $answerIndex => $answer) + + @endforeach +
    +
    + +
    + + @if($question->image) + + @endif + +
    +

    +

    +
    + +
    +
    + @if($isLastQuestion && !$isLearningMode) + + @else + + @endif +
    + + @if($showSkipButton) +
    + @csrf + + +
    + @endif +
    +
    + @endif + + @if($quiz->has_timer) +
    +
    +
    +
    +

    + {{ __('join.time_remaining') }} {{ $formattedTimeRemaining }} +

    +
    + @endif +
    +
    +
    + + + +@endsection diff --git a/resources/views/quiz/templates/shared_img/result.blade.php b/resources/views/quiz/templates/shared_img/result.blade.php new file mode 100644 index 0000000..bd12338 --- /dev/null +++ b/resources/views/quiz/templates/shared_img/result.blade.php @@ -0,0 +1,475 @@ +@extends('layouts.quiz_guest') + +@section('content') + + +@if(! empty($imageThemePartial)) + @include($imageThemePartial, ['templateScreen' => $templateScreen ?? 'result']) +@endif + +@php + $isLearningModeResult = $isLearningModeResult ?? false; + $passedQuiz = !$isLearningModeResult && $scorePercentage >= $quiz->pass_percentage; + $roundedScorePercentage = (int) round($scorePercentage); + $isGuestAttempt = $attempt->student_code === '0000'; + $participantRoleLabel = $isGuestAttempt ? __('join.guest_name') : rtrim(__('join.student'), ':'); + $correctAnswersLabel = \Illuminate\Support\Str::before(__('join.correct_answers', ['correct' => 0, 'total' => 0]), ': 0'); + $scorePercentageLabel = \Illuminate\Support\Str::before(__('join.score_percentage', ['score' => 0]), ': 0'); +@endphp + +
    + +
    +
    +
    + @if($quiz->image) +
    + {{ $quiz->title }} +
    + @endif + +
    + {{ __('join.results') }} +
    + +

    {{ __('join.quiz_completed') }}

    + @if(!$isGuestAttempt) +
    + {{ $participantRoleLabel }}: {{ $attempt->student_name }} +
    + @endif + + @if($isLearningModeResult) +
    + {{ __('join.learning_mode_result_message') }} +
    + @elseif($passedQuiz) +
    + {{ __('join.passed', ['score' => $roundedScorePercentage, 'required' => $quiz->pass_percentage]) }} +
    + @else +
    + {{ __('join.failed', ['score' => $roundedScorePercentage, 'required' => $quiz->pass_percentage]) }} +
    + @endif + + @unless($isLearningModeResult) +
    +
    + {{ $correctAnswersLabel }} +
    {{ $correctCount }} / {{ $totalQuestions }}
    +
    {{ __('join.total_questions_note', ['total' => $totalQuestions]) }}
    +
    + +
    + {{ $scorePercentageLabel }} +
    {{ $roundedScorePercentage }}%
    +
    {{ __('join.pass_threshold_note', ['required' => (int) $quiz->pass_percentage]) }}
    +
    +
    + @else +

    {{ __('join.learning_mode_result_message') }}

    + @endunless + +
    + @unless($isLearningModeResult) + @if($isGuestAttempt) +
    + +

    {{ __('join.pdf_note') }}

    +
    + @else + + {{ __('join.download_pdf') }} + + @endif + + @php + $canRetryAsGuest = $attempt->student_code === '0000' + && $scorePercentage < $quiz->pass_percentage + && $quiz->is_public + && $quiz->allow_guest; + + $canRetryAsStudent = $attempt->student_code !== '0000' + && $scorePercentage < $quiz->pass_percentage + && $remainingAttempts > 0 + && $quiz->supportsStudentPersonalLinks(); + + $retryUrl = null; + + if ($canRetryAsGuest) { + $retryUrl = $quiz->publicAccessUrl(); + } elseif ($canRetryAsStudent && !empty($attempt->student_code)) { + $student = $attempt->student; + + if (!$student) { + $student = \App\Models\QuizStudent::where('quiz_id', $quiz->id) + ->where('student_code', $attempt->student_code) + ->first(); + } + + if ($student) { + $retryUrl = $student->accessLinkUrl(); + } + } + @endphp + + @if($retryUrl) + + {{ __('join.retry_quiz') }} + + @endif + @endunless + + + {{ __('join.back_to_home') }} + +
    +
    +
    +
    + +@endsection diff --git a/resources/views/quiz/templates/shared_img/start.blade.php b/resources/views/quiz/templates/shared_img/start.blade.php new file mode 100644 index 0000000..12edffd --- /dev/null +++ b/resources/views/quiz/templates/shared_img/start.blade.php @@ -0,0 +1,433 @@ +@extends('layouts.quiz_guest') + +@section('meta') +@endsection + +@section('content') + + +@if(! empty($imageThemePartial)) + @include($imageThemePartial, ['templateScreen' => $templateScreen ?? 'start']) +@endif + +
    + +
    +
    +
    +
    +
    + @if($quiz->image) +
    + {{ $quiz->title }} +
    + @endif + +
    + {{ __('join.app_title') }} +
    + +

    + {{ __('join.for_quiz') }} +

    + +

    + {{ $quiz->title }} +

    + +
    + {{ __('join.student') }} {{ session('student_name') }} +
    + +
    +
    + + + + {{ __('join.starting_shortly_in', ['seconds' => 10]) }} + +
    +
    + +

    + {{ __('join.get_ready') }} +

    + + @if(! empty($imageStartNoticePartial)) + @include($imageStartNoticePartial) + @endif +
    +
    +
    +
    +
    + +{{-- Redirect automatically after the countdown so all templates start the attempt consistently. --}} +@endsection diff --git a/resources/views/quiz/templates/shared_img/student.blade.php b/resources/views/quiz/templates/shared_img/student.blade.php new file mode 100644 index 0000000..7a219b0 --- /dev/null +++ b/resources/views/quiz/templates/shared_img/student.blade.php @@ -0,0 +1,373 @@ +@extends('layouts.quiz_guest') + +@section('content') + + +@if(! empty($imageThemePartial)) + @include($imageThemePartial, ['templateScreen' => $templateScreen ?? 'student']) +@endif + +
    + +
    +
    +
    +
    + @if($quiz->image) +
    +
    + {{ $quiz->title }} +
    +
    + @endif + +
    +
    + + {{ __('join.app_title') }} +
    + +

    + {{ __('join.for_quiz') }} +

    + +

    + {{ $quiz->title }} +

    + + @if($quiz->description) +
    + + {{ __('join.description_label') }} + +
    {{ $quiz->description }}
    +
    + @endif + + @if(session('error')) +
    + {{ session('error') }} +
    + @endif + +
    + @csrf + +
    + +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    + +{{-- Keep the student-code reveal toggle explicit for shared kiosk or classroom devices. --}} +@endsection diff --git a/resources/views/quiz_attempts/anonymous_cards_pdf.blade.php b/resources/views/quiz_attempts/anonymous_cards_pdf.blade.php index 43f095a..7002e47 100644 --- a/resources/views/quiz_attempts/anonymous_cards_pdf.blade.php +++ b/resources/views/quiz_attempts/anonymous_cards_pdf.blade.php @@ -8,14 +8,16 @@ body { font-family: DejaVu Sans, sans-serif; font-size: 12px; color: #1f2937; } h1 { margin: 0 0 6px; font-size: 18px; } p { margin: 0 0 14px; color: #4b5563; } - table { width: 100%; border-collapse: collapse; } - td { width: 50%; padding: 8px; vertical-align: top; } - .card { border: 1px solid #d1d5db; border-radius: 10px; padding: 14px; min-height: 250px; } + table { width: 100%; border-collapse: collapse; table-layout: fixed; } + tr { page-break-inside: avoid; } + .card-cell { width: 50%; padding: 6px; vertical-align: top; } + .card { border: 1px solid #d1d5db; border-radius: 10px; padding: 14px; min-height: 250px; overflow: hidden; } .label { font-size: 11px; color: #6b7280; text-transform: uppercase; } .value { font-size: 18px; font-weight: bold; margin: 4px 0 10px; } + .handwrite-line { display: block; min-height: 20px; margin: 5px 0 12px; border-bottom: 1.4px solid #374151; } .muted { font-size: 11px; color: #6b7280; margin-bottom: 10px; } .qr { text-align: center; margin: 10px 0; } - .url { font-size: 9px; color: #4b5563; word-break: break-all; } + .url { font-size: 8px; line-height: 1.35; color: #4b5563; overflow-wrap: break-word; word-wrap: break-word; } @@ -23,14 +25,18 @@

    {{ __('quizzes.anonymous_cards_pdf_hint', ['quiz' => $quiz->title]) }}

    + + + + @foreach ($cards->chunk(2) as $row) @foreach ($row as $card) - @endforeach @if ($row->count() === 1) - + @endif @endforeach diff --git a/resources/views/quiz_attempts/certificate.blade.php b/resources/views/quiz_attempts/certificate.blade.php index 89b0e84..fa2b8cb 100644 --- a/resources/views/quiz_attempts/certificate.blade.php +++ b/resources/views/quiz_attempts/certificate.blade.php @@ -30,6 +30,14 @@ margin-top: 40px; font-weight: bold; } + + .certificate-name-line { + display: inline-block; + width: 320px; + min-height: 18px; + border-bottom: 2px solid #111; + vertical-align: baseline; + } @php use Illuminate\Support\Facades\URL; @@ -52,7 +60,13 @@

    {{ __('pdfexp.certificate_title') }}

    - {{ __('pdfexp.certificate_body_line1') }} {{ $attempt->student_name }}
    + {{ __('pdfexp.certificate_body_line1') }} + @if ($isAnonymousParticipant ?? false) +   + @else + {{ $attempt->student_name }} + @endif +
    {{ __('pdfexp.certificate_body_line2') }}
    {{ __('pdfexp.certificate_program') }}
    "{{ $quiz->title }}"
    diff --git a/resources/views/quiz_attempts/public_pool_invitation_pdf.blade.php b/resources/views/quiz_attempts/public_pool_invitation_pdf.blade.php new file mode 100644 index 0000000..b41628c --- /dev/null +++ b/resources/views/quiz_attempts/public_pool_invitation_pdf.blade.php @@ -0,0 +1,159 @@ + + + + + {{ __('public_pool_invitation.document_title') }} — {{ $quiz->title }} + + + +
    +
    {{ __('public_pool_invitation.eyebrow') }}
    +

    {{ __('public_pool_invitation.document_title') }}

    +
    {{ $quiz->title }}
    + + @if (filled($quiz->description)) +
    {{ $quiz->description }}
    + @endif + +
    +
    {{ __('quizzes.student_name') }}
    -
    {{ $card['student_name'] }}
    +
     
    {{ __('quizzes.student_code') }}
    {{ $card['student_code'] }}
    @@ -44,12 +50,12 @@ @endif
    {{ __('quizzes.personal_link') }}
    -
    {{ $card['student_url'] }}
    +
    @foreach(str_split((string) $card['student_url'], 18) as $urlChunk){{ $urlChunk }}​@endforeach
    + + + + + +
    + {{ __('public_pool_invitation.educator') }} + {{ $quiz->creator?->name ?? '—' }} + + {{ __('public_pool_invitation.duration') }} + {{ __('public_pool_invitation.minutes', ['count' => max(1, (int) ceil($quiz->time_limit / 60))]) }} + + {{ __('public_pool_invitation.capacity') }} + {{ $effectiveCapacity }} +
    + +
    + {{ __('public_pool_invitation.qr_alt') }} +
    + +
    {{ __('public_pool_invitation.instruction') }}
    +
    @foreach(str_split($publicUrl, 32) as $urlChunk){{ $urlChunk }}​@endforeach
    + +
    {{ __('public_pool_invitation.anonymous_notice') }}
    + + @if ($expiresAt) +
    + {{ __('public_pool_invitation.expires_at', ['date' => $expiresAt->timezone(config('app.timezone'))->format('d/m/Y H:i')]) }} +
    + @else +
    {{ __('public_pool_invitation.no_expiration') }}
    + @endif + + + diff --git a/resources/views/quiz_attempts/register_students.blade.php b/resources/views/quiz_attempts/register_students.blade.php index 7d24f46..0f3d477 100644 --- a/resources/views/quiz_attempts/register_students.blade.php +++ b/resources/views/quiz_attempts/register_students.blade.php @@ -78,7 +78,7 @@
    -

    {{ $student->student_name }}

    +

    {{ $student->displayName($quiz) }}

    {{ $code }} @@ -182,7 +182,6 @@ class="btn dashboard-btn dashboard-btn--ghost" @endif - @php $guestUrl = $quiz->publicAccessUrl(); @endphp @if (!empty($guestUrl))
    @@ -196,20 +195,31 @@ class="btn dashboard-btn dashboard-btn--ghost"
    -
    +
    $quiz->is_public_anonymous_pool_mode + && $quiz->status === 'active' + && !empty($publicPoolInvitationPdfUrl), + ])> + -
    - -
    + + @if ($quiz->is_public_anonymous_pool_mode && $quiz->status === 'active' && !empty($publicPoolInvitationPdfUrl)) + + {{ __('quizzes.download_public_pool_invitation_pdf') }} + + @endif
    @endif @@ -225,7 +235,7 @@ class="btn dashboard-btn dashboard-btn--ghost"
    - @if(isset($studentLimit) && !auth()->user()?->isAdmin()) + @if(isset($studentLimit))
    {{ __('quizzes.student_limit_status', ['count' => $students->count(), 'limit' => $studentLimit]) }}
    @@ -235,7 +245,7 @@ class="btn dashboard-btn dashboard-btn--ghost" @if ($quiz->is_public_anonymous_pool_mode)
    -
    {{ __('quizzes.public_anonymous_pool_dashboard_hint', ['count' => $publicAnonymousPoolCompletedCount ?? 0, 'capacity' => $quiz->anonymous_pool_capacity ?? 0]) }}
    +
    {{ __('quizzes.public_anonymous_pool_dashboard_hint', ['count' => $publicAnonymousPoolCompletedCount ?? 0, 'capacity' => $effectiveAnonymousPoolCapacity ?? $quiz->anonymous_pool_capacity ?? 0]) }}
    @elseif ($quiz->is_anonymous_bulk_mode)
    @@ -246,7 +256,7 @@ class="btn dashboard-btn dashboard-btn--ghost" - +
    {{ __('quizzes.anonymous_slots_count_hint') }}
    diff --git a/resources/views/quiz_attempts/result_pdf.blade.php b/resources/views/quiz_attempts/result_pdf.blade.php index e3dd4ae..d01404a 100644 --- a/resources/views/quiz_attempts/result_pdf.blade.php +++ b/resources/views/quiz_attempts/result_pdf.blade.php @@ -120,6 +120,14 @@ font-weight: bold; } + .result-name-line { + display: inline-block; + width: 170px; + min-height: 15px; + border-bottom: 1.5px solid #11284d; + vertical-align: baseline; + } + .metric-strip { margin: 16px 0 18px; } @@ -335,7 +343,13 @@ - + diff --git a/resources/views/quiz_attempts/student_info_pdf.blade.php b/resources/views/quiz_attempts/student_info_pdf.blade.php index 7e66616..30d15f1 100644 --- a/resources/views/quiz_attempts/student_info_pdf.blade.php +++ b/resources/views/quiz_attempts/student_info_pdf.blade.php @@ -131,6 +131,13 @@ font-weight: bold; } + .handwrite-line { + display: block; + width: 100%; + min-height: 17px; + border-bottom: 1.4px solid #31507f; + } + .access-section { margin-top: 16px; border: 1px solid #d6deeb; @@ -292,7 +299,13 @@
    {{ __('pdfexp.student_name') }}{{ $attempt->student_name ?? '—' }} + @if ($isAnonymousParticipant ?? false) +   + @else + {{ $attempt->student_name ?? '—' }} + @endif +
    {{ __('pdfexp.student_code') }}
    - + @@ -321,7 +334,6 @@ @if (($is_guest && $guest_url) || (!$is_guest && ($student_url || $pin_join_url)))
    {{ __('pdfexp.access_title') }}
    -
    {{ __('pdfexp.qr_label') }}
    {{ __('pdfexp.student_name') }}{{ $student->student_name }} + @if($student->is_anonymous) +   + @else + {{ $student->student_name }} + @endif +
    {{ __('pdfexp.student_code') }}
    diff --git a/resources/views/quiz_attempts/students_report_pdf.blade.php b/resources/views/quiz_attempts/students_report_pdf.blade.php index 64b13ac..2885896 100644 --- a/resources/views/quiz_attempts/students_report_pdf.blade.php +++ b/resources/views/quiz_attempts/students_report_pdf.blade.php @@ -1,133 +1,218 @@ - + - {{ __('pdfexp.students_list_title') ?? 'Κατάλογος Μαθητών' }} + {{ __('pdfexp.students_list_title') }} -
    - {{ __('pdfexp.issue_date') ?? 'Ημερομηνία έκδοσης:' }} {{ \Carbon\Carbon::now()->format('d/m/Y') }} -
    - -

    {{ __('pdfexp.students_list_title') ?? 'Κατάλογος Καταχωρημένων Μαθητών' }}

    - -
    -

    {{ __('pdfexp.quiz_info') }}

    -
    - - - - - - - - - - - - -
    {{ __('pdfexp.quiz_title') }}:{{ $quiz->title }}
    {{ __('pdfexp.quiz_code') }}:{{ $quiz->quiz_code }}
    {{ __('pdfexp.quiz_teacher') }}:{{ $quiz->creator->name ?? '—' }}
    - - -

    - {{ __('pdfexp.student_list') ?? 'Λίστα Μαθητών' }} -

    - - - - - - - - - - - - @foreach ($data as $index => $entry) +
    +
    +
    {{ __('pdfexp.student_list') }}
    +

    {{ __('pdfexp.students_list_title') }}

    +

    + {{ __('pdfexp.issue_date') }} {{ now()->format('d/m/Y') }} +

    +
    + +
    +
    {{ __('pdfexp.quiz_info') }}
    +
    {{ $quiz->title }}
    + {{ __('pdfexp.quiz_code') }}: {{ $quiz->quiz_code }} + {{ __('pdfexp.quiz_teacher') }}: {{ $quiz->creator->name ?? '—' }} + {{ __('pdfexp.registered_students_count') }}: {{ count($data) }} +
    + +

    {{ __('pdfexp.student_list') }}

    + +
    #{{ __('pdfexp.student_name') }}{{ __('pdfexp.student_code') }}{{ __('pdfexp.max_attempts') }}
    + - - - - + + + + - @endforeach - -
    {{ $index + 1 }}{{ $entry['name'] }}{{ $entry['code'] }}{{ $entry['max_attempts'] }}#{{ __('pdfexp.student_name') }}{{ __('pdfexp.student_code') }}{{ __('pdfexp.max_attempts') }}
    + + + @foreach ($data as $index => $entry) + + {{ $index + 1 }} + + @if($entry['is_anonymous']) +   + @else + {{ $entry['name'] }} + @endif + + {{ $entry['code'] }} + {{ $entry['max_attempts'] }} + + @endforeach + + + + + diff --git a/resources/views/quizzes/create.blade.php b/resources/views/quizzes/create.blade.php index 7025882..2e6c47d 100644 --- a/resources/views/quizzes/create.blade.php +++ b/resources/views/quizzes/create.blade.php @@ -184,6 +184,41 @@ +
    +

    {{ __('quizzes_cards.participation_modes_section') }}

    +

    {{ __('quizzes_cards.participation_modes_notice') }}

    + +
    +
    + + +
    {{ __('quizzes_cards.anonymous_bulk_mode_notice') }}
    +
    + +
    + + +
    {{ __('quizzes_cards.public_anonymous_pool_mode_notice') }}
    +
    +
    + +
    + + +
    + {{ __('quizzes.anonymous_pool_capacity_hint') }} + @if ($participantLimit !== null) + {{ __('quizzes.anonymous_pool_account_limit', ['limit' => $participantLimit]) }} + @endif +
    + @error('anonymous_pool_capacity') +
    {{ $message }}
    + @enderror +
    +
    + @if (Auth::user()->isAdmin())

    {{ __('quizzes_cards.create_section_d') }}

    @@ -200,26 +235,6 @@
    {{ __('display.mode_notice') }}
    - -
    - - -
    {{ __('quizzes_cards.anonymous_bulk_mode_notice') }}
    -
    - -
    - - -
    {{ __('quizzes_cards.public_anonymous_pool_mode_notice') }}
    -
    - - -
    - - -
    {{ __('quizzes.anonymous_pool_capacity_hint') }}
    @endif diff --git a/resources/views/quizzes/edit.blade.php b/resources/views/quizzes/edit.blade.php index 976ad96..b56226b 100644 --- a/resources/views/quizzes/edit.blade.php +++ b/resources/views/quizzes/edit.blade.php @@ -216,6 +216,43 @@ class="form-check-input" +
    +

    {{ __('quizzes_cards.participation_modes_section') }}

    +

    {{ __('quizzes_cards.participation_modes_notice') }}

    + +
    +
    + + is_anonymous_bulk_mode) ? 'checked' : '' }} @if($isContentLocked) disabled @endif> + +
    {{ __('quizzes_cards.anonymous_bulk_mode_notice') }}
    +
    + +
    + + is_public_anonymous_pool_mode) ? 'checked' : '' }} @if($isContentLocked) disabled @endif> + +
    {{ __('quizzes_cards.public_anonymous_pool_mode_notice') }}
    +
    +
    + +
    + + +
    + {{ __('quizzes.anonymous_pool_capacity_hint') }} + @if ($participantLimit !== null) + {{ __('quizzes.anonymous_pool_account_limit', ['limit' => $participantLimit]) }} + @endif +
    + @error('anonymous_pool_capacity') +
    {{ $message }}
    + @enderror +
    +
    + @if (Auth::user()->isAdmin())

    {{ __('quizzes_cards.create_section_d') }}

    @@ -234,28 +271,6 @@ class="form-check-input"
    {{ __('display.mode_notice') }}
    - -
    - - is_anonymous_bulk_mode) ? 'checked' : '' }} @if($isContentLocked) disabled @endif> - -
    {{ __('quizzes_cards.anonymous_bulk_mode_notice') }}
    -
    - -
    - - is_public_anonymous_pool_mode) ? 'checked' : '' }} @if($isContentLocked) disabled @endif> - -
    {{ __('quizzes_cards.public_anonymous_pool_mode_notice') }}
    -
    - - -
    - - -
    {{ __('quizzes.anonymous_pool_capacity_hint') }}
    @endif diff --git a/resources/views/quizzes/index.blade.php b/resources/views/quizzes/index.blade.php index be2d699..09d24c2 100644 --- a/resources/views/quizzes/index.blade.php +++ b/resources/views/quizzes/index.blade.php @@ -1,6 +1,10 @@ @extends('layouts.navigation') @section('content') +@php + $isAdmin = auth()->user()->isAdmin(); +@endphp +
    @@ -10,8 +14,12 @@ {{ __('quizzes.index_title') }} -

    {{ __('dashboard.my_active_quizzes') }}

    -

    {{ __('dashboard.quiz_collection_intro') }}

    +

    + {{ $isAdmin ? __('dashboard.all_quizzes_collection') : __('dashboard.my_active_quizzes') }} +

    +

    + {{ $isAdmin ? __('dashboard.all_quizzes_collection_intro') : __('dashboard.quiz_collection_intro') }} +

    @@ -136,7 +144,7 @@

    - {{ ($exampleQuizzes ?? collect())->isNotEmpty() ? __('quizzes.no_personal_quizzes') : __('quizzes.no_quizzes') }} + {{ ! $isAdmin && ($exampleQuizzes ?? collect())->isNotEmpty() ? __('quizzes.no_personal_quizzes') : __('quizzes.no_quizzes') }}

    @if ($canCreateQuiz ?? true) @@ -163,6 +171,15 @@
    + @if($isAdmin) + + + {{ __('quizzes.creator_label') }}: + {{ $quiz->creator?->id === auth()->id() + ? __('quizzes.creator_you') + : ($quiz->creator?->name ?? __('quizzes.creator_unavailable')) }} + + @endif {{ $quiz->category->name }} diff --git a/routes/web.php b/routes/web.php index b9c8474..7fadb40 100644 --- a/routes/web.php +++ b/routes/web.php @@ -93,6 +93,9 @@ Route::get('quizzes/catalogue', [QuizParticipantController::class, 'catalogue'])->name('quizzes.catalogue'); Route::get('quizzes/{quiz}/students/report', [QuizAttemptController::class, 'studentsReportPdf'])->name('quiz_attempts.students_report_pdf'); Route::get('quizzes/{quiz}/anonymous-cards-pdf', [QuizAttemptController::class, 'downloadAnonymousCardsPdf'])->name('quiz_attempts.anonymous_cards_pdf'); + Route::get('quizzes/{quiz}/public-pool-invitation-pdf', [QuizAttemptController::class, 'downloadPublicPoolInvitationPdf']) + ->middleware('signed') + ->name('quiz_attempts.public_pool_invitation_pdf'); Route::post('quizzes/{quiz}/quiz_attempts/{quizAttempt}/submit', [QuizAttemptController::class, 'submit'])->name('quizzes.quiz_attempts.submit'); Route::get('/quiz_attempts/{attempt}/certificate', [QuizAttemptController::class, 'downloadCertificate'])->name('quiz_attempts.certificate'); diff --git a/tests/Feature/AnonymousStudentPdfTest.php b/tests/Feature/AnonymousStudentPdfTest.php new file mode 100644 index 0000000..a2ccbf9 --- /dev/null +++ b/tests/Feature/AnonymousStudentPdfTest.php @@ -0,0 +1,222 @@ + 'Pre-registered Exam Slots Quiz', + 'description' => 'Printable exam-slot mapping.', + 'quiz_code' => 'ABCD1234', + 'status' => 'active', + 'has_timer' => false, + 'allow_resume' => false, + 'is_anonymous_bulk_mode' => true, + ]); + $quiz->id = 8; + $quiz->setRelation('creator', new User(['name' => 'Quiz Teacher'])); + + return $quiz; +} + +test('individual student sheet leaves a handwritten name line only for pre-registered exam slots', function () { + $quiz = anonymousPdfQuiz(); + $anonymousStudent = new QuizStudent([ + 'student_code' => '0001', + 'student_name' => QuizStudent::examSlotName('0001'), + 'max_attempts' => 1, + 'is_anonymous' => true, + ]); + $registeredStudent = new QuizStudent([ + 'student_code' => '1001', + 'student_name' => 'Registered Student', + 'max_attempts' => 1, + 'is_anonymous' => false, + ]); + $viewData = [ + 'quiz' => $quiz, + 'join_url' => 'https://example.test/exams', + 'student_url' => 'https://example.test/exams/student', + 'guest_url' => null, + 'pin_join_url' => 'https://example.test/exams', + 'qr_svg' => base64_encode(''), + 'is_guest' => false, + 'show_pin_access' => true, + 'show_personal_link' => true, + ]; + + $anonymousHtml = view('quiz_attempts.student_info_pdf', [ + ...$viewData, + 'student' => $anonymousStudent, + ])->render(); + $registeredHtml = view('quiz_attempts.student_info_pdf', [ + ...$viewData, + 'student' => $registeredStudent, + ])->render(); + + expect($anonymousHtml) + ->toContain('class="handwrite-line"') + ->not->toContain(QuizStudent::examSlotName('0001')) + ->toContain('0001') + ->and(substr_count($anonymousHtml, __('pdfexp.qr_label')))->toBe(1) + ->and($registeredHtml) + ->toContain('Registered Student') + ->not->toContain(' '); +}); + +test('student register keeps real names and replaces exam-slot labels with handwritten lines', function () { + $html = view('quiz_attempts.students_report_pdf', [ + 'quiz' => anonymousPdfQuiz(), + 'data' => collect([ + [ + 'name' => QuizStudent::examSlotName('0001'), + 'code' => '0001', + 'max_attempts' => 1, + 'is_anonymous' => true, + ], + [ + 'name' => 'Registered Student', + 'code' => '1001', + 'max_attempts' => 2, + 'is_anonymous' => false, + ], + ]), + ])->render(); + + expect($html) + ->toContain('class="sheet"') + ->toContain('class="students-table"') + ->toContain(' ') + ->not->toContain(QuizStudent::examSlotName('0001')) + ->toContain('Registered Student') + ->toContain('0001') + ->toContain('1001'); +}); + +test('exam-slot QR cards provide a handwritten name line without carrying a placeholder name', function () { + $html = view('quiz_attempts.anonymous_cards_pdf', [ + 'quiz' => anonymousPdfQuiz(), + 'cards' => collect([ + [ + 'student_code' => '0001', + 'max_attempts' => 1, + 'student_url' => 'https://example.test/exams/student', + 'qr_svg' => null, + ], + ]), + ])->render(); + + expect($html) + ->toContain('table-layout: fixed') + ->toContain('class="card-cell"') + ->toContain('class="handwrite-line"') + ->toContain('​') + ->not->toContain(QuizStudent::examSlotName('0001')) + ->toContain('0001'); +}); + +test('legacy anonymous placeholders are presented as exam slots only in pre-registered bulk mode', function () { + $student = new QuizStudent([ + 'student_code' => '0001', + 'student_name' => __('controllers.anonymous_student_name'), + 'is_anonymous' => true, + ]); + $bulkQuiz = anonymousPdfQuiz(); + $publicPoolQuiz = new Quiz([ + 'is_anonymous_bulk_mode' => false, + 'is_public_anonymous_pool_mode' => true, + ]); + + expect($student->displayName($bulkQuiz)) + ->toBe(QuizStudent::examSlotName('0001')) + ->and($student->displayName($publicPoolQuiz)) + ->toBe(__('controllers.anonymous_student_name')); +}); + +test('certificate leaves a handwritten name line only for anonymous participants', function () { + $quiz = anonymousPdfQuiz(); + $quiz->setAttribute('is_certificate_verification_enabled', false); + + $anonymousAttempt = new QuizAttempt([ + 'student_name' => __('controllers.anonymous_student_name'), + 'score' => 100, + 'submitted_at' => now(), + ]); + $anonymousAttempt->id = 2; + + $registeredAttempt = new QuizAttempt([ + 'student_name' => 'Registered Student', + 'score' => 100, + 'submitted_at' => now(), + ]); + $registeredAttempt->id = 3; + + $anonymousHtml = view('quiz_attempts.certificate', [ + 'attempt' => $anonymousAttempt, + 'quiz' => $quiz, + 'isAnonymousParticipant' => true, + ])->render(); + $registeredHtml = view('quiz_attempts.certificate', [ + 'attempt' => $registeredAttempt, + 'quiz' => $quiz, + 'isAnonymousParticipant' => false, + ])->render(); + + expect($anonymousHtml) + ->toContain('class="certificate-name-line"') + ->not->toContain(__('controllers.anonymous_student_name')) + ->and($registeredHtml) + ->toContain('Registered Student') + ->not->toContain('class="certificate-name-line"'); +}); + +test('result PDF leaves a handwritten name line only for anonymous participants', function () { + $quiz = anonymousPdfQuiz(); + $quiz->setRelation('questions', collect()); + + $anonymousAttempt = new QuizAttempt([ + 'student_code' => '0002', + 'student_name' => __('controllers.anonymous_student_name'), + 'score' => 100, + 'submitted_at' => now(), + ]); + $anonymousAttempt->id = 2; + + $registeredAttempt = new QuizAttempt([ + 'student_code' => '1002', + 'student_name' => 'Registered Student', + 'score' => 100, + 'submitted_at' => now(), + ]); + $registeredAttempt->id = 3; + + $viewData = [ + 'quiz' => $quiz, + 'groupedAnswersByQuestion' => collect(), + 'questionResults' => [], + 'correctAnswersMap' => [], + 'scorePercentage' => 100, + ]; + + $anonymousHtml = view('quiz_attempts.result_pdf', [ + ...$viewData, + 'attempt' => $anonymousAttempt, + 'isAnonymousParticipant' => true, + ])->render(); + $registeredHtml = view('quiz_attempts.result_pdf', [ + ...$viewData, + 'attempt' => $registeredAttempt, + 'isAnonymousParticipant' => false, + ])->render(); + + expect($anonymousHtml) + ->toContain('class="result-name-line"') + ->not->toContain(__('controllers.anonymous_student_name')) + ->toContain('0002') + ->and($registeredHtml) + ->toContain('Registered Student') + ->not->toContain('class="result-name-line"'); +}); diff --git a/tests/Feature/DocumentationPagesTest.php b/tests/Feature/DocumentationPagesTest.php new file mode 100644 index 0000000..b2f44fd --- /dev/null +++ b/tests/Feature/DocumentationPagesTest.php @@ -0,0 +1,49 @@ +toBeFalse() + ->and($htmlFiles)->not->toBeEmpty(); + + $filesToInspect = [ + base_path('README.md'), + ...$htmlFiles, + $docsPath.'/assets/pages.css', + $docsPath.'/assets/pages.js', + ]; + + foreach ($filesToInspect as $file) { + $contents = file_get_contents($file); + + expect($contents) + ->not->toContain('sponsor.html') + ->not->toContain('paypal.com/donate') + ->not->toContain('name="theme-color"'); + } +}); + +test('public documentation local links and assets resolve', function () { + $htmlFiles = glob(base_path('docs/*.html')) ?: []; + $brokenReferences = []; + + foreach ($htmlFiles as $file) { + $contents = file_get_contents($file); + preg_match_all('/(?:href|src)="([^"]+)"/', $contents, $matches); + + foreach ($matches[1] as $reference) { + if (preg_match('/^(?:https?:|mailto:|tel:|#|data:|javascript:)/', $reference)) { + continue; + } + + $relativePath = preg_split('/[?#]/', $reference, 2)[0]; + + if ($relativePath !== '' && ! file_exists(dirname($file).'/'.$relativePath)) { + $brokenReferences[] = basename($file).' -> '.$reference; + } + } + } + + expect($brokenReferences)->toBeEmpty(); +}); diff --git a/tests/Feature/ModernImageQuizTemplateTest.php b/tests/Feature/ModernImageQuizTemplateTest.php new file mode 100644 index 0000000..1497bd9 --- /dev/null +++ b/tests/Feature/ModernImageQuizTemplateTest.php @@ -0,0 +1,407 @@ + 'Modern image quiz', + 'description' => 'Adaptive answer layout test', + 'show_answer_numbering' => true, + 'allow_resume' => false, + 'has_timer' => false, + 'time_limit' => 600, + 'pass_percentage' => 50, + 'language' => 'en', + ]); + + $answers = new Collection; + foreach ($answerTexts as $index => $text) { + $answers->push(new Answer([ + 'text' => $text, + 'is_correct' => $index < $correctAnswersCount, + ])); + } + + $question = new Question([ + 'text' => 'Which answer is the most appropriate?', + 'image' => $image, + 'correct_answers_count' => $correctAnswersCount, + 'order' => 1, + ]); + $question->setRelation('answers', $answers); + + return view('quiz.templates.modern_img.question', [ + 'quiz' => $quiz, + 'question' => $question, + 'quizRouteKey' => 'quiz-route-key', + 'questionRouteKey' => 'question-route-key', + 'questionProgressLabel' => 'Question 1 of 1', + 'currentQuestionNumber' => 1, + 'totalQuestions' => 1, + 'timeRemaining' => 0, + 'allowDisplay' => true, + 'isLastQuestion' => true, + 'isReviewPass' => false, + 'showLearningFeedback' => false, + 'isLearningMode' => false, + ])->render(); +} + +test('the core template seeder registers modern image as private and preserves admin visibility changes', function () { + (new QuizTemplateSeeder)->run(); + + $template = QuizTemplate::query()->where('code', 'modern_img')->first(); + + expect($template)->not->toBeNull() + ->and((bool) $template->is_common)->toBeFalse(); + + $template->update(['is_common' => true]); + (new QuizTemplateSeeder)->run(); + + expect((bool) $template->fresh()->is_common)->toBeTrue(); +}); + +test('modern image template uses a compact answer grid for short answers', function () { + $html = renderModernImageQuestion([ + 'Athens', + 'Thessaloniki', + 'Patras', + 'Heraklion', + ]); + + expect($html) + ->toContain('data-answer-layout="compact"') + ->toContain('image-answer-list--compact') + ->toContain('--modern-accent: #176b70') + ->toContain('clip: rect(0, 0, 0, 0)') + ->toContain('clip-path: inset(50%)') + ->toContain('role="status" aria-live="polite" aria-atomic="true"') + ->not->toContain(__('join.template_media_notice')); +}); + +test('modern image template keeps long answers in a comfortable single column', function () { + $html = renderModernImageQuestion([ + 'Energy is transferred between systems while the total amount remains conserved.', + 'Energy disappears completely whenever an object stops moving.', + 'Only electrical systems can store or transfer energy.', + 'Temperature and energy are always exactly the same physical quantity.', + ]); + + expect($html) + ->toContain('data-answer-layout="comfortable"') + ->toContain('image-answer-list--comfortable') + ->not->toContain('class="answer-list image-answer-list--compact"'); +}); + +test('modern image template renders multiple-correct questions as an accessible checkbox group', function () { + $html = renderModernImageQuestion([ + 'First correct answer', + 'Second correct answer', + 'Incorrect answer', + 'Another incorrect answer', + ], correctAnswersCount: 2); + + expect($html) + ->toContain('data-correct-count="2"') + ->toContain('data-instruction-text="'.trans_choice('join.select_instruction', 2, ['count' => 2]).'"') + ->toContain('type="checkbox" name="answer_id[]"') + ->not->toContain('type="radio" name="answer_id[]"') + ->toContain('role="status" aria-live="polite" aria-atomic="true"'); +}); + +test('default image template does not receive modern theme markup', function () { + $quiz = new Quiz([ + 'title' => 'Default image quiz', + 'show_answer_numbering' => true, + 'allow_resume' => false, + 'has_timer' => false, + 'time_limit' => 600, + 'pass_percentage' => 50, + 'language' => 'en', + ]); + $question = new Question([ + 'text' => 'Default template question', + 'correct_answers_count' => 1, + 'order' => 1, + ]); + $question->setRelation('answers', new Collection([ + new Answer(['text' => 'First', 'is_correct' => true]), + new Answer(['text' => 'Second', 'is_correct' => false]), + ])); + + $html = view('quiz.templates.default_img.question', [ + 'quiz' => $quiz, + 'question' => $question, + 'quizRouteKey' => 'quiz-route-key', + 'questionRouteKey' => 'question-route-key', + 'questionProgressLabel' => 'Question 1 of 1', + 'currentQuestionNumber' => 1, + 'totalQuestions' => 1, + 'timeRemaining' => 0, + 'allowDisplay' => true, + 'isLastQuestion' => true, + 'isReviewPass' => false, + 'showLearningFeedback' => false, + 'isLearningMode' => false, + ])->render(); + + expect($html) + ->not->toContain('data-answer-layout') + ->not->toContain('--modern-accent: #176b70') + ->not->toContain(__('join.template_media_notice')); +}); + +test('default and modern image templates depend on a neutral shared image layer', function () { + foreach (['question', 'result', 'start', 'student'] as $screen) { + $defaultWrapper = file_get_contents(resource_path("views/quiz/templates/default_img/{$screen}.blade.php")); + $modernWrapper = file_get_contents(resource_path("views/quiz/templates/modern_img/{$screen}.blade.php")); + $sharedView = file_get_contents(resource_path("views/quiz/templates/shared_img/{$screen}.blade.php")); + + expect($defaultWrapper) + ->toContain("quiz.templates.shared_img.{$screen}") + ->not->toContain('quiz.templates.modern_img') + ->and($modernWrapper) + ->toContain("quiz.templates.shared_img.{$screen}") + ->not->toContain('quiz.templates.default_img') + ->and($sharedView) + ->not->toContain('quiz.templates.modern_img') + ->not->toContain('quiz.templates.default_img'); + } +}); + +test('the internal shared image layer cannot be registered as a selectable template', function () { + $admin = User::factory()->create(['role' => 'admin']); + + $response = $this + ->actingAs($admin) + ->post(route('quiz_templates.store'), [ + 'code' => 'shared_img', + 'name' => 'Internal shared layer', + ]); + + $response->assertSessionHasErrors('code'); + expect(QuizTemplate::query()->where('code', 'shared_img')->exists())->toBeFalse(); +}); + +test('all modern image participant screens render with the shared visual theme', function () { + $quiz = new Quiz([ + 'title' => 'Modern participant experience', + 'description' => 'A calm modern template with image support.', + 'show_answer_numbering' => true, + 'allow_guest' => true, + 'is_public' => true, + 'public_token' => 'modern-template-public-token', + 'allow_resume' => false, + 'has_timer' => false, + 'time_limit' => 600, + 'pass_percentage' => 50, + 'language' => 'en', + 'image' => 'quiz-images/modern-cover.jpg', + ]); + $quiz->id = 8; + $attempt = new QuizAttempt([ + 'student_code' => '0000', + 'student_name' => 'Guest participant', + ]); + + $studentHtml = view('quiz.templates.modern_img.student', compact('quiz'))->render(); + $startHtml = view('quiz.templates.modern_img.start', [ + 'quiz' => $quiz, + 'quizRouteKey' => 'quiz-route-key', + ])->render(); + $resultHtml = view('quiz.templates.modern_img.result', [ + 'quiz' => $quiz, + 'attempt' => $attempt, + 'totalQuestions' => 4, + 'correctCount' => 3, + 'scorePercentage' => 75.0, + 'remainingAttempts' => 0, + 'isLearningModeResult' => false, + ])->render(); + $defaultResultHtml = view('quiz.templates.default_img.result', [ + 'quiz' => $quiz, + 'attempt' => $attempt, + 'totalQuestions' => 4, + 'correctCount' => 3, + 'scorePercentage' => 75.0, + 'remainingAttempts' => 0, + 'isLearningModeResult' => false, + ])->render(); + $defaultStudentHtml = view('quiz.templates.default_img.student', compact('quiz'))->render(); + $failedResultHtml = view('quiz.templates.modern_img.result', [ + 'quiz' => $quiz, + 'attempt' => $attempt, + 'totalQuestions' => 4, + 'correctCount' => 1, + 'scorePercentage' => 25.0, + 'remainingAttempts' => 0, + 'isLearningModeResult' => false, + ])->render(); + $registeredAttempt = new QuizAttempt([ + 'student_code' => '1234', + 'student_name' => 'Registered participant', + ]); + $registeredAttempt->id = 99; + $registeredResultHtml = view('quiz.templates.modern_img.result', [ + 'quiz' => $quiz, + 'attempt' => $registeredAttempt, + 'totalQuestions' => 4, + 'correctCount' => 3, + 'scorePercentage' => 75.0, + 'remainingAttempts' => 0, + 'isLearningModeResult' => false, + ])->render(); + + foreach ([$studentHtml, $startHtml, $resultHtml] as $html) { + expect($html) + ->toContain('') + ->toContain('--modern-accent: #176b70') + ->toContain("url('".asset('storage/quiz-images/modern-cover.jpg')."')"); + } + + expect($startHtml) + ->toContain(__('join.template_media_notice')) + ->toContain(__('join.template_media_notice_aria')) + ->toContain('width: 124px') + ->toContain('stroke: var(--modern-accent)'); + + expect($resultHtml) + ->not->toContain('class="identity-chip"') + ->not->toContain(__('join.retry_quiz')) + ->toContain('repeat(auto-fit, minmax(min(100%, 14rem), 1fr))'); + + expect($defaultResultHtml) + ->not->toContain('class="identity-chip"') + ->not->toContain(__('join.retry_quiz')); + + expect($defaultStudentHtml) + ->toContain("background-image: url('".asset('storage/quiz-images/modern-cover.jpg')."')"); + + expect($failedResultHtml) + ->toContain(__('join.retry_quiz')); + + expect($registeredResultHtml) + ->toContain('class="identity-chip"') + ->toContain('Registered participant'); + + foreach ([$studentHtml, $resultHtml] as $html) { + expect($html) + ->not->toContain(__('join.template_media_notice')); + } +}); + +test('registered participant retry follows remaining attempts and personal-link access', function () { + $quiz = new Quiz([ + 'title' => 'Registered participant retry', + 'pass_percentage' => 50, + 'student_access_policy' => Quiz::STUDENT_ACCESS_POLICY_PIN_AND_LINKS, + ]); + $quiz->id = 8; + + $student = new QuizStudent([ + 'quiz_id' => 8, + 'student_code' => '1234', + 'student_name' => 'Registered participant', + 'access_token_hash' => str_repeat('a', 64), + ]); + $student->id = 77; + + $attempt = new QuizAttempt([ + 'quiz_id' => 8, + 'quiz_student_id' => 77, + 'student_code' => '1234', + 'student_name' => 'Registered participant', + ]); + $attempt->id = 99; + $attempt->setRelation('student', $student); + + $viewData = [ + 'quiz' => $quiz, + 'attempt' => $attempt, + 'totalQuestions' => 4, + 'correctCount' => 1, + 'scorePercentage' => 25.0, + 'isLearningModeResult' => false, + ]; + + $withRetryHtml = view('quiz.templates.modern_img.result', [ + ...$viewData, + 'remainingAttempts' => 1, + ])->render(); + $withoutRetryHtml = view('quiz.templates.modern_img.result', [ + ...$viewData, + 'remainingAttempts' => 0, + ])->render(); + + expect($withRetryHtml) + ->toContain(__('join.retry_quiz')) + ->toContain('class="identity-chip"') + ->and($withoutRetryHtml) + ->not->toContain(__('join.retry_quiz')) + ->toContain('class="identity-chip"'); +}); + +test('learning mode result hides score download and retry actions', function () { + $quiz = new Quiz([ + 'title' => 'Learning mode result', + 'pass_percentage' => 50, + ]); + $quiz->id = 8; + + $attempt = new QuizAttempt([ + 'student_code' => '0000', + 'student_name' => 'Guest participant', + ]); + + $html = view('quiz.templates.modern_img.result', [ + 'quiz' => $quiz, + 'attempt' => $attempt, + 'totalQuestions' => 0, + 'correctCount' => 0, + 'scorePercentage' => 0.0, + 'remainingAttempts' => 0, + 'isLearningModeResult' => true, + ])->render(); + + expect($html) + ->toContain(__('join.learning_mode_result_message')) + ->not->toContain('class="metrics-panel"') + ->not->toContain(__('join.download_pdf')) + ->not->toContain(__('join.pdf_unavailable')) + ->not->toContain(__('join.retry_quiz')) + ->toContain(__('join.back_to_home')); +}); + +test('modern image media notice follows the resolved interface locale', function () { + $previousLocale = app()->getLocale(); + + try { + app()->setLocale('en'); + + $quiz = new Quiz([ + 'title' => 'English template notice', + 'allow_guest' => true, + 'language' => 'en', + ]); + + $html = view('quiz.templates.modern_img.start', [ + 'quiz' => $quiz, + 'quizRouteKey' => 'quiz-route-key', + ])->render(); + + expect($html) + ->toContain('Images and screenshots are displayed at low resolution') + ->toContain('aria-label="Image usage notice"'); + } finally { + app()->setLocale($previousLocale); + } +}); diff --git a/tests/Feature/QuizAttemptAuthorizationTest.php b/tests/Feature/QuizAttemptAuthorizationTest.php index 1630d08..d5353e7 100644 --- a/tests/Feature/QuizAttemptAuthorizationTest.php +++ b/tests/Feature/QuizAttemptAuthorizationTest.php @@ -15,6 +15,7 @@ use App\Models\User; use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\URL; use Maatwebsite\Excel\Facades\Excel; @@ -185,9 +186,157 @@ function makeQuizAttemptForAuthorization(): array ->and(strpos($content, 'Alpha Student'))->toBeLessThan(strpos($content, 'Zeta Student')); }); -it('generates anonymous participant slots with unique codes and independent attempt limits', function () { +it('renders the guest link and copy action as one responsive accessible control', function () { + [$owner, $quiz] = makeQuizAttemptForAuthorization(); + + $quiz->update([ + 'allow_guest' => true, + 'is_public' => true, + 'public_token_hash' => Quiz::generateLinkTokenHash(), + ]); + + $this->actingAs($owner) + ->get(route('quiz_attempts.register_students', $quiz)) + ->assertOk() + ->assertSee('class="dashboard-copy-control"', false) + ->assertSee('for="guest-link" class="visually-hidden"', false) + ->assertSee('class="btn dashboard-btn dashboard-btn--ghost dashboard-copy-control__button"', false) + ->assertSee('data-copy-target="guest-link"', false); +}); + +it('downloads a signed public pool invitation with the exact shared link shown on screen', function () { $owner = User::factory()->create([ 'role' => 'teacher', + 'max_students_per_quiz' => 30, + ]); + $category = Category::create([ + 'name' => 'Public Invitation Category '.uniqid(), + ]); + $quiz = Quiz::create([ + 'title' => 'Open Knowledge Invitation', + 'description' => 'Shared anonymous assessment invitation', + 'category_id' => $category->id, + 'creator_id' => $owner->id, + 'quiz_code' => substr(str_shuffle('ABCDEFGHJKLMNPQRSTUVWXYZ23456789'), 0, 8), + 'max_attempts' => 1, + 'time_limit' => 900, + 'is_random_order' => false, + 'is_random_answers_order' => false, + 'show_answer_numbering' => false, + 'allow_guest' => false, + 'has_timer' => true, + 'allow_resume' => false, + 'pass_percentage' => 50, + 'question_view' => 'default', + 'status' => 'active', + 'questions_limit' => null, + 'is_public' => true, + 'is_public_anonymous_pool_mode' => true, + 'anonymous_pool_capacity' => 30, + 'public_token_hash' => Quiz::generateLinkTokenHash(), + 'language' => 'el', + ]); + + $page = $this->actingAs($owner) + ->get(route('quiz_attempts.register_students', $quiz)) + ->assertOk() + ->assertSee(__('quizzes.download_public_pool_invitation_pdf')) + ->assertSee('class="dashboard-copy-control dashboard-copy-control--with-actions"', false); + + preg_match('/value="([^"]+)" readonly id="guest-link"/', $page->getContent(), $linkMatches); + preg_match('/href="([^"]*public-pool-invitation-pdf[^"]*)"/', $page->getContent(), $pdfMatches); + + $sharedUrl = html_entity_decode($linkMatches[1] ?? ''); + $pdfUrl = html_entity_decode($pdfMatches[1] ?? ''); + parse_str((string) parse_url($pdfUrl, PHP_URL_QUERY), $pdfQuery); + $expiresAt = Carbon::createFromTimestamp((int) ($pdfQuery['link_expires'] ?? 0)); + + expect($sharedUrl)->not->toBe('') + ->and($pdfUrl)->not->toBe('') + ->and($sharedUrl)->toBe($quiz->publicAccessUrl($expiresAt)); + + $this->get($pdfUrl) + ->assertOk() + ->assertHeader('content-type', 'application/pdf') + ->assertDownload('quiz_invitation_'.$quiz->id.'.pdf'); + + $otherTeacher = User::factory()->create(['role' => 'teacher']); + + $this->actingAs($otherTeacher) + ->get($pdfUrl) + ->assertForbidden(); +}); + +it('rejects public pool invitation PDFs for unsigned inactive or incompatible quizzes', function () { + [$owner, $quiz] = makeQuizAttemptForAuthorization(); + + $unsignedUrl = route('quiz_attempts.public_pool_invitation_pdf', [ + 'quiz' => $quiz, + 'link_expires' => now()->addHour()->getTimestamp(), + ]); + + $this->actingAs($owner) + ->get($unsignedUrl) + ->assertForbidden(); + + $signedUrl = URL::signedRoute('quiz_attempts.public_pool_invitation_pdf', [ + 'quiz' => $quiz, + 'link_expires' => now()->addHour()->getTimestamp(), + ]); + + $this->get($signedUrl) + ->assertRedirect(route('quiz_attempts.register_students', $quiz)) + ->assertSessionHas('error', __('controllers.public_anonymous_pool_mode_disabled')); + + $quiz->update([ + 'is_public' => true, + 'is_public_anonymous_pool_mode' => true, + 'anonymous_pool_capacity' => 10, + 'public_token_hash' => Quiz::generateLinkTokenHash(), + 'status' => 'inactive', + ]); + $inactiveUrl = URL::signedRoute('quiz_attempts.public_pool_invitation_pdf', [ + 'quiz' => $quiz, + 'link_expires' => now()->addHour()->getTimestamp(), + ]); + + $this->get($inactiveUrl) + ->assertRedirect(route('quiz_attempts.register_students', $quiz)) + ->assertSessionHas('error', __('controllers.public_anonymous_pool_invitation_inactive')); +}); + +it('renders localized invitation details without participant data', function () { + [$owner, $quiz] = makeQuizAttemptForAuthorization(); + $quiz->load('creator'); + $publicUrl = 'https://example.test/public/invitation?signature=safe'; + $expiresAt = now()->addDay(); + + App::setLocale('el'); + $html = view('quiz_attempts.public_pool_invitation_pdf', [ + 'quiz' => $quiz, + 'publicUrl' => $publicUrl, + 'qrSvg' => base64_encode(''), + 'expiresAt' => $expiresAt, + 'effectiveCapacity' => 30, + ])->render(); + $normalizedHtml = str_replace('​', '', $html); + + expect($normalizedHtml) + ->toContain(__('public_pool_invitation.document_title')) + ->toContain($quiz->title) + ->toContain($owner->name) + ->toContain($publicUrl) + ->toContain('data:image/svg+xml;base64,') + ->not->toContain('student_code') + ->not->toContain('student_name'); +}); + +it('generates pre-registered exam slots with unique codes and independent attempt limits', function () { + App::setLocale('en'); + + $owner = User::factory()->create([ + 'role' => 'teacher', + 'max_students_per_quiz' => 3, ]); $category = Category::create([ @@ -230,10 +379,68 @@ function makeQuizAttemptForAuthorization(): array ->get(); expect($students)->toHaveCount(3) - ->and($students->pluck('student_name')->unique()->all())->toBe([__('controllers.anonymous_student_name')]) + ->and($students->every( + fn (QuizStudent $student) => $student->student_name === QuizStudent::examSlotName($student->student_code) + ))->toBeTrue() ->and($students->pluck('max_attempts')->unique()->all())->toBe([2]) ->and($students->pluck('student_code')->unique()->count())->toBe(3) ->and($students->every(fn (QuizStudent $student) => preg_match('/^\d{4}$/', $student->student_code) === 1 && $student->student_code !== '0000'))->toBeTrue(); + + $this->actingAs($owner) + ->post(route('quiz_attempts.store_anonymous_students', $quiz), [ + 'anonymous_slots_count' => 1, + 'anonymous_max_attempts' => 1, + ]) + ->assertRedirect(route('quiz_attempts.register_students', $quiz)) + ->assertSessionHas('error', __('controllers.student_limit_reached')); + + expect(QuizStudent::query()->where('quiz_id', $quiz->id)->count())->toBe(3); + + $legacyStudent = $students->first(); + $legacyStudent->update([ + 'student_name' => __('controllers.anonymous_student_name'), + ]); + $adminExamSlotName = QuizStudent::examSlotName($legacyStudent->student_code); + $participantExamSlotName = trans( + 'controllers.exam_slot_name', + ['code' => $legacyStudent->student_code], + $quiz->resolvedLocale(config('app.locale')) + ); + + $this->actingAs($owner) + ->get(route('quiz_attempts.register_students', $quiz)) + ->assertOk() + ->assertSee($adminExamSlotName) + ->assertDontSee(__('controllers.anonymous_student_name')); + + $this->get($legacyStudent->accessLinkUrl(now()->addMinutes(30))) + ->assertRedirect(route('quiz.start')) + ->assertSessionHas('student_name', $participantExamSlotName); + + $this->withSession(['quiz_id' => $quiz->id]) + ->post(route('quiz.validate_student'), [ + 'student_code' => $legacyStudent->student_code, + ]) + ->assertRedirect(route('quiz.start')) + ->assertSessionHas('student_name', $participantExamSlotName); + + expect( + $quiz->attempts()->latest('id')->value('student_name') + )->toBe($participantExamSlotName); + + $pdfResponses = [ + $this->actingAs($owner)->get(route('quiz_attempts.student_info_pdf', [ + 'quiz' => $quiz, + 'student_code' => $students->first()->student_code, + ])), + $this->actingAs($owner)->get(route('quiz_attempts.students_report_pdf', $quiz)), + $this->actingAs($owner)->get(route('quiz_attempts.anonymous_cards_pdf', $quiz)), + ]; + + foreach ($pdfResponses as $response) { + $response->assertOk(); + expect((string) $response->headers->get('content-type'))->toContain('application/pdf'); + } }); it('limits manual student registrations to five attempts even when resume is disabled', function () { diff --git a/tests/Feature/QuizAuthorizationTest.php b/tests/Feature/QuizAuthorizationTest.php index aa17b5a..733b241 100644 --- a/tests/Feature/QuizAuthorizationTest.php +++ b/tests/Feature/QuizAuthorizationTest.php @@ -10,6 +10,7 @@ use App\Models\Category; use App\Models\Quiz; use App\Models\QuizAttempt; +use App\Models\QuizStudent; use App\Models\QuizTemplate; use App\Models\User; use Illuminate\Http\UploadedFile; @@ -49,6 +50,55 @@ function makeQuizForAuthorization(array $ownerOverrides = [], array $quizOverrid return [$owner, $quiz]; } +it('shows every personal quiz with its creator to administrators', function () { + [$teacher, $teacherQuiz] = makeQuizForAuthorization( + ['name' => 'Teacher Quiz Owner'], + ['title' => 'Teacher Managed Quiz'] + ); + $admin = User::factory()->create([ + 'name' => 'Administrator Owner', + 'role' => 'admin', + ]); + + $adminQuiz = $teacherQuiz->replicate(); + $adminQuiz->fill([ + 'title' => 'Administrator Managed Quiz', + 'creator_id' => $admin->id, + 'quiz_code' => substr(str_shuffle('ABCDEFGHJKLMNPQRSTUVWXYZ23456789'), 0, 8), + ]); + $adminQuiz->save(); + + $this->actingAs($admin) + ->get(route('quizzes.index')) + ->assertOk() + ->assertSee(__('dashboard.all_quizzes_collection')) + ->assertSee(__('dashboard.all_quizzes_collection_intro')) + ->assertSee($teacherQuiz->title) + ->assertSee($adminQuiz->title) + ->assertSee(__('quizzes.creator_label')) + ->assertSee($teacher->name) + ->assertSee(__('quizzes.creator_you')); +}); + +it('keeps the personal quiz list private and creator metadata hidden from teachers', function () { + [$teacher, $ownQuiz] = makeQuizForAuthorization( + ['name' => 'Current Teacher'], + ['title' => 'Current Teacher Quiz'] + ); + [, $otherQuiz] = makeQuizForAuthorization( + ['name' => 'Other Teacher'], + ['title' => 'Other Teacher Quiz'] + ); + + $this->actingAs($teacher) + ->get(route('quizzes.index')) + ->assertOk() + ->assertSee(__('dashboard.my_active_quizzes')) + ->assertSee($ownQuiz->title) + ->assertDontSee($otherQuiz->title) + ->assertDontSee(__('quizzes.creator_label')); +}); + it('allows the quiz creator to open the quiz edit screen', function () { [$owner, $quiz] = makeQuizForAuthorization(); @@ -105,6 +155,42 @@ function makeQuizForAuthorization(array $ownerOverrides = [], array $quizOverrid ->and($quiz->fresh()->shouldNotifyCreatorOnPass())->toBeFalse(); }); +it('prevents changing a participation mode while participant records exist', function () { + [$owner, $quiz] = makeQuizForAuthorization([], [ + 'is_anonymous_bulk_mode' => true, + ]); + + QuizStudent::create([ + 'quiz_id' => $quiz->id, + 'student_code' => '0001', + 'student_name' => QuizStudent::examSlotName('0001'), + 'max_attempts' => 1, + 'is_anonymous' => true, + 'access_token_hash' => QuizStudent::generateLinkTokenHash(), + ]); + + $this->actingAs($owner) + ->from(route('quizzes.edit', $quiz)) + ->put(route('quizzes.update', $quiz), [ + 'title' => $quiz->title, + 'description' => $quiz->description, + 'category_id' => $quiz->category_id, + 'time_limit' => 10, + 'pass_percentage' => 60, + 'question_view' => 'default', + 'is_anonymous_bulk_mode' => '0', + 'is_public_anonymous_pool_mode' => '1', + 'anonymous_pool_capacity' => 10, + 'status' => 'active', + 'language' => 'el', + ]) + ->assertRedirect(route('quizzes.edit', $quiz)) + ->assertSessionHasErrors(['is_anonymous_bulk_mode']); + + expect((bool) $quiz->fresh()->is_anonymous_bulk_mode)->toBeTrue() + ->and((bool) $quiz->fresh()->is_public_anonymous_pool_mode)->toBeFalse(); +}); + it('returns 403 when another teacher tries to update the quiz', function () { [, $quiz] = makeQuizForAuthorization(); @@ -322,9 +408,10 @@ function makeQuizForAuthorization(array $ownerOverrides = [], array $quizOverrid ->and($createdQuiz->public_token_hash)->toBeNull(); }); -it('allows only admins to activate special anonymous modes', function () { +it('allows teachers to activate pre-registered bulk exam mode', function () { $teacher = User::factory()->create([ 'role' => 'teacher', + 'max_students_per_quiz' => 30, ]); $category = Category::create([ @@ -334,14 +421,12 @@ function makeQuizForAuthorization(array $ownerOverrides = [], array $quizOverrid $this->actingAs($teacher) ->post(route('quizzes.store'), [ 'title' => 'Teacher Special Mode Quiz', - 'description' => 'Teacher should not activate special modes', + 'description' => 'Teacher may activate a quota-limited bulk mode', 'category_id' => $category->id, 'time_limit' => 10, 'pass_percentage' => 60, 'question_view' => 'default', 'is_anonymous_bulk_mode' => '1', - 'is_public_anonymous_pool_mode' => '1', - 'anonymous_pool_capacity' => 100, 'status' => 'active', 'language' => 'el', ]) @@ -350,11 +435,93 @@ function makeQuizForAuthorization(array $ownerOverrides = [], array $quizOverrid $createdQuiz = Quiz::query()->where('creator_id', $teacher->id)->latest('id')->first(); expect($createdQuiz)->not->toBeNull() - ->and((bool) $createdQuiz->is_anonymous_bulk_mode)->toBeFalse() + ->and((bool) $createdQuiz->is_anonymous_bulk_mode)->toBeTrue() ->and((bool) $createdQuiz->is_public_anonymous_pool_mode)->toBeFalse() ->and($createdQuiz->anonymous_pool_capacity)->toBeNull(); }); +it('allows teachers to activate a public anonymous pool within their participant quota', function () { + $teacher = User::factory()->create([ + 'role' => 'teacher', + 'max_students_per_quiz' => 30, + ]); + + $category = Category::create([ + 'name' => 'Teacher Public Pool Category '.uniqid(), + ]); + + $this->actingAs($teacher) + ->post(route('quizzes.store'), [ + 'title' => 'Teacher Public Pool Quiz', + 'description' => 'Teacher quota-limited public pool', + 'category_id' => $category->id, + 'time_limit' => 10, + 'pass_percentage' => 60, + 'question_view' => 'default', + 'is_public_anonymous_pool_mode' => '1', + 'anonymous_pool_capacity' => 30, + 'status' => 'active', + 'language' => 'el', + ]) + ->assertRedirect(route('quizzes.index')); + + $createdQuiz = Quiz::query()->where('creator_id', $teacher->id)->latest('id')->first(); + + expect($createdQuiz)->not->toBeNull() + ->and((bool) $createdQuiz->is_public_anonymous_pool_mode)->toBeTrue() + ->and((bool) $createdQuiz->is_public)->toBeTrue() + ->and((bool) $createdQuiz->allow_guest)->toBeFalse() + ->and($createdQuiz->anonymous_pool_capacity)->toBe(30) + ->and($createdQuiz->public_token_hash)->not->toBeNull(); +}); + +it('rejects a teacher public anonymous pool capacity above the account quota', function () { + $teacher = User::factory()->create([ + 'role' => 'teacher', + 'max_students_per_quiz' => 30, + ]); + + $category = Category::create([ + 'name' => 'Teacher Pool Limit Category '.uniqid(), + ]); + + $this->actingAs($teacher) + ->from(route('quizzes.create')) + ->post(route('quizzes.store'), [ + 'title' => 'Oversized Teacher Public Pool', + 'description' => 'Must fail server-side validation', + 'category_id' => $category->id, + 'time_limit' => 10, + 'pass_percentage' => 60, + 'question_view' => 'default', + 'is_public_anonymous_pool_mode' => '1', + 'anonymous_pool_capacity' => 31, + 'status' => 'active', + 'language' => 'el', + ]) + ->assertRedirect(route('quizzes.create')) + ->assertSessionHasErrors(['anonymous_pool_capacity']); + + expect(Quiz::query()->where('creator_id', $teacher->id)->exists())->toBeFalse(); +}); + +it('shows participation modes to teachers while keeping administrator options hidden', function () { + $teacher = User::factory()->create([ + 'role' => 'teacher', + 'max_students_per_quiz' => 30, + ]); + + $this->actingAs($teacher) + ->get(route('quizzes.create')) + ->assertOk() + ->assertSee(__('quizzes_cards.participation_modes_section')) + ->assertSee(__('quizzes_cards.anonymous_bulk_mode')) + ->assertSee(__('quizzes_cards.public_anonymous_pool_mode')) + ->assertSee(__('quizzes.anonymous_pool_account_limit', ['limit' => 30])) + ->assertDontSee(__('quizzes_cards.certificate_verification')) + ->assertDontSee(__('display.mode_label')); +}); + it('forces public anonymous pool settings when enabled by an admin', function () { $owner = User::factory()->create([ 'role' => 'admin', @@ -546,7 +713,7 @@ function makeQuizForAuthorization(array $ownerOverrides = [], array $quizOverrid ]); $privateTemplate = QuizTemplate::create([ - 'code' => 'private_template_'.uniqid(), + 'code' => 'modern_img', 'name' => 'Private Template', 'description' => 'Not assigned to the acting teacher', 'is_common' => false, @@ -557,6 +724,11 @@ function makeQuizForAuthorization(array $ownerOverrides = [], array $quizOverrid 'name' => 'Template Validation Category '.uniqid(), ]); + $this->actingAs($owner) + ->get(route('quizzes.create')) + ->assertOk() + ->assertDontSee('value="modern_img"', false); + $this->actingAs($owner) ->post(route('quizzes.store'), [ 'title' => 'Quiz With Forbidden Template', @@ -573,6 +745,78 @@ function makeQuizForAuthorization(array $ownerOverrides = [], array $quizOverrid expect(Quiz::query()->where('creator_id', $owner->id)->count())->toBe(0); }); +it('allows an assigned teacher to view and select a private built-in template', function () { + $owner = User::factory()->create([ + 'role' => 'teacher', + ]); + + $privateTemplate = QuizTemplate::create([ + 'code' => 'modern_img', + 'name' => 'Assigned Modern Template', + 'description' => 'Available only to selected teachers', + 'is_common' => false, + ]); + $privateTemplate->users()->sync([$owner->id]); + + $category = Category::create([ + 'name' => 'Assigned Template Category '.uniqid(), + ]); + + $this->actingAs($owner) + ->get(route('quizzes.create')) + ->assertOk() + ->assertSee('value="modern_img"', false); + + $this->actingAs($owner) + ->post(route('quizzes.store'), [ + 'title' => 'Quiz With Assigned Template', + 'description' => 'Authorized private template selection', + 'category_id' => $category->id, + 'time_limit' => 10, + 'pass_percentage' => 60, + 'question_view' => 'modern_img', + 'status' => 'active', + 'language' => 'el', + ]) + ->assertSessionDoesntHaveErrors() + ->assertRedirect(route('quizzes.index')); + + expect(Quiz::query() + ->where('creator_id', $owner->id) + ->where('question_view', 'modern_img') + ->exists())->toBeTrue(); +}); + +it('does not authorize a non-core template from filesystem presence alone', function () { + $owner = User::factory()->create([ + 'role' => 'teacher', + ]); + + $category = Category::create([ + 'name' => 'Unregistered Template Category '.uniqid(), + ]); + + expect(QuizTemplate::query()->where('code', 'modern_img')->exists())->toBeFalse(); + + $this->actingAs($owner) + ->get(route('quizzes.create')) + ->assertOk() + ->assertDontSee('value="modern_img"', false); + + $this->actingAs($owner) + ->post(route('quizzes.store'), [ + 'title' => 'Quiz With Unregistered Template', + 'description' => 'Filesystem presence must not grant access', + 'category_id' => $category->id, + 'time_limit' => 10, + 'pass_percentage' => 60, + 'question_view' => 'modern_img', + 'status' => 'active', + 'language' => 'el', + ]) + ->assertSessionHasErrors(['question_view']); +}); + it('falls back to the default participant template when the configured template view is missing', function () { $owner = User::factory()->create([ 'role' => 'teacher', diff --git a/tests/Feature/QuizSecurityHardeningTest.php b/tests/Feature/QuizSecurityHardeningTest.php index a911a92..dd018fe 100644 --- a/tests/Feature/QuizSecurityHardeningTest.php +++ b/tests/Feature/QuizSecurityHardeningTest.php @@ -986,10 +986,11 @@ ->toContain('private'); }); -it('stores public anonymous pool results only after final submission', function () { +it('stores public anonymous pool results only after final submission and enforces the teacher quota', function () { /** @var TestCase $this */ - $admin = User::factory()->create([ - 'role' => 'admin', + $owner = User::factory()->create([ + 'role' => 'teacher', + 'max_students_per_quiz' => 1, ]); $category = Category::create([ @@ -1000,7 +1001,7 @@ 'title' => 'Public Pool Runtime Quiz', 'description' => 'Public pool runtime test quiz', 'category_id' => $category->id, - 'creator_id' => $admin->id, + 'creator_id' => $owner->id, 'quiz_code' => substr(str_shuffle('ABCDEFGHJKLMNPQRSTUVWXYZ23456789'), 0, 8), 'max_attempts' => 1, 'time_limit' => 600, @@ -1017,7 +1018,7 @@ 'is_public' => true, 'is_anonymous_bulk_mode' => false, 'is_public_anonymous_pool_mode' => true, - 'anonymous_pool_capacity' => 1, + 'anonymous_pool_capacity' => 100, 'public_token_hash' => Quiz::generateLinkTokenHash(), 'language' => 'el', ]); @@ -1054,11 +1055,20 @@ $questionKey = array_key_first(session('question_route_map', [])); - $this->post(route('quiz.submit_final', ['quizKey' => $quizKey]), [ + $this->post(route('quiz.submit_answer', [ + 'quizKey' => $quizKey, + 'questionKey' => $questionKey, + ]), [ 'current_question_key' => $questionKey, 'answer_id' => [$correctAnswer->id], ])->assertRedirect(route('quiz.end', ['quizKey' => $quizKey])); + expect(QuizStudent::query()->where('quiz_id', $quiz->id)->count())->toBe(0) + ->and(QuizAttempt::query()->where('quiz_id', $quiz->id)->count())->toBe(0); + + $this->get(route('quiz.end', ['quizKey' => $quizKey])) + ->assertOk(); + $student = QuizStudent::query()->where('quiz_id', $quiz->id)->first(); $attempt = QuizAttempt::query()->where('quiz_id', $quiz->id)->first(); @@ -1069,7 +1079,24 @@ ->and($attempt)->not->toBeNull() ->and($attempt->quiz_student_id)->toBe($student->id) ->and($attempt->status)->toBe(QuizAttempt::STATUS_SUBMITTED) + ->and((float) $attempt->score)->toBe(100.0) + ->and($attempt->answers()->count())->toBe(1) ->and(QuizAnonymousPoolReservation::query()->where('quiz_id', $quiz->id)->count())->toBe(0); + + $this->actingAs($owner) + ->get(route('quiz_attempts.register_students', $quiz)) + ->assertOk() + ->assertSee(__('quizzes.public_anonymous_pool_dashboard_hint', [ + 'count' => 1, + 'capacity' => 1, + ])); + + $this->get($quiz->publicAccessUrl(now()->addMinutes(30))) + ->assertRedirect(route('quiz.join')) + ->assertSessionHas('error', __('join.public_pool_full')); + + expect(QuizAnonymousPoolReservation::query()->where('quiz_id', $quiz->id)->count())->toBe(0) + ->and(QuizStudent::query()->where('quiz_id', $quiz->id)->count())->toBe(1); }); it('drops abandoned public anonymous pool sessions without persisting participants', function () { diff --git a/tests/Feature/RetiredQuizTemplateMigrationTest.php b/tests/Feature/RetiredQuizTemplateMigrationTest.php new file mode 100644 index 0000000..5c2fa3c --- /dev/null +++ b/tests/Feature/RetiredQuizTemplateMigrationTest.php @@ -0,0 +1,51 @@ +create([ + 'role' => 'teacher', + ]); + $category = Category::create([ + 'name' => 'Retired Template Migration Category', + ]); + $template = QuizTemplate::create([ + 'code' => 'retroAXD3_img', + 'name' => 'Retired Retro Template', + 'description' => 'Scheduled for permanent removal', + 'is_common' => false, + ]); + $template->users()->sync([$owner->id]); + + $quiz = Quiz::create([ + 'title' => 'Legacy Retro Quiz', + 'description' => 'Must keep rendering after the template is removed', + 'category_id' => $category->id, + 'creator_id' => $owner->id, + 'quiz_code' => 'RETRO123', + 'max_attempts' => 1, + 'time_limit' => 600, + 'is_random_order' => false, + 'is_random_answers_order' => false, + 'show_answer_numbering' => true, + 'allow_guest' => false, + 'has_timer' => false, + 'allow_resume' => false, + 'pass_percentage' => 50, + 'question_view' => 'retroAXD3_img', + 'status' => 'active', + 'language' => 'el', + ]); + + $migration = require database_path('migrations/2026_07_27_120000_remove_retro_axd3_img_template.php'); + $migration->up(); + + expect($quiz->fresh()->question_view)->toBe('default_img') + ->and(QuizTemplate::query()->where('code', 'retroAXD3_img')->exists())->toBeFalse() + ->and(DB::table('quiz_template_user')->where('quiz_template_id', $template->id)->exists())->toBeFalse() + ->and(is_dir(resource_path('views/quiz/templates/retroAXD3_img')))->toBeFalse(); +});