This commit is contained in:
Stepan 2025-11-11 21:45:51 +01:00
parent 590a346781
commit 8e2d51660a
8 changed files with 897 additions and 0 deletions

View File

@ -8,6 +8,10 @@ HoshiAI-be — the backend part of the HoshiAI project for the Web Programming c
* [L5-Swagger](https://github.com/DarkaOnLine/L5-Swagger)
* [mobiledetect/mobiledetectlib](https://packagist.org/packages/mobiledetect/mobiledetectlib)
## External API
* [ip-api.com](http://ip-api.com/)
* [openai.com](https://openai.com/)
## Installation
1. Install php and composer

View File

@ -0,0 +1,267 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\TestResource;
use App\Models\Log;
use App\Models\Test;
use Illuminate\Http\Request;
class TestController extends Controller
{
const PAGINATION_COUNT = 20;
private const FIELD_RULES = [
'title' => 'required|string|max:255',
'description' => 'required|string',
'category_id' => 'nullable|exists:categories,id',
'questions' => 'required|array|min:1',
'questions.*' => 'exists:questions,id',
'closed_at' => 'nullable|date',
];
/**
* @OA\Get(
* path="/api/tests",
* summary="Get a list of tests (paginated)",
* description="Retrieve a paginated list of tests. Optionally filter by category_id.",
* tags={"Tests"},
* @OA\Parameter(
* name="category_id",
* in="query",
* required=false,
* description="Filter tests by category ID",
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="Paginated list of tests",
* @OA\JsonContent(
* type="object",
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/TestResource")
* ),
* @OA\Property(
* property="links",
* type="object",
* @OA\Property(property="first", type="string", example="http://api.example.com/tests?page=1"),
* @OA\Property(property="last", type="string", example="http://api.example.com/tests?page=10"),
* @OA\Property(property="prev", type="string", nullable=true, example=null),
* @OA\Property(property="next", type="string", nullable=true, example="http://api.example.com/tests?page=2")
* ),
* @OA\Property(
* property="meta",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="from", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=10),
* @OA\Property(property="path", type="string", example="http://api.example.com/tests"),
* @OA\Property(property="per_page", type="integer", example=15),
* @OA\Property(property="to", type="integer", example=15),
* @OA\Property(property="total", type="integer", example=150)
* )
* )
* ),
* @OA\Response(
* response=401,
* description="Unauthorized"
* )
* )
*/
public function index(Request $request)
{
$query = Test::with(['author', 'category']);
if ($request->has('category_id')) {
$query->where('category_id', $request->query('category_id'));
}
$questions = $query->paginate(self::PAGINATION_COUNT);
return TestResource::collection($questions);
}
/**
* @OA\Post(
* path="/api/tests",
* summary="Create a new test (only admin or creator)",
* description="Store a new test in the system (only admin or creator).",
* tags={"Tests"},
* security={{"bearerAuth":{}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"title","closed_at","questions"},
* @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="closed_at", type="string", format="date-time", nullable=true, example="2025-12-01T23:59:59Z"),
* @OA\Property(property="category_id", type="integer", nullable=true, example=3),
* @OA\Property(
* property="questions",
* type="array",
* description="Array of question IDs to attach to this test",
* @OA\Items(type="integer", example=1)
* )
* )
* ),
* @OA\Response(
* response=200,
* description="Test created successfully",
* @OA\JsonContent(ref="#/components/schemas/TestResource")
* ),
* @OA\Response(
* response=401,
* description="Unauthorized"
* ),
* @OA\Response(
* response=422,
* description="Validation error"
* )
* )
*/
public function store(Request $request)
{
$this->authorize('create', Test::class);
$fields = $request->validate(self::FIELD_RULES);
$fields['author_id'] = $request->user()->id;
$test = Test::create($fields);
$test->questions()->sync($fields['questions']);
$test->load(['category', 'author', 'questions']);
Log::writeLog("Test '" . $test->title . "' is created by " . $request->user()->username);
return new TestResource($test);
}
/**
* @OA\Get(
* path="/api/tests/{id}",
* summary="Get a single test",
* description="Retrieve a single test with its questions, category, and author",
* tags={"Tests"},
* @OA\Parameter(
* name="id",
* in="path",
* required=true,
* description="Test ID",
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="Test retrieved successfully",
* @OA\JsonContent(ref="#/components/schemas/TestResource")
* ),
* @OA\Response(
* response=404,
* description="Test not found"
* )
* )
*/
public function show(Test $test)
{
$test->load(['author', 'category', 'questions']);
return new TestResource($test);
}
/**
* @OA\Put(
* path="/api/tests/{id}",
* summary="Update a test (only admin or creator)",
* description="Update a test's data and associated questions (only admin/creator).",
* tags={"Tests"},
* security={{"bearerAuth":{}}},
* @OA\Parameter(
* name="id",
* in="path",
* required=true,
* description="Test ID",
* @OA\Schema(type="integer")
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"title","questions"},
* @OA\Property(property="title", type="string", maxLength=255, example="Updated Test Title"),
* @OA\Property(property="description", type="string", nullable=true, example="Optional description"),
* @OA\Property(property="category_id", type="integer", nullable=true, example=3),
* @OA\Property(property="closed_at", type="string", format="date-time", nullable=true, example="2025-12-01T23:59:59Z"),
* @OA\Property(
* property="questions",
* type="array",
* description="Array of question IDs to attach to this test",
* @OA\Items(type="integer", example=1)
* )
* )
* ),
* @OA\Response(
* response=200,
* description="Test updated successfully",
* @OA\JsonContent(ref="#/components/schemas/TestResource")
* ),
* @OA\Response(
* response=401,
* description="Unauthorized"
* ),
* @OA\Response(
* response=422,
* description="Validation error"
* )
* )
*/
public function update(Request $request, Test $test)
{
$this->authorize('update', $test);
$fields = $request->validate(self::FIELD_RULES);
$test->update($fields);
$test->questions()->sync($fields['questions']);
Log::writeLog("Test '" . $test->title . "' is updated by " . $request->user()->username);
$test->load(['category', 'author', 'questions']);
return new TestResource($test);
}
/**
* @OA\Delete(
* path="/api/tests/{id}",
* summary="Delete a test (only admin or creator)",
* description="Delete a test by ID (only admin or creator).",
* tags={"Tests"},
* security={{"bearerAuth":{}}},
* @OA\Parameter(
* name="id",
* in="path",
* required=true,
* description="Test ID",
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="Test deleted successfully",
* @OA\JsonContent(
* type="object",
* @OA\Property(property="message", type="string", example="The test was deleted")
* )
* ),
* @OA\Response(
* response=401,
* description="Unauthorized"
* ),
* @OA\Response(
* response=404,
* description="Test not found"
* )
* )
*/
public function destroy(Request $request, Test $test)
{
$this->authorize('delete', $test);
$test->delete();
Log::writeLog("Test '" . $test->title . "' is deleted by " . $request->user()->username);
return ['message' => 'The hitcount was deleted'];
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TestResource extends JsonResource
{
/**
* @OA\Schema(
* schema="TestResource",
* type="object",
* title="Test",
* description="Test resource",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="title", type="string", example="Sample Test"),
* @OA\Property(property="description", type="string", nullable=true, example="Optional description"),
*
* @OA\Property(property="category_id", type="integer", nullable=true, example=3),
* @OA\Property(property="category", ref="#/components/schemas/CategoryResource"),
*
* @OA\Property(
* property="questions",
* type="array",
* @OA\Items(ref="#/components/schemas/QuestionResource")
* ),
*
* @OA\Property(property="author_id", type="integer", nullable=true, example=2),
* @OA\Property(property="author", ref="#/components/schemas/UserResource"),
*
* @OA\Property(property="closed_at", type="string", format="date-time", nullable=true, example="2025-11-11T10:30:00Z"),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-11-11T10:00:00Z"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-11-11T10:15:00Z")
* )
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'category_id' => $this->category_id,
'category' => new CategoryResource($this->whenLoaded('category')),
'questions' => QuestionResource::collection($this->whenLoaded('questions')),
'author_id' => $this->author_id,
'author' => new UserResource($this->whenLoaded('author')),
'closed_at' => $this->closed_at,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

41
app/Models/Test.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Test extends Model
{
use HasFactory;
protected $fillable = [
'title',
'description',
'category_id',
'author_id',
'closed_at',
];
protected $dates = [
'closed_at',
'created_at',
'updated_at',
];
public function category()
{
return $this->belongsTo(Category::class);
}
public function author()
{
return $this->belongsTo(User::class);
}
public function questions()
{
return $this->belongsToMany(Question::class)
->withTimestamps();
}
}

View File

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

View File

@ -0,0 +1,42 @@
<?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('tests', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('description');
$table->timestamp('closed_at')->nullable();
$table->unsignedBigInteger('category_id')->nullable();
$table->unsignedBigInteger('author_id')->nullable();
$table->foreign('author_id')->references('id')->on('users')->onDelete('set null');
$table->foreign('category_id')->references('id')->on('categories')->onDelete('set null');
$table->timestamps();
});
Schema::create('question_test', function (Blueprint $table) {
$table->foreignId('test_id')->constrained()->cascadeOnDelete();
$table->foreignId('question_id')->constrained()->cascadeOnDelete();
$table->primary(['test_id', 'question_id']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tests');
}
};

View File

@ -5,9 +5,19 @@ use App\Http\Controllers\CategoryController;
use App\Http\Controllers\HitcountController;
use App\Http\Controllers\LogController;
use App\Http\Controllers\QuestionController;
use App\Http\Controllers\TestController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;
// Tests
Route::get('tests', [TestController::class, 'index']);
Route::get('tests/{test}', [TestController::class, 'show']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('tests', [TestController::class, 'store']);
Route::put('tests/{test}', [TestController::class, 'update']);
Route::delete('tests/{test}', [TestController::class, 'destroy']);
});
// Questions
Route::get('questions', [QuestionController::class, 'index']);
Route::get('questions/{question}', [QuestionController::class, 'show']);

View File

@ -1496,6 +1496,354 @@
]
}
},
"/api/tests": {
"get": {
"tags": [
"Tests"
],
"summary": "Get a list of tests (paginated)",
"description": "Retrieve a paginated list of tests. Optionally filter by category_id.",
"operationId": "5f539f69bb1d910182eb35136c5baa3a",
"parameters": [
{
"name": "category_id",
"in": "query",
"description": "Filter tests by category ID",
"required": false,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Paginated list of tests",
"content": {
"application/json": {
"schema": {
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TestResource"
}
},
"links": {
"properties": {
"first": {
"type": "string",
"example": "http://api.example.com/tests?page=1"
},
"last": {
"type": "string",
"example": "http://api.example.com/tests?page=10"
},
"prev": {
"type": "string",
"example": null,
"nullable": true
},
"next": {
"type": "string",
"example": "http://api.example.com/tests?page=2",
"nullable": true
}
},
"type": "object"
},
"meta": {
"properties": {
"current_page": {
"type": "integer",
"example": 1
},
"from": {
"type": "integer",
"example": 1
},
"last_page": {
"type": "integer",
"example": 10
},
"path": {
"type": "string",
"example": "http://api.example.com/tests"
},
"per_page": {
"type": "integer",
"example": 15
},
"to": {
"type": "integer",
"example": 15
},
"total": {
"type": "integer",
"example": 150
}
},
"type": "object"
}
},
"type": "object"
}
}
}
},
"401": {
"description": "Unauthorized"
}
}
},
"post": {
"tags": [
"Tests"
],
"summary": "Create a new test (only admin or creator)",
"description": "Store a new test in the system (only admin or creator).",
"operationId": "7728a2f3dd87105d6d617df9a3f231d4",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"required": [
"title",
"closed_at",
"questions"
],
"properties": {
"title": {
"type": "string",
"maxLength": 255,
"example": "Sample Test"
},
"description": {
"type": "string",
"example": "Optional description",
"nullable": true
},
"closed_at": {
"type": "string",
"format": "date-time",
"example": "2025-12-01T23:59:59Z",
"nullable": true
},
"category_id": {
"type": "integer",
"example": 3,
"nullable": true
},
"questions": {
"description": "Array of question IDs to attach to this test",
"type": "array",
"items": {
"type": "integer",
"example": 1
}
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Test created successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResource"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Validation error"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/api/tests/{id}": {
"get": {
"tags": [
"Tests"
],
"summary": "Get a single test",
"description": "Retrieve a single test with its questions, category, and author",
"operationId": "7e3d8428f4df82c6d4ee7bd1d4d2128c",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Test ID",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Test retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResource"
}
}
}
},
"404": {
"description": "Test not found"
}
}
},
"put": {
"tags": [
"Tests"
],
"summary": "Update a test (only admin or creator)",
"description": "Update a test's data and associated questions (only admin/creator).",
"operationId": "ca1490751234c723e0a4a708afe16dd6",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Test ID",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"required": [
"title",
"questions"
],
"properties": {
"title": {
"type": "string",
"maxLength": 255,
"example": "Updated Test Title"
},
"description": {
"type": "string",
"example": "Optional description",
"nullable": true
},
"category_id": {
"type": "integer",
"example": 3,
"nullable": true
},
"closed_at": {
"type": "string",
"format": "date-time",
"example": "2025-12-01T23:59:59Z",
"nullable": true
},
"questions": {
"description": "Array of question IDs to attach to this test",
"type": "array",
"items": {
"type": "integer",
"example": 1
}
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Test updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResource"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Validation error"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"delete": {
"tags": [
"Tests"
],
"summary": "Delete a test (only admin or creator)",
"description": "Delete a test by ID (only admin or creator).",
"operationId": "92f76a68796679554c71a4659f62b296",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Test ID",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Test deleted successfully",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "The test was deleted"
}
},
"type": "object"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Test not found"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/api/users": {
"get": {
"tags": [
@ -2011,6 +2359,64 @@
},
"type": "object"
},
"TestResource": {
"title": "Test",
"description": "Test resource",
"properties": {
"id": {
"type": "integer",
"example": 1
},
"title": {
"type": "string",
"example": "Sample Test"
},
"description": {
"type": "string",
"example": "Optional description",
"nullable": true
},
"category_id": {
"type": "integer",
"example": 3,
"nullable": true
},
"category": {
"$ref": "#/components/schemas/CategoryResource"
},
"questions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionResource"
}
},
"author_id": {
"type": "integer",
"example": 2,
"nullable": true
},
"author": {
"$ref": "#/components/schemas/UserResource"
},
"closed_at": {
"type": "string",
"format": "date-time",
"example": "2025-11-11T10:30:00Z",
"nullable": true
},
"created_at": {
"type": "string",
"format": "date-time",
"example": "2025-11-11T10:00:00Z"
},
"updated_at": {
"type": "string",
"format": "date-time",
"example": "2025-11-11T10:15:00Z"
}
},
"type": "object"
},
"UserResource": {
"properties": {
"id": {
@ -2074,6 +2480,10 @@
"name": "Questions",
"description": "Questions"
},
{
"name": "Tests",
"description": "Tests"
},
{
"name": "Users",
"description": "Users"