UserTests and Answers

This commit is contained in:
Stepan 2025-11-12 23:04:55 +01:00
parent 8e2d51660a
commit 4e30af1841
15 changed files with 1371 additions and 80 deletions

View File

@ -80,10 +80,6 @@ class QuestionController extends Controller
* } * }
* ) * )
* ) * )
* ),
* @OA\Response(
* response=401,
* description="Unauthorized"
* ) * )
* ) * )
*/ */
@ -117,14 +113,6 @@ class QuestionController extends Controller
* response=200, * response=200,
* description="Question retrieved successfully", * description="Question retrieved successfully",
* @OA\JsonContent(ref="#/components/schemas/QuestionResource") * @OA\JsonContent(ref="#/components/schemas/QuestionResource")
* ),
* @OA\Response(
* response=401,
* description="Unauthorized"
* ),
* @OA\Response(
* response=404,
* description="Question not found"
* ) * )
* ) * )
*/ */
@ -174,14 +162,6 @@ class QuestionController extends Controller
* response=200, * response=200,
* description="Question created successfully", * description="Question created successfully",
* @OA\JsonContent(ref="#/components/schemas/QuestionResource") * @OA\JsonContent(ref="#/components/schemas/QuestionResource")
* ),
* @OA\Response(
* response=401,
* description="Unauthorized"
* ),
* @OA\Response(
* response=422,
* description="Validation error"
* ) * )
* ) * )
*/ */
@ -245,14 +225,6 @@ class QuestionController extends Controller
* response=200, * response=200,
* description="Question updated successfully", * description="Question updated successfully",
* @OA\JsonContent(ref="#/components/schemas/QuestionResource") * @OA\JsonContent(ref="#/components/schemas/QuestionResource")
* ),
* @OA\Response(
* response=401,
* description="Unauthorized"
* ),
* @OA\Response(
* response=422,
* description="Validation error"
* ) * )
* ) * )
*/ */
@ -289,18 +261,6 @@ class QuestionController extends Controller
* @OA\JsonContent( * @OA\JsonContent(
* @OA\Property(property="message", type="string", example="The hitcount was deleted") * @OA\Property(property="message", type="string", example="The hitcount was deleted")
* ) * )
* ),
* @OA\Response(
* response=401,
* description="Unauthorized"
* ),
* @OA\Response(
* response=403,
* description="Forbidden"
* ),
* @OA\Response(
* response=404,
* description="Question not found"
* ) * )
* ) * )
*/ */
@ -310,6 +270,6 @@ class QuestionController extends Controller
$question->delete(); $question->delete();
Log::writeLog("Question '" . $question->title . "' is deleted by " . $request->user()->username); Log::writeLog("Question '" . $question->title . "' is deleted by " . $request->user()->username);
return ['message' => 'The hitcount was deleted']; return ['message' => 'The question was deleted'];
} }
} }

View File

@ -95,7 +95,7 @@ class TestController extends Controller
* required={"title","closed_at","questions"}, * required={"title","closed_at","questions"},
* @OA\Property(property="title", type="string", maxLength=255, example="Sample Test"), * @OA\Property(property="title", type="string", maxLength=255, example="Sample Test"),
* @OA\Property(property="description", type="string", nullable=true, example="Optional description"), * @OA\Property(property="description", type="string", nullable=true, example="Optional description"),
* @OA\Property(property="closed_at", type="string", format="date-time", nullable=true, example="2025-12-01T23:59:59Z"), * @OA\Property(property="closed_at", type="string", format="date-time", nullable=true, example="2025-12-01 23:59:59"),
* @OA\Property(property="category_id", type="integer", nullable=true, example=3), * @OA\Property(property="category_id", type="integer", nullable=true, example=3),
* @OA\Property( * @OA\Property(
* property="questions", * property="questions",

View File

@ -0,0 +1,394 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\UserTestAnswerResource;
use App\Http\Resources\UserTestResource;
use App\Models\Log;
use App\Models\Question;
use App\Models\Test;
use App\Models\UserTest;
use App\Models\UserTestAnswer;
use Illuminate\Http\Request;
class UserTestController extends Controller
{
/**
* @OA\Get(
* path="/api/user-tests",
* summary="Get list of user tests for the authenticated user",
* description="Retrieve all UserTests belonging to the authenticated user, with related category, test, and answers.",
* tags={"UserTests"},
* security={{"bearerAuth":{}}},
* @OA\Response(
* response=200,
* description="List of user tests",
* @OA\JsonContent(
* type="object",
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/UserTestResource")
* )
* )
* )
* )
*/
public function index(Request $request)
{
$user = $request->user();
$userTests = UserTest::where('user_id', $user->id)
->orderBy('id', 'desc')
->with(['user', 'category', 'answers.question'])
->get();
return UserTestResource::collection($userTests);
}
/**
* @OA\Post(
* path="/api/user-tests",
* summary="Create a new user test",
* description="Generate a new UserTest for the authenticated user with questions from a specific category and difficulty range.",
* tags={"UserTests"},
* security={{"bearerAuth":{}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"category_id"},
* @OA\Property(property="category_id", type="integer", example=3, description="Category from which to select questions"),
* @OA\Property(property="min_difficulty", type="integer", nullable=true, example=0, description="Minimum difficulty of questions"),
* @OA\Property(property="max_difficulty", type="integer", nullable=true, example=10, description="Maximum difficulty of questions")
* )
* ),
* @OA\Response(
* response=422,
* description="Validation error or no questions found"
* )
* )
*/
public function store(Request $request)
{
$this->authorize('create', UserTest::class);
$fields = $request->validate([
'category_id' => 'required|exists:categories,id',
'min_difficulty' => 'nullable|integer',
'max_difficulty' => 'nullable|integer',
]);
$minDifficulty = $fields['min_difficulty'] ?? 0;
$maxDifficulty = $fields['max_difficulty'] ?? 10;
$questions = Question::where('category_id', $fields['category_id'])
->whereBetween('difficulty', [$minDifficulty, $maxDifficulty])
->inRandomOrder()
->take(10)
->get();
if ($questions->isEmpty()) {
return response()->json(['message' => 'No questions found for this criteria'], 422);
}
$userTest = new UserTest();
$userTest->category_id = $fields['category_id'];
$userTest->user_id = $request->user()->id;
$userTest->closed_at = now()->addDay();
$userTest->save();
$userTest->generateEmptyAnswers($questions);
return new UserTestResource($userTest);
}
/**
* @OA\Post(
* path="/api/user-tests/by-test",
* summary="Create a UserTest based on an existing Test",
* description="Generates a UserTest from an existing Test, including all its questions. Only available if the Test is available.",
* tags={"UserTests"},
* security={{"bearerAuth":{}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"test_id"},
* @OA\Property(property="test_id", type="integer", example=1, description="ID of the existing Test to create a UserTest from")
* )
* ),
* @OA\Response(
* response=200,
* description="UserTest created successfully",
* @OA\JsonContent(ref="#/components/schemas/UserTestResource")
* ),
* @OA\Response(
* response=409,
* description="Test is not available at this time"
* )
* )
*/
public function storeByTest(Request $request)
{
$this->authorize('create', UserTest::class);
$fields = $request->validate([
'test_id' => 'required|exists:tests,id'
]);
$test = Test::findOrFail($fields['test_id']);
if(!$test->isAvailable())
return response(['message' => 'This test isn\'t available now!'], 409);
$test->load(['questions']);
$userTest = new UserTest();
$userTest->category_id = $test->category_id;
$userTest->test_id = $test->id;
$userTest->closed_at = $test->closed_at;
$userTest->user_id = $request->user()->id;
$userTest->save();
$userTest->generateEmptyAnswers($test->questions->toArray());
return new UserTestResource($userTest);
}
/**
* @OA\Post(
* path="/api/user-test-answers/{answer}/submit",
* summary="Submit an answer for a UserTestAnswer",
* description="Records the user's answer for a specific UserTestAnswer. The answer must match the question type (string for text, integer for single/multiple choice).",
* tags={"UserTestAnswers"},
* security={{"bearerAuth":{}}},
* @OA\Parameter(
* name="answer",
* in="path",
* required=true,
* description="ID of the UserTestAnswer to submit",
* @OA\Schema(type="integer")
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"answer"},
* @OA\Property(
* property="answer",
* type="array",
* description="Array of answers. Should be string(s) if the question type is 'text' or integer(s) otherwise",
* @OA\Items(
* oneOf={
* @OA\Schema(type="string"),
* @OA\Schema(type="integer")
* }
* )
* )
* )
* ),
* @OA\Response(
* response=200,
* description="Answer recorded successfully",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Answer is recorded")
* )
* ),
* @OA\Response(
* response=401,
* description="Unauthorized"
* ),
* @OA\Response(
* response=422,
* description="Validation error (wrong type or empty array)"
* ),
* @OA\Response(
* response=403,
* description="Forbidden (user cannot edit this answer)"
* )
* )
*/
public function answerByTest(Request $request, UserTestAnswer $answer)
{
$answer->load(['userTest', 'question']);
$answerType = $answer->question->type == 'text' ? 'string' : 'integer';
$this->authorize('canBeEdit', $answer->userTest);
$fields = $request->validate([
'answer' => 'required|array|min:1',
'answer.*' => 'required|' . $answerType
]);
$answer->answer = $fields['answer'];
$answer->save();
return ['message' => 'Answer is recorded'];
}
/**
* @OA\Get(
* path="/api/user-test-answers/{answer}",
* summary="Get a UserTestAnswer",
* description="Retrieve a specific UserTestAnswer by ID. Only the owner or authorized user can view it.",
* tags={"UserTestAnswers"},
* security={{"bearerAuth":{}}},
* @OA\Parameter(
* name="answer",
* in="path",
* required=true,
* description="ID of the UserTestAnswer to retrieve",
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="UserTestAnswer retrieved successfully",
* @OA\JsonContent(ref="#/components/schemas/UserTestAnswerResource")
* ),
* @OA\Response(
* response=401,
* description="Unauthorized"
* ),
* @OA\Response(
* response=403,
* description="Forbidden: user cannot view this answer"
* ),
* @OA\Response(
* response=404,
* description="UserTestAnswer not found"
* )
* )
*/
public function showAnswer(UserTestAnswer $answer)
{
$answer->load(['userTest', 'question']);
$this->authorize('view', $answer->userTest);
return new UserTestAnswerResource($answer);
}
/**
* @OA\Post(
* path="/api/user-tests/{userTest}/complete",
* summary="Complete a UserTest",
* description="Marks a UserTest as completed, calculates the score based on answers, and prevents further editing.",
* tags={"UserTests"},
* security={{"bearerAuth":{}}},
* @OA\Parameter(
* name="userTest",
* in="path",
* required=true,
* description="ID of the UserTest to complete",
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="Test successfully completed",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Test is completed")
* )
* ),
* @OA\Response(
* response=403,
* description="Forbidden: user cannot edit this test"
* )
* )
*/
public function completeTest(UserTest $userTest)
{
$this->authorize('canBeEdit', $userTest);
$userTest->load(['category', 'answers.question', 'user']);
$userTest->completeTest();
return ['message' => 'Test is completed'];
}
/**
* @OA\Get(
* path="/api/user-tests/{userTest}",
* summary="Get a single UserTest",
* description="Retrieve a UserTest with its answers, question details, category, and user info.",
* tags={"UserTests"},
* security={{"bearerAuth":{}}},
* @OA\Parameter(
* name="userTest",
* in="path",
* required=true,
* description="ID of the UserTest to retrieve",
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="UserTest details",
* @OA\JsonContent(ref="#/components/schemas/UserTestResource")
* ),
* @OA\Response(
* response=401,
* description="Unauthorized"
* ),
* @OA\Response(
* response=403,
* description="Forbidden: user cannot view this test"
* ),
* @OA\Response(
* response=404,
* description="UserTest not found"
* )
* )
*/
public function show(UserTest $userTest)
{
$this->authorize('view', $userTest);
$userTest->load(['user', 'category', 'answers.question']);
return new UserTestResource($userTest);
}
/**
* @OA\Delete(
* path="/api/user-tests/{userTest}",
* summary="Delete a UserTest",
* description="Delete a UserTest by ID. Only the owner or admin can delete it.",
* tags={"UserTests"},
* security={{"bearerAuth":{}}},
* @OA\Parameter(
* name="userTest",
* in="path",
* required=true,
* description="ID of the UserTest to delete",
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="UserTest deleted successfully",
* @OA\JsonContent(
* type="object",
* @OA\Property(property="message", type="string", example="The UserTest was deleted")
* )
* ),
* @OA\Response(
* response=401,
* description="Unauthorized"
* ),
* @OA\Response(
* response=403,
* description="Forbidden: user cannot delete this test"
* ),
* @OA\Response(
* response=404,
* description="UserTest not found"
* )
* )
*/
public function destroy(Request $request, UserTest $userTest)
{
$this->authorize('delete', $userTest);
$userTest->load('user');
$username = $userTest->user ? $userTest->user->username : 'unknown';
$userTestId = $userTest->id;
$userTest->delete();
Log::writeLog("UserTest #$userTestId ($username)' is deleted by " . $request->user()->username);
return ['message' => 'The UserTest was deleted'];
}
}

View File

@ -33,7 +33,6 @@ class QuestionResource extends JsonResource
* type="array", * type="array",
* @OA\Items(type="integer", example=1) * @OA\Items(type="integer", example=1)
* ), * ),
* @OA\Property(property="is_pending_question", type="integer", example=0),
* @OA\Property(property="category_id", type="integer", nullable=true, example=3), * @OA\Property(property="category_id", type="integer", nullable=true, example=3),
* @OA\Property(property="category", ref="#/components/schemas/CategoryResource"), * @OA\Property(property="category", ref="#/components/schemas/CategoryResource"),
* @OA\Property(property="author_id", type="integer", nullable=true, example=2), * @OA\Property(property="author_id", type="integer", nullable=true, example=2),
@ -52,7 +51,6 @@ class QuestionResource extends JsonResource
'difficulty' => $this->difficulty, 'difficulty' => $this->difficulty,
'variants' => $this->variants, 'variants' => $this->variants,
'correct_answers' => $this->correct_answers, 'correct_answers' => $this->correct_answers,
'is_pending_question' => $this->is_pending_question,
'category_id' => $this->category_id, 'category_id' => $this->category_id,
'category' => new CategoryResource($this->whenLoaded('category')), 'category' => new CategoryResource($this->whenLoaded('category')),

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserTestAnswerResource extends JsonResource
{
/**
* @OA\Schema(
* schema="UserTestAnswerResource",
* type="object",
* title="UserTestAnswer",
* properties={
* @OA\Property(property="id", type="integer", example=1),
*
* @OA\Property(property="user_test_id", type="integer", example=5),
*
* @OA\Property(property="question_id", type="integer", example=12),
* @OA\Property(property="question", ref="#/components/schemas/QuestionResource"),
*
* @OA\Property(property="answer", type="array", @OA\Items(type="string"), example={"42","43"}),
*
* @OA\Property(property="user_id", type="integer", example=10),
* @OA\Property(property="user", ref="#/components/schemas/UserResource"),
*
* @OA\Property(property="is_correct", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-11-11T20:39:22Z"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-11-11T20:39:22Z")
* }
* )
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'user_test_id' => $this->user_test_id,
'question_id' => $this->question_id,
'question' => new QuestionResource($this->whenLoaded('question')),
'answer' => $this->answer,
'user_id' => $this->user_id,
'user' => new UserResource($this->whenLoaded('user')),
'is_correct' => $this->isCorrect(),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserTestResource extends JsonResource
{
/**
* @OA\Schema(
* schema="UserTestResource",
* type="object",
* title="UserTest",
* properties={
* @OA\Property(property="id", type="integer", example=1),
*
* @OA\Property(property="test_id", type="integer", example=5),
* @OA\Property(property="test", ref="#/components/schemas/TestResource"),
*
* @OA\Property(property="user_id", type="integer", example=10),
* @OA\Property(property="user", ref="#/components/schemas/UserResource"),
*
* @OA\Property(property="closed_at", type="string", format="date-time", example="2025-12-01T23:59:59Z"),
* @OA\Property(property="is_completed", type="boolean", example=false),
* @OA\Property(property="score", type="integer", example=85),
* @OA\Property(property="is_available", type="boolean", example=true),
*
* @OA\Property(
* property="answers",
* type="array",
* @OA\Items(ref="#/components/schemas/UserTestAnswerResource")
* ),
*
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-11-11T20:39:22Z"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-11-11T20:39:22Z")
* }
* )
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'test_id' => $this->test_id,
'test' => new TestResource($this->whenLoaded('test')),
'user_id' => $this->user_id,
'user' => new UserResource($this->whenLoaded('user')),
'closed_at' => $this->closed_at,
'is_completed' => $this->is_completed,
'score' => $this->score,
'is_available' => $this->isAvailable(),
'answers' => UserTestAnswerResource::collection($this->whenLoaded('answers')),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@ -24,6 +24,11 @@ class Test extends Model
'updated_at', 'updated_at',
]; ];
public function isAvailable()
{
return $this->closed_at === null || now()->lt($this->closed_at);
}
public function category() public function category()
{ {
return $this->belongsTo(Category::class); return $this->belongsTo(Category::class);

92
app/Models/UserTest.php Normal file
View File

@ -0,0 +1,92 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UserTest extends Model
{
use HasFactory;
protected $fillable = [
'category_id',
'test_id',
'closed_at',
'is_completed',
'score',
];
protected $casts = [
'closed_at' => 'datetime',
'is_completed' => 'boolean',
];
/**
* Generating empty answers to fill later
* @param Collection<Question>|array<Question> $questions
* @return void
*/
public function generateEmptyAnswers(Collection|array $questions)
{
$answers = [];
foreach ($questions as $question) {
$questionId = is_array($question) ? $question['id'] : $question->id;
$answers[] = [
'question_id' => $questionId,
'user_test_id' => $this->id,
'user_id' => $this->user_id,
'created_at' => now(),
'updated_at' => now(),
];
}
UserTestAnswer::insert($answers);
}
public function completeTest()
{
$correctAnswers = 0;
$allAnswers = $this->answers->count();
foreach ($this->answers as $answer) {
if($answer->isCorrect())
$correctAnswers++;
}
$this->score = intval($correctAnswers * 100 / $allAnswers);
$this->is_completed = true;
$this->save();
}
/**
* Check cooldown for current test
* @return bool
*/
public function isAvailable()
{
return (!$this->is_completed) &&
($this->closed_at === null || now()->lt($this->closed_at));
}
public function category()
{
return $this->belongsTo(Category::class);
}
public function test()
{
return $this->belongsTo(Test::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function answers()
{
return $this->hasMany(UserTestAnswer::class);
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UserTestAnswer extends Model
{
use HasFactory;
protected $fillable = [
'question_id',
'answer',
'user_test_id',
'user_id',
];
protected $casts = [
'answer' => 'array',
];
public function isCorrect() {
if(empty($this->answer))
return false;
if($this->question->type == 'text') {
$correctAnswer = trim(strtolower($this->question->correct_answers[0]));
$answer = trim(strtolower($this->answer[0]));
return $correctAnswer == $answer;
}
if($this->question->type == 'single') {
$correctAnswer = intval($this->question->correct_answers[0]);
$answer = intval($this->answer[0]);
return $correctAnswer == $answer;
}
if($this->question->type == 'multiple') {
$correctAnswers = collect($this->question->correct_answers)->map(fn($a) => (int)$a)->sort()->values()->all();
$answers = collect($this->answer)->map(fn($a) => (int)$a)->sort()->values()->all();
return $correctAnswers === $answers;
}
return false;
}
public function question()
{
return $this->belongsTo(Question::class);
}
public function userTest()
{
return $this->belongsTo(UserTest::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\UserTest;
use Illuminate\Auth\Access\Response;
class UserTestPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, UserTest $userTest): bool
{
return $user->id == $userTest->user_id || $user->type == 'creator' || $user->type == 'admin';
}
public function canBeEdit(User $user, UserTest $userTest): bool
{
return ($user->id == $userTest->user_id && $userTest->isAvailable()) || $user->type == 'creator' || $user->type == 'admin';
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->type != 'banned';
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, UserTest $userTest): bool
{
return $user->type == 'admin';
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, UserTest $userTest): bool
{
return $user->type == 'admin';
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, UserTest $userTest): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, UserTest $userTest): bool
{
return false;
}
}

View File

@ -20,7 +20,6 @@ return new class extends Migration
$table->unsignedBigInteger('category_id')->nullable(); $table->unsignedBigInteger('category_id')->nullable();
$table->unsignedBigInteger('author_id')->nullable(); $table->unsignedBigInteger('author_id')->nullable();
$table->text('variants'); $table->text('variants');
$table->tinyInteger('is_pending_question')->unsigned()->default(0);
$table->text('correct_answers')->default('[]'); $table->text('correct_answers')->default('[]');
$table->foreign('author_id')->references('id')->on('users')->onDelete('set null'); $table->foreign('author_id')->references('id')->on('users')->onDelete('set null');

View File

@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_tests', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('category_id')->nullable();
$table->unsignedBigInteger('test_id')->nullable();
$table->unsignedBigInteger('user_id');
$table->dateTime('closed_at')->nullable();
$table->boolean('is_completed')->default(false);
$table->integer('score')->default(0);
$table->foreign('category_id')
->references('id')
->on('categories')
->onDelete('set null');
$table->foreign('test_id')
->references('id')
->on('tests')
->onDelete('set null');
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_tests');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_test_answers', function (Blueprint $table) {
$table->id();
$table->foreignId('question_id')->constrained()->cascadeOnDelete();
$table->text('answer')->nullable();
$table->foreignId('user_test_id')->constrained('user_tests')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_test_answers');
}
};

View File

@ -7,8 +7,27 @@ use App\Http\Controllers\LogController;
use App\Http\Controllers\QuestionController; use App\Http\Controllers\QuestionController;
use App\Http\Controllers\TestController; use App\Http\Controllers\TestController;
use App\Http\Controllers\UserController; use App\Http\Controllers\UserController;
use App\Http\Controllers\UserTestController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
// UserTests
Route::middleware('auth:sanctum')->group(function () {
Route::get('user-tests', [UserTestController::class, 'index']);
Route::post('user-tests', [UserTestController::class, 'store']);
Route::post('user-tests/by-test', [UserTestController::class, 'storeByTest']);
Route::get('user-tests/{userTest}', [UserTestController::class, 'show']);
Route::post('user-tests/{userTest}/complete', [UserTestController::class, 'completeTest']);
Route::delete('user-tests/{userTest}', [UserTestController::class, 'destroy']);
});
// UserTestAnswers
Route::middleware('auth:sanctum')->group(function () {
Route::get('user-test-answers/{answer}', [UserTestController::class, 'showAnswer']);
Route::post('user-test-answers/{answer}/submit', [UserTestController::class, 'answerByTest']);
});
// Tests // Tests
Route::get('tests', [TestController::class, 'index']); Route::get('tests', [TestController::class, 'index']);
Route::get('tests/{test}', [TestController::class, 'show']); Route::get('tests/{test}', [TestController::class, 'show']);

View File

@ -1178,9 +1178,6 @@
} }
} }
} }
},
"401": {
"description": "Unauthorized"
} }
} }
}, },
@ -1275,12 +1272,6 @@
} }
} }
} }
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Validation error"
} }
}, },
"security": [ "security": [
@ -1319,12 +1310,6 @@
} }
} }
} }
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Question not found"
} }
} }
}, },
@ -1430,12 +1415,6 @@
} }
} }
} }
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Validation error"
} }
}, },
"security": [ "security": [
@ -1478,15 +1457,6 @@
} }
} }
} }
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Question not found"
} }
}, },
"security": [ "security": [
@ -1626,7 +1596,7 @@
"closed_at": { "closed_at": {
"type": "string", "type": "string",
"format": "date-time", "format": "date-time",
"example": "2025-12-01T23:59:59Z", "example": "2025-12-01 23:59:59",
"nullable": true "nullable": true
}, },
"category_id": { "category_id": {
@ -2187,6 +2157,415 @@
} }
] ]
} }
},
"/api/user-tests": {
"get": {
"tags": [
"UserTests"
],
"summary": "Get list of user tests for the authenticated user",
"description": "Retrieve all UserTests belonging to the authenticated user, with related category, test, and answers.",
"operationId": "bf312e7c74799e8dbaa29025b8f52f46",
"responses": {
"200": {
"description": "List of user tests",
"content": {
"application/json": {
"schema": {
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserTestResource"
}
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"UserTests"
],
"summary": "Create a new user test",
"description": "Generate a new UserTest for the authenticated user with questions from a specific category and difficulty range.",
"operationId": "dab4526b31f8a1bf1abf3048c3a4ecbe",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"required": [
"category_id"
],
"properties": {
"category_id": {
"description": "Category from which to select questions",
"type": "integer",
"example": 3
},
"min_difficulty": {
"description": "Minimum difficulty of questions",
"type": "integer",
"example": 0,
"nullable": true
},
"max_difficulty": {
"description": "Maximum difficulty of questions",
"type": "integer",
"example": 10,
"nullable": true
}
},
"type": "object"
}
}
}
},
"responses": {
"422": {
"description": "Validation error or no questions found"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/api/user-tests/by-test": {
"post": {
"tags": [
"UserTests"
],
"summary": "Create a UserTest based on an existing Test",
"description": "Generates a UserTest from an existing Test, including all its questions. Only available if the Test is available.",
"operationId": "81b613bf7df7621756ee71dc88027236",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"required": [
"test_id"
],
"properties": {
"test_id": {
"description": "ID of the existing Test to create a UserTest from",
"type": "integer",
"example": 1
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "UserTest created successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserTestResource"
}
}
}
},
"409": {
"description": "Test is not available at this time"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/api/user-test-answers/{answer}/submit": {
"post": {
"tags": [
"UserTestAnswers"
],
"summary": "Submit an answer for a UserTestAnswer",
"description": "Records the user's answer for a specific UserTestAnswer. The answer must match the question type (string for text, integer for single/multiple choice).",
"operationId": "4a7d5822bbbe5fe72fed7aca775ab792",
"parameters": [
{
"name": "answer",
"in": "path",
"description": "ID of the UserTestAnswer to submit",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"required": [
"answer"
],
"properties": {
"answer": {
"description": "Array of answers. Should be string(s) if the question type is 'text' or integer(s) otherwise",
"type": "array",
"items": {
"oneOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
}
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Answer recorded successfully",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Answer is recorded"
}
},
"type": "object"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Validation error (wrong type or empty array)"
},
"403": {
"description": "Forbidden (user cannot edit this answer)"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/api/user-test-answers/{answer}": {
"get": {
"tags": [
"UserTestAnswers"
],
"summary": "Get a UserTestAnswer",
"description": "Retrieve a specific UserTestAnswer by ID. Only the owner or authorized user can view it.",
"operationId": "ea81ce3e7c5383b8fabeef5338bb1db9",
"parameters": [
{
"name": "answer",
"in": "path",
"description": "ID of the UserTestAnswer to retrieve",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "UserTestAnswer retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserTestAnswerResource"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden: user cannot view this answer"
},
"404": {
"description": "UserTestAnswer not found"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/api/user-tests/{userTest}/complete": {
"post": {
"tags": [
"UserTests"
],
"summary": "Complete a UserTest",
"description": "Marks a UserTest as completed, calculates the score based on answers, and prevents further editing.",
"operationId": "d9265537e9cfb510ba68eede4930ea09",
"parameters": [
{
"name": "userTest",
"in": "path",
"description": "ID of the UserTest to complete",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Test successfully completed",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Test is completed"
}
},
"type": "object"
}
}
}
},
"403": {
"description": "Forbidden: user cannot edit this test"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/api/user-tests/{userTest}": {
"get": {
"tags": [
"UserTests"
],
"summary": "Get a single UserTest",
"description": "Retrieve a UserTest with its answers, question details, category, and user info.",
"operationId": "0de523e8221fc7a1b13fb6dbfd40e3f3",
"parameters": [
{
"name": "userTest",
"in": "path",
"description": "ID of the UserTest to retrieve",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "UserTest details",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserTestResource"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden: user cannot view this test"
},
"404": {
"description": "UserTest not found"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"delete": {
"tags": [
"UserTests"
],
"summary": "Delete a UserTest",
"description": "Delete a UserTest by ID. Only the owner or admin can delete it.",
"operationId": "a5e4b2f9860f4428f72bd99b5eb9688d",
"parameters": [
{
"name": "userTest",
"in": "path",
"description": "ID of the UserTest to delete",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "UserTest deleted successfully",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "The UserTest was deleted"
}
},
"type": "object"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden: user cannot delete this test"
},
"404": {
"description": "UserTest not found"
}
},
"security": [
{
"bearerAuth": []
}
]
}
} }
}, },
"components": { "components": {
@ -2326,10 +2705,6 @@
"example": 1 "example": 1
} }
}, },
"is_pending_question": {
"type": "integer",
"example": 0
},
"category_id": { "category_id": {
"type": "integer", "type": "integer",
"example": 3, "example": 3,
@ -2449,6 +2824,115 @@
} }
}, },
"type": "object" "type": "object"
},
"UserTestAnswerResource": {
"title": "UserTestAnswer",
"properties": {
"id": {
"type": "integer",
"example": 1
},
"user_test_id": {
"type": "integer",
"example": 5
},
"question_id": {
"type": "integer",
"example": 12
},
"question": {
"$ref": "#/components/schemas/QuestionResource"
},
"answer": {
"type": "array",
"items": {
"type": "string"
},
"example": [
"42",
"43"
]
},
"user_id": {
"type": "integer",
"example": 10
},
"user": {
"$ref": "#/components/schemas/UserResource"
},
"is_correct": {
"type": "boolean",
"example": true
},
"created_at": {
"type": "string",
"format": "date-time",
"example": "2025-11-11T20:39:22Z"
},
"updated_at": {
"type": "string",
"format": "date-time",
"example": "2025-11-11T20:39:22Z"
}
},
"type": "object"
},
"UserTestResource": {
"title": "UserTest",
"properties": {
"id": {
"type": "integer",
"example": 1
},
"test_id": {
"type": "integer",
"example": 5
},
"test": {
"$ref": "#/components/schemas/TestResource"
},
"user_id": {
"type": "integer",
"example": 10
},
"user": {
"$ref": "#/components/schemas/UserResource"
},
"closed_at": {
"type": "string",
"format": "date-time",
"example": "2025-12-01T23:59:59Z"
},
"is_completed": {
"type": "boolean",
"example": false
},
"score": {
"type": "integer",
"example": 85
},
"is_available": {
"type": "boolean",
"example": true
},
"answers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserTestAnswerResource"
}
},
"created_at": {
"type": "string",
"format": "date-time",
"example": "2025-11-11T20:39:22Z"
},
"updated_at": {
"type": "string",
"format": "date-time",
"example": "2025-11-11T20:39:22Z"
}
},
"type": "object"
} }
}, },
"securitySchemes": { "securitySchemes": {
@ -2487,6 +2971,14 @@
{ {
"name": "Users", "name": "Users",
"description": "Users" "description": "Users"
},
{
"name": "UserTests",
"description": "UserTests"
},
{
"name": "UserTestAnswers",
"description": "UserTestAnswers"
} }
] ]
} }