New_Micro_Learning/backend/app/Modules/Assessments/Http/AssessmentController.php

274 خطوط
14 KiB
PHP

<?php
namespace App\Modules\Assessments\Http;
use App\Http\Controllers\Controller;
use App\Modules\Assessments\Application\QuestionSchema;
use App\Modules\Assessments\Domain\Assessment;
use App\Modules\Assessments\Domain\Question;
use App\Modules\Assessments\Domain\QuestionResult;
use App\Modules\Courses\Domain\CourseVersion;
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
use App\Modules\Courses\Domain\Lesson;
use App\Modules\Identity\Application\RolePermissions;
use App\Modules\Identity\Domain\Enums\Permission;
use App\Modules\Tenancy\Application\TenantContext;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
final class AssessmentController extends Controller
{
public function __construct(
private readonly TenantContext $tenant,
private readonly RolePermissions $permissions,
private readonly QuestionSchema $schema,
) {}
public function contexts(Request $request): JsonResponse
{
$this->authorize($request);
$versions = CourseVersion::query()->with(['course:id,title', 'lessons:id,course_version_id,title'])
->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Draft)
->latest()->get()->map(fn (CourseVersion $version) => [
'id' => $version->getKey(), 'title' => $version->title, 'course' => ['id' => $version->course->getKey(), 'title' => $version->course->title],
'lessons' => $version->lessons->map(fn (Lesson $lesson) => ['id' => $lesson->getKey(), 'title' => $lesson->title])->values(),
]);
return response()->json(['data' => $versions]);
}
public function index(Request $request): JsonResponse
{
$this->authorize($request);
$items = Assessment::query()->with('questions')
->where('organization_id', $this->tenant->id())->latest()->get()
->map(fn (Assessment $assessment) => $this->assessmentPayload($assessment));
return response()->json(['data' => $items]);
}
public function store(Request $request): JsonResponse
{
$this->authorize($request);
$data = $this->assessmentData($request);
$version = $this->draftVersion($data['courseVersionId']);
$lesson = isset($data['lessonId']) ? Lesson::query()->where('organization_id', $this->tenant->id())->where('course_version_id', $version->getKey())->findOrFail($data['lessonId']) : null;
$assessment = Assessment::query()->create([
'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(),
'lesson_id' => $lesson?->getKey(), 'title' => $data['title'], 'settings' => $data['settings'],
]);
return response()->json(['data' => $this->assessmentPayload($assessment->load('questions'))], 201);
}
public function show(Request $request, string $assessment): JsonResponse
{
$this->authorize($request);
$model = $this->assessment($assessment)->load('questions');
return response()->json(['data' => $this->assessmentPayload($model)]);
}
public function update(Request $request, string $assessment): JsonResponse
{
$this->authorize($request);
$model = $this->assessment($assessment);
$this->assertDraft($model->course_version_id);
$rules = ['title' => ['sometimes', 'required', 'string', 'max:240'], 'settings' => ['sometimes', 'array']];
if ($request->has('settings')) {
$rules = [...$rules, ...$this->settingRules('settings.')];
}
$data = $request->validate($rules);
$model->update($data);
return response()->json(['data' => $this->assessmentPayload($model->fresh('questions'))]);
}
public function destroy(Request $request, string $assessment): JsonResponse
{
$this->authorize($request);
$model = $this->assessment($assessment);
$this->assertDraft($model->course_version_id);
abort_if($model->questions()->whereHas('questionResults')->exists(), 409, 'Assessment with learner results cannot be deleted.');
$model->delete();
return response()->json(status: 204);
}
public function storeQuestion(Request $request, string $assessment): JsonResponse
{
$this->authorize($request);
$model = $this->assessment($assessment);
$version = $this->draftVersion((string) $model->course_version_id);
$data = $request->validate([
'sourceQuestionId' => ['nullable', 'string'],
'type' => ['required_without:sourceQuestionId', Rule::in($this->schema->types())],
'prompt' => ['required_without:sourceQuestionId', 'string', 'max:5000'],
'configuration' => ['required_without:sourceQuestionId', 'array'],
'difficulty' => ['nullable', Rule::in(['beginner', 'intermediate', 'advanced'])],
'topic' => ['nullable', 'string', 'max:160'], 'tags' => ['nullable', 'array', 'max:20'], 'tags.*' => ['string', 'max:80'],
'explanation' => ['nullable', 'string', 'max:5000'], 'saveToBank' => ['nullable', 'boolean'],
]);
$source = isset($data['sourceQuestionId']) ? Question::query()->where('organization_id', $this->tenant->id())->where('is_bank_item', true)->findOrFail($data['sourceQuestionId']) : null;
$type = $source?->type ?? $data['type'];
$configuration = $this->schema->validate($type, $source?->configuration ?? $data['configuration']);
$question = Question::query()->create([
'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), 'assessment_id' => $model->getKey(),
'source_question_id' => $source?->getKey(), 'type' => $type, 'prompt' => $source?->prompt ?? $data['prompt'],
'configuration' => $configuration, 'difficulty' => $source?->difficulty ?? ($data['difficulty'] ?? null),
'topic' => $source?->topic ?? ($data['topic'] ?? null), 'tags' => $source?->tags ?? ($data['tags'] ?? []),
'explanation' => $source?->explanation ?? ($data['explanation'] ?? null), 'position' => ($model->questions()->max('position') ?? 0) + 1,
]);
if (($data['saveToBank'] ?? false) && ! $source) {
$bank = $question->replicate(['assessment_id', 'position']);
$bank->assessment_id = null;
$bank->position = 0;
$bank->is_bank_item = true;
$bank->save();
$question->update(['source_question_id' => $bank->getKey()]);
}
return response()->json(['data' => $this->questionPayload($question->fresh())], 201);
}
public function updateQuestion(Request $request, string $question): JsonResponse
{
$this->authorize($request);
$model = $this->question($question);
if ($model->course_version_id) {
$this->assertDraft($model->course_version_id);
}
$data = $request->validate([
'prompt' => ['sometimes', 'required', 'string', 'max:5000'], 'configuration' => ['sometimes', 'array'],
'difficulty' => ['sometimes', 'nullable', Rule::in(['beginner', 'intermediate', 'advanced'])],
'topic' => ['sometimes', 'nullable', 'string', 'max:160'], 'tags' => ['sometimes', 'array', 'max:20'], 'tags.*' => ['string', 'max:80'],
'explanation' => ['sometimes', 'nullable', 'string', 'max:5000'],
]);
if (isset($data['configuration'])) {
$data['configuration'] = $this->schema->validate($model->type, $data['configuration']);
}
$model->update($data);
return response()->json(['data' => $this->questionPayload($model->fresh())]);
}
public function destroyQuestion(Request $request, string $question): JsonResponse
{
$this->authorize($request);
$model = $this->question($question);
if ($model->course_version_id) {
$this->assertDraft($model->course_version_id);
}
abort_if(QuestionResult::query()->where('question_id', $model->getKey())->exists(), 409, 'Question with learner results cannot be deleted.');
DB::transaction(function () use ($model): void {
$assessmentId = $model->assessment_id;
$position = $model->position;
$model->delete();
if ($assessmentId) {
Question::query()->where('assessment_id', $assessmentId)->where('position', '>', $position)->decrement('position');
}
});
return response()->json(status: 204);
}
public function reorderQuestions(Request $request, string $assessment): JsonResponse
{
$this->authorize($request);
$model = $this->assessment($assessment);
$this->assertDraft($model->course_version_id);
$ids = $request->validate(['questionIds' => ['required', 'array'], 'questionIds.*' => ['string']])['questionIds'];
$current = $model->questions()->pluck('id')->map(fn ($id): string => (string) $id)->all();
if (count($ids) !== count($current) || array_diff($ids, $current) || array_diff($current, $ids)) {
throw ValidationException::withMessages(['questionIds' => ['فهرست کامل سؤال‌ها الزامی است.']]);
}
DB::transaction(fn () => collect($ids)->each(fn (string $id, int $index) => Question::query()->whereKey($id)->update(['position' => 10000 + $index])));
DB::transaction(fn () => collect($ids)->each(fn (string $id, int $index) => Question::query()->whereKey($id)->update(['position' => $index + 1])));
return response()->json(['data' => $model->fresh('questions')->questions->map(fn (Question $item) => $this->questionPayload($item))]);
}
/** @return array<string, mixed> */
private function assessmentData(Request $request): array
{
return $request->validate([
'courseVersionId' => ['required', 'string'], 'lessonId' => ['nullable', 'string'], 'title' => ['required', 'string', 'max:240'],
'settings' => ['required', 'array'], ...$this->settingRules('settings.'),
]);
}
/** @return array<string, array<int, string>> */
private function settingRules(string $prefix): array
{
return [
$prefix.'randomSelection' => ['required', 'boolean'], $prefix.'questionPoolSize' => ['nullable', 'integer', 'min:1', 'max:500'],
$prefix.'shuffleQuestions' => ['required', 'boolean'], $prefix.'shuffleOptions' => ['required', 'boolean'],
$prefix.'passingScore' => ['required', 'integer', 'between:0,100'], $prefix.'attemptLimit' => ['nullable', 'integer', 'min:1', 'max:100'],
$prefix.'feedbackMode' => ['required', Rule::in(['immediate', 'after_submission', 'none'])],
$prefix.'timeLimitSeconds' => ['nullable', 'integer', 'min:30', 'max:86400'],
];
}
private function draftVersion(string $id): CourseVersion
{
$version = CourseVersion::query()->where('organization_id', $this->tenant->id())->findOrFail($id);
$this->assertDraft($version->getKey());
return $version;
}
private function assertDraft(string $versionId): void
{
$status = CourseVersion::query()->where('organization_id', $this->tenant->id())->whereKey($versionId)->value('status');
$statusValue = $status instanceof CourseVersionStatus ? $status->value : $status;
if ($statusValue !== CourseVersionStatus::Draft->value) {
throw ValidationException::withMessages(['courseVersionId' => ['Published course version assessments are immutable.']]);
}
}
private function assessment(string $id): Assessment
{
return Assessment::query()->where('organization_id', $this->tenant->id())->findOrFail($id);
}
private function question(string $id): Question
{
return Question::query()->where('organization_id', $this->tenant->id())->findOrFail($id);
}
private function authorize(Request $request): void
{
abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403);
}
/** @return array<string, mixed> */
private function assessmentPayload(Assessment $assessment): array
{
return [
'id' => $assessment->getKey(), 'courseVersionId' => $assessment->course_version_id, 'lessonId' => $assessment->lesson_id,
'title' => $assessment->title, 'settings' => $assessment->settings ?? [],
'questionCount' => $assessment->questions->count(), 'questions' => $assessment->questions->map(fn (Question $question) => $this->questionPayload($question))->values(),
'updatedAt' => $assessment->updated_at?->toISOString(),
];
}
/** @return array<string, mixed> */
private function questionPayload(Question $question): array
{
$usage = $question->is_bank_item ? Question::query()->where('source_question_id', $question->getKey())->count() : 0;
$performance = QuestionResult::query()->where('question_id', $question->getKey())->avg('normalized_value');
return [
'id' => $question->getKey(), 'courseVersionId' => $question->course_version_id, 'assessmentId' => $question->assessment_id,
'sourceQuestionId' => $question->source_question_id, 'isBankItem' => $question->is_bank_item,
'type' => $question->type, 'schemaVersion' => $question->schema_version, 'prompt' => $question->prompt,
'configuration' => $question->configuration, 'difficulty' => $question->difficulty, 'topic' => $question->topic,
'tags' => $question->tags ?? [], 'explanation' => $question->explanation, 'position' => $question->position,
'usageCount' => $usage, 'performance' => $performance === null ? null : round(((float) $performance) * 100, 1),
];
}
}