Initial commit
This commit is contained in:
132
app/Http/Controllers/BandController.php
Normal file
132
app/Http/Controllers/BandController.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Band;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class BandController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth:sanctum')->except(['index', 'show']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam pásem (s stránkováním).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$bands = Band::query()
|
||||
->with(['ediBands', 'contests'])
|
||||
->orderBy('order')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($bands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoří nové pásmo.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Band::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$relations = $request->validate([
|
||||
'edi_band_ids' => ['sometimes', 'array'],
|
||||
'edi_band_ids.*' => ['integer', 'exists:edi_bands,id'],
|
||||
'contest_ids' => ['sometimes', 'array'],
|
||||
'contest_ids.*' => ['integer', 'exists:contests,id'],
|
||||
]);
|
||||
|
||||
$band = Band::create($data);
|
||||
|
||||
if (array_key_exists('edi_band_ids', $relations)) {
|
||||
$band->ediBands()->sync($relations['edi_band_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('contest_ids', $relations)) {
|
||||
$band->contests()->sync($relations['contest_ids']);
|
||||
}
|
||||
|
||||
return response()->json($band, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho pásma.
|
||||
*/
|
||||
public function show(Band $band): JsonResponse
|
||||
{
|
||||
$band->load(['ediBands', 'contests']);
|
||||
|
||||
return response()->json($band);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace existujícího pásma.
|
||||
*/
|
||||
public function update(Request $request, Band $band): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $band);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$relations = $request->validate([
|
||||
'edi_band_ids' => ['sometimes', 'array'],
|
||||
'edi_band_ids.*' => ['integer', 'exists:edi_bands,id'],
|
||||
'contest_ids' => ['sometimes', 'array'],
|
||||
'contest_ids.*' => ['integer', 'exists:contests,id'],
|
||||
]);
|
||||
|
||||
$band->fill($data);
|
||||
$band->save();
|
||||
|
||||
if (array_key_exists('edi_band_ids', $relations)) {
|
||||
$band->ediBands()->sync($relations['edi_band_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('contest_ids', $relations)) {
|
||||
$band->contests()->sync($relations['contest_ids']);
|
||||
}
|
||||
|
||||
return response()->json($band);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání pásma.
|
||||
*/
|
||||
public function destroy(Band $band): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $band);
|
||||
|
||||
$band->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Společná validace vstupu pro store/update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'name' => [$required, 'string', 'max:255'],
|
||||
'order' => [$required, 'integer'],
|
||||
'edi_band_begin' => [$required, 'integer'],
|
||||
'edi_band_end' => [$required, 'integer', 'gte:edi_band_begin'],
|
||||
'has_power_category' => [$required, 'boolean'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
138
app/Http/Controllers/CategoryController.php
Normal file
138
app/Http/Controllers/CategoryController.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class CategoryController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// store / update / destroy jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam kategorií (API, JSON).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$categories = Category::query()
|
||||
->with(['ediCategories', 'contests'])
|
||||
->orderBy('order')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nové kategorie.
|
||||
* Autorizace přes CategoryPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Category::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
$category = Category::create($data);
|
||||
|
||||
if (array_key_exists('edi_category_ids', $relations)) {
|
||||
$category->ediCategories()->sync($relations['edi_category_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('contest_ids', $relations)) {
|
||||
$category->contests()->sync($relations['contest_ids']);
|
||||
}
|
||||
|
||||
$category->load(['ediCategories', 'contests']);
|
||||
|
||||
return response()->json($category, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jedné kategorie.
|
||||
*/
|
||||
public function show(Category $category): JsonResponse
|
||||
{
|
||||
$category->load(['ediCategories', 'contests']);
|
||||
|
||||
return response()->json($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace existující kategorie (partial update).
|
||||
* Autorizace přes CategoryPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, Category $category): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $category);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
$category->fill($data);
|
||||
$category->save();
|
||||
|
||||
if (array_key_exists('edi_category_ids', $relations)) {
|
||||
$category->ediCategories()->sync($relations['edi_category_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('contest_ids', $relations)) {
|
||||
$category->contests()->sync($relations['contest_ids']);
|
||||
}
|
||||
|
||||
$category->load(['ediCategories', 'contests']);
|
||||
|
||||
return response()->json($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání kategorie.
|
||||
* Autorizace přes CategoryPolicy@delete.
|
||||
*/
|
||||
public function destroy(Category $category): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $category);
|
||||
|
||||
$category->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Společná validace dat pro store/update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'name' => [$required, 'string', 'max:255'],
|
||||
'order' => [$required, 'integer'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace ID relací (EDI kategorie a soutěže).
|
||||
*/
|
||||
protected function validateRelations(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'edi_category_ids' => ['sometimes', 'array'],
|
||||
'edi_category_ids.*' => ['integer', 'exists:edi_categories,id'],
|
||||
'contest_ids' => ['sometimes', 'array'],
|
||||
'contest_ids.*' => ['integer', 'exists:contests,id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
287
app/Http/Controllers/ContestController.php
Normal file
287
app/Http/Controllers/ContestController.php
Normal file
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Contest;
|
||||
use App\Models\EvaluationRuleSet;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class ContestController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápisové operace jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam soutěží (stránkovaný výstup).
|
||||
* Podporuje ?lang=cs / ?lang=en – name/description se vrací v daném jazyce.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
$onlyActive = (bool) $request->query('only_active', false);
|
||||
$includeTests = filter_var($request->query('include_tests', true), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if ($includeTests === null) {
|
||||
$includeTests = true;
|
||||
}
|
||||
|
||||
$lang = $request->query('lang');
|
||||
if (! is_string($lang) || $lang === '') {
|
||||
$lang = app()->getLocale();
|
||||
}
|
||||
|
||||
$items = Contest::query()
|
||||
->with([
|
||||
'rounds',
|
||||
'parameters',
|
||||
'bands',
|
||||
'categories',
|
||||
'powerCategories',
|
||||
'ruleSet',
|
||||
])
|
||||
->when($onlyActive, fn ($q) => $q->where('is_active', true))
|
||||
->when(! $includeTests, fn ($q) => $q->where('is_test', false))
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage);
|
||||
|
||||
// přemapování na konkrétní jazyk (stejný princip jako NewsPostController@index)
|
||||
$items->getCollection()->transform(function (Contest $contest) use ($lang) {
|
||||
$data = $contest->toArray();
|
||||
|
||||
$data['name'] = $contest->getTranslation('name', $lang, true);
|
||||
$data['description'] = $contest->getTranslation('description', $lang, true);
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Vytvoření nové soutěže.
|
||||
* Autorizace přes ContestPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Contest::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
if (! array_key_exists('rule_set_id', $data) || $data['rule_set_id'] === null) {
|
||||
$data['rule_set_id'] = $this->resolveDefaultRuleSetId();
|
||||
}
|
||||
|
||||
$contest = Contest::create($data);
|
||||
|
||||
$this->syncRelations($contest, $relations);
|
||||
|
||||
$contest->load([
|
||||
'rounds',
|
||||
'parameters',
|
||||
'bands',
|
||||
'categories',
|
||||
'powerCategories',
|
||||
'ruleSet',
|
||||
]);
|
||||
|
||||
return response()->json($contest, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail soutěže.
|
||||
* Můžeš volat i s ?lang=cs pro konkrétní jazyk.
|
||||
*/
|
||||
public function show(Request $request, Contest $contest): JsonResponse
|
||||
{
|
||||
$lang = $request->query('lang');
|
||||
if (! is_string($lang) || $lang === '') {
|
||||
$lang = app()->getLocale();
|
||||
}
|
||||
|
||||
$contest->load([
|
||||
'rounds',
|
||||
'parameters',
|
||||
'bands',
|
||||
'categories',
|
||||
'powerCategories',
|
||||
'ruleSet',
|
||||
]);
|
||||
|
||||
$data = $contest->toArray();
|
||||
|
||||
$data['name'] = $contest->getTranslation('name', $lang, true);
|
||||
$data['description'] = $contest->getTranslation('description', $lang, true);
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace soutěže (partial update).
|
||||
* Autorizace přes ContestPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, Contest $contest): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $contest);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
$contest->fill($data);
|
||||
$contest->save();
|
||||
|
||||
$this->syncRelations($contest, $relations);
|
||||
|
||||
$contest->load([
|
||||
'rounds',
|
||||
'parameters',
|
||||
'bands',
|
||||
'categories',
|
||||
'powerCategories',
|
||||
'ruleSet',
|
||||
]);
|
||||
|
||||
return response()->json($contest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání soutěže.
|
||||
* Autorizace přes ContestPolicy@delete.
|
||||
*/
|
||||
public function destroy(Contest $contest): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $contest);
|
||||
|
||||
$contest->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace dat pro store / update.
|
||||
* Stejný princip jako u NewsPost – string nebo array { locale: value }.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'name' => [
|
||||
$required,
|
||||
function (string $attribute, $value, \Closure $fail) {
|
||||
if (is_string($value)) {
|
||||
if (mb_strlen($value) > 255) {
|
||||
$fail('The '.$attribute.' may not be greater than 255 characters.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $locale => $text) {
|
||||
if (! is_string($text)) {
|
||||
$fail("The {$attribute}.{$locale} must be a string.");
|
||||
return;
|
||||
}
|
||||
if (mb_strlen($text) > 255) {
|
||||
$fail("The {$attribute}.{$locale} may not be greater than 255 characters.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$fail('The '.$attribute.' must be a string or an array of translated strings.');
|
||||
},
|
||||
],
|
||||
|
||||
'description' => [
|
||||
'sometimes',
|
||||
function (string $attribute, $value, \Closure $fail) {
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
// max length pokud chceš, nebo bez omezení
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $locale => $text) {
|
||||
if (! is_string($text)) {
|
||||
$fail("The {$attribute}.{$locale} must be a string.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$fail('The '.$attribute.' must be null, a string, or an array of translated strings.');
|
||||
},
|
||||
],
|
||||
|
||||
'url' => ['sometimes', 'nullable', 'email', 'max:255'],
|
||||
'evaluator' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'email' => ['sometimes', 'nullable', 'email', 'max:255'],
|
||||
'email2' => ['sometimes', 'nullable', 'email', 'max:255'],
|
||||
|
||||
'is_mcr' => ['sometimes', 'boolean'],
|
||||
'is_sixhr' => ['sometimes', 'boolean'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
|
||||
'start_time' => ['sometimes', 'date_format:H:i:s'],
|
||||
'duration' => ['sometimes', 'integer', 'min:1'],
|
||||
'logs_deadline_days' => ['sometimes', 'integer', 'min:0'],
|
||||
'rule_set_id' => ['sometimes', 'nullable', 'integer', 'exists:evaluation_rule_sets,id'],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function resolveDefaultRuleSetId(): ?int
|
||||
{
|
||||
return EvaluationRuleSet::where('code', 'default_vhf_compat')->value('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace ID navázaných entit (bands, categories, powerCategories).
|
||||
*/
|
||||
protected function validateRelations(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'band_ids' => ['sometimes', 'array'],
|
||||
'band_ids.*' => ['integer', 'exists:bands,id'],
|
||||
|
||||
'category_ids' => ['sometimes', 'array'],
|
||||
'category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
|
||||
'power_category_ids' => ['sometimes', 'array'],
|
||||
'power_category_ids.*' => ['integer', 'exists:power_categories,id'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync vazeb pro belongsToMany vztahy.
|
||||
*/
|
||||
protected function syncRelations(Contest $contest, array $relations): void
|
||||
{
|
||||
if (array_key_exists('band_ids', $relations)) {
|
||||
$contest->bands()->sync($relations['band_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('category_ids', $relations)) {
|
||||
$contest->categories()->sync($relations['category_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('power_category_ids', $relations)) {
|
||||
$contest->powerCategories()->sync($relations['power_category_ids']);
|
||||
}
|
||||
}
|
||||
}
|
||||
119
app/Http/Controllers/ContestParameterController.php
Normal file
119
app/Http/Controllers/ContestParameterController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ContestParameter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class ContestParameterController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápisové operace jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam contest parametrů (stránkovaně).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$items = ContestParameter::query()
|
||||
->with('contest')
|
||||
->orderBy('contest_id')
|
||||
->orderBy('log_type')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nových parametrů pro contest.
|
||||
* Autorizace přes ContestParameterPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', ContestParameter::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$item = ContestParameter::create($data);
|
||||
|
||||
$item->load('contest');
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail parametrů.
|
||||
*/
|
||||
public function show(ContestParameter $contest_parameter): JsonResponse
|
||||
{
|
||||
$contest_parameter->load('contest');
|
||||
|
||||
return response()->json($contest_parameter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace parametrů (partial update).
|
||||
* Autorizace přes ContestParameterPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, ContestParameter $contest_parameter): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $contest_parameter);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$contest_parameter->fill($data);
|
||||
$contest_parameter->save();
|
||||
|
||||
$contest_parameter->load('contest');
|
||||
|
||||
return response()->json($contest_parameter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání parametrů.
|
||||
* Autorizace přes ContestParameterPolicy@delete.
|
||||
*/
|
||||
public function destroy(ContestParameter $contest_parameter): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $contest_parameter);
|
||||
|
||||
$contest_parameter->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace vstupu pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'contest_id' => [$required, 'integer', 'exists:contests,id'],
|
||||
'log_type' => [$required, 'in:STANDARD,CHECK'],
|
||||
'ignore_slash_part' => [$required, 'boolean'],
|
||||
'ignore_third_part' => [$required, 'boolean'],
|
||||
'letters_in_rst' => [$required, 'boolean'],
|
||||
'discard_qso_rec_diff_call' => [$required, 'boolean'],
|
||||
'discard_qso_sent_diff_call' => [$required, 'boolean'],
|
||||
'discard_qso_rec_diff_rst' => [$required, 'boolean'],
|
||||
'discard_qso_sent_diff_rst' => [$required, 'boolean'],
|
||||
'discard_qso_rec_diff_code' => [$required, 'boolean'],
|
||||
'discard_qso_sent_diff_code' => [$required, 'boolean'],
|
||||
'unique_qso' => [$required, 'boolean'],
|
||||
'time_tolerance' => [$required, 'integer', 'min:0'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
101
app/Http/Controllers/CountryWwlController.php
Normal file
101
app/Http/Controllers/CountryWwlController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CountryWwl;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class CountryWwlController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Create / Update / Delete pouze pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam záznamů country-WWL (stránkovaně).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$items = CountryWwl::query()
|
||||
->orderBy('country_name')
|
||||
->orderBy('wwl')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nového country-WWL záznamu.
|
||||
* Autorizace přes CountryWwlPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', CountryWwl::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$item = CountryWwl::create($data);
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho country-WWL záznamu.
|
||||
*/
|
||||
public function show(CountryWwl $country_wwl): JsonResponse
|
||||
{
|
||||
return response()->json($country_wwl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace existujícího country-WWL záznamu (partial update).
|
||||
* Autorizace přes CountryWwlPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, CountryWwl $country_wwl): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $country_wwl);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$country_wwl->fill($data);
|
||||
$country_wwl->save();
|
||||
|
||||
return response()->json($country_wwl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání country-WWL záznamu.
|
||||
* Autorizace přes CountryWwlPolicy@delete.
|
||||
*/
|
||||
public function destroy(CountryWwl $country_wwl): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $country_wwl);
|
||||
|
||||
$country_wwl->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace dat pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'country_name' => [$required, 'string', 'max:150'],
|
||||
'wwl' => [$required, 'string', 'size:4'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
110
app/Http/Controllers/CtyController.php
Normal file
110
app/Http/Controllers/CtyController.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Cty;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class CtyController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Create / Update / Delete jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam CTY záznamů (stránkovaný výstup).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$items = Cty::query()
|
||||
->orderBy('country_name')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nového CTY záznamu.
|
||||
* Autorizace přes CtyPolicy@create (pokud ji používáš).
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Cty::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$item = Cty::create($data);
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho CTY záznamu.
|
||||
*/
|
||||
public function show(Cty $cty): JsonResponse
|
||||
{
|
||||
return response()->json($cty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace CTY záznamu (partial update).
|
||||
* Autorizace přes CtyPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, Cty $cty): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $cty);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$cty->fill($data);
|
||||
$cty->save();
|
||||
|
||||
return response()->json($cty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání CTY záznamu.
|
||||
* Autorizace přes CtyPolicy@delete.
|
||||
*/
|
||||
public function destroy(Cty $cty): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $cty);
|
||||
|
||||
$cty->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'country_name' => [$required, 'string', 'max:150'],
|
||||
'dxcc' => [$required, 'integer'],
|
||||
'cq_zone' => [$required, 'integer'],
|
||||
'itu_zone' => [$required, 'integer'],
|
||||
'continent' => [$required, 'string', 'size:2'],
|
||||
'latitude' => [$required, 'numeric'],
|
||||
'longitude' => [$required, 'numeric'],
|
||||
'time_offset' => [$required, 'numeric'],
|
||||
'prefix' => [$required, 'string', 'max:64'],
|
||||
'prefix_norm' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||
'precise' => [$required, 'boolean'],
|
||||
'source' => [$required, 'string', 'max:25'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
93
app/Http/Controllers/EdiBandController.php
Normal file
93
app/Http/Controllers/EdiBandController.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EdiBand;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class EdiBandController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth:sanctum')->except(['index', 'show']);
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$items = EdiBand::query()
|
||||
->with('bands') // eager load pokud chceš mít vazby
|
||||
->orderBy('value')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', EdiBand::class);
|
||||
|
||||
$data = $request->validate([
|
||||
'value' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$relations = $request->validate([
|
||||
'band_ids' => ['sometimes', 'array'],
|
||||
'band_ids.*' => ['integer', 'exists:bands,id'],
|
||||
]);
|
||||
|
||||
$item = EdiBand::create($data);
|
||||
|
||||
if (array_key_exists('band_ids', $relations)) {
|
||||
$item->bands()->sync($relations['band_ids']);
|
||||
}
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
public function show(EdiBand $edi_band): JsonResponse
|
||||
{
|
||||
$edi_band->load('bands');
|
||||
|
||||
return response()->json($edi_band);
|
||||
}
|
||||
|
||||
public function update(Request $request, EdiBand $edi_band): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $edi_band);
|
||||
|
||||
$data = $request->validate([
|
||||
'value' => ['sometimes', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$relations = $request->validate([
|
||||
'band_ids' => ['sometimes', 'array'],
|
||||
'band_ids.*' => ['integer', 'exists:bands,id'],
|
||||
]);
|
||||
|
||||
$edi_band->fill($data);
|
||||
$edi_band->save();
|
||||
|
||||
if (array_key_exists('band_ids', $relations)) {
|
||||
$edi_band->bands()->sync($relations['band_ids']);
|
||||
}
|
||||
|
||||
return response()->json($edi_band);
|
||||
}
|
||||
|
||||
public function destroy(EdiBand $edi_band): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $edi_band);
|
||||
|
||||
$edi_band->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
128
app/Http/Controllers/EdiCategoryController.php
Normal file
128
app/Http/Controllers/EdiCategoryController.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EdiCategory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class EdiCategoryController extends BaseController
|
||||
{
|
||||
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápisové operace jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam EDI kategorií (API, JSON).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$items = EdiCategory::query()
|
||||
->with('categories') // n:m vazba na Category, pokud ji chceš mít v odpovědi
|
||||
->orderBy('value')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nové EDI kategorie.
|
||||
* Autorizace přes EdiCategoryPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', EdiCategory::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
$item = EdiCategory::create($data);
|
||||
|
||||
if (array_key_exists('category_ids', $relations)) {
|
||||
$item->categories()->sync($relations['category_ids']);
|
||||
}
|
||||
|
||||
$item->load('categories');
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jedné EDI kategorie.
|
||||
*/
|
||||
public function show(EdiCategory $edi_category): JsonResponse
|
||||
{
|
||||
$edi_category->load('categories');
|
||||
|
||||
return response()->json($edi_category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace existující EDI kategorie (partial update).
|
||||
* Autorizace přes EdiCategoryPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, EdiCategory $edi_category): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $edi_category);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
$edi_category->fill($data);
|
||||
$edi_category->save();
|
||||
|
||||
if (array_key_exists('category_ids', $relations)) {
|
||||
$edi_category->categories()->sync($relations['category_ids']);
|
||||
}
|
||||
|
||||
$edi_category->load('categories');
|
||||
|
||||
return response()->json($edi_category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání EDI kategorie.
|
||||
* Autorizace přes EdiCategoryPolicy@delete.
|
||||
*/
|
||||
public function destroy(EdiCategory $edi_category): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $edi_category);
|
||||
|
||||
$edi_category->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace dat pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'value' => [$required, 'string', 'max:255'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace ID navázaných kategorií (Category).
|
||||
*/
|
||||
protected function validateRelations(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'category_ids' => ['sometimes', 'array'],
|
||||
'category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
202
app/Http/Controllers/EvaluationRuleSetController.php
Normal file
202
app/Http/Controllers/EvaluationRuleSetController.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EvaluationRuleSet;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class EvaluationRuleSetController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápis jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam rulesetů (stránkovaně).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$items = EvaluationRuleSet::query()
|
||||
->orderBy('name')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nového rulesetu.
|
||||
* Autorizace přes EvaluationRuleSetPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', EvaluationRuleSet::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$item = EvaluationRuleSet::create($data);
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail rulesetu.
|
||||
*/
|
||||
public function show(EvaluationRuleSet $evaluationRuleSet): JsonResponse
|
||||
{
|
||||
$evaluationRuleSet->load(['evaluationRuns']);
|
||||
|
||||
return response()->json($evaluationRuleSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update (partial).
|
||||
* Autorizace přes EvaluationRuleSetPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, EvaluationRuleSet $evaluationRuleSet): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $evaluationRuleSet);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$evaluationRuleSet->fill($data);
|
||||
$evaluationRuleSet->save();
|
||||
|
||||
return response()->json($evaluationRuleSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání rulesetu.
|
||||
* Autorizace přes EvaluationRuleSetPolicy@delete.
|
||||
*/
|
||||
public function destroy(EvaluationRuleSet $evaluationRuleSet): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $evaluationRuleSet);
|
||||
|
||||
$evaluationRuleSet->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace vstupů pro store/update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => [$required, 'string', 'max:100'],
|
||||
'code' => [$required, 'string', 'max:50'],
|
||||
'description' => ['sometimes', 'nullable', 'string'],
|
||||
|
||||
'scoring_mode' => [$required, 'in:DISTANCE,FIXED_POINTS'],
|
||||
'points_per_qso' => ['sometimes', 'integer', 'min:0'],
|
||||
'points_per_km' => ['sometimes', 'numeric', 'min:0'],
|
||||
|
||||
'use_multipliers' => ['sometimes', 'boolean'],
|
||||
'multiplier_type' => [$required, 'in:NONE,WWL,DXCC,SECTION,COUNTRY'],
|
||||
|
||||
'dup_qso_policy' => [$required, 'in:ZERO_POINTS,PENALTY'],
|
||||
'nil_qso_policy' => [$required, 'in:ZERO_POINTS,PENALTY'],
|
||||
'no_counterpart_log_policy' => ['sometimes', 'in:INVALID,ZERO_POINTS,FLAG_ONLY,PENALTY'],
|
||||
'not_in_counterpart_log_policy' => ['sometimes', 'in:INVALID,ZERO_POINTS,FLAG_ONLY,PENALTY'],
|
||||
'unique_qso_policy' => ['sometimes', 'in:INVALID,ZERO_POINTS,FLAG_ONLY'],
|
||||
'busted_call_policy' => [$required, 'in:ZERO_POINTS,PENALTY'],
|
||||
'busted_exchange_policy'=> [$required, 'in:ZERO_POINTS,PENALTY'],
|
||||
'busted_serial_policy' => ['sometimes', 'in:ZERO_POINTS,PENALTY'],
|
||||
'busted_locator_policy' => ['sometimes', 'in:ZERO_POINTS,PENALTY'],
|
||||
|
||||
'penalty_dup_points' => ['sometimes', 'integer', 'min:0'],
|
||||
'penalty_nil_points' => ['sometimes', 'integer', 'min:0'],
|
||||
'penalty_busted_call_points' => ['sometimes', 'integer', 'min:0'],
|
||||
'penalty_busted_exchange_points' => ['sometimes', 'integer', 'min:0'],
|
||||
'penalty_busted_serial_points' => ['sometimes', 'integer', 'min:0'],
|
||||
'penalty_busted_locator_points' => ['sometimes', 'integer', 'min:0'],
|
||||
|
||||
'dupe_scope' => ['sometimes', 'in:BAND,BAND_MODE'],
|
||||
'callsign_normalization' => ['sometimes', 'in:STRICT,IGNORE_SUFFIX'],
|
||||
'distance_rounding' => ['sometimes', 'in:FLOOR,ROUND,CEIL'],
|
||||
'min_distance_km' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'require_locators' => ['sometimes', 'boolean'],
|
||||
'out_of_window_policy' => ['sometimes', 'in:IGNORE,ZERO_POINTS,PENALTY,INVALID'],
|
||||
'penalty_out_of_window_points' => ['sometimes', 'integer', 'min:0'],
|
||||
|
||||
'exchange_type' => ['sometimes', 'in:SERIAL,WWL,SERIAL_WWL,CUSTOM'],
|
||||
'exchange_requires_wwl' => ['sometimes', 'boolean'],
|
||||
'exchange_requires_serial' => ['sometimes', 'boolean'],
|
||||
'exchange_requires_report' => ['sometimes', 'boolean'],
|
||||
'exchange_pattern' => ['sometimes', 'nullable', 'string', 'max:200'],
|
||||
|
||||
'ignore_slash_part' => ['sometimes', 'boolean'],
|
||||
'ignore_third_part' => ['sometimes', 'boolean'],
|
||||
'letters_in_rst' => ['sometimes', 'boolean'],
|
||||
'rst_ignore_third_char' => ['sometimes', 'boolean'],
|
||||
'discard_qso_rec_diff_call' => ['sometimes', 'boolean'],
|
||||
'discard_qso_sent_diff_call' => ['sometimes', 'boolean'],
|
||||
'discard_qso_rec_diff_rst' => ['sometimes', 'boolean'],
|
||||
'discard_qso_sent_diff_rst' => ['sometimes', 'boolean'],
|
||||
'discard_qso_rec_diff_code' => ['sometimes', 'boolean'],
|
||||
'discard_qso_sent_diff_code' => ['sometimes', 'boolean'],
|
||||
'discard_qso_rec_diff_serial' => ['sometimes', 'boolean'],
|
||||
'discard_qso_sent_diff_serial' => ['sometimes', 'boolean'],
|
||||
'discard_qso_rec_diff_wwl' => ['sometimes', 'boolean'],
|
||||
'discard_qso_sent_diff_wwl' => ['sometimes', 'boolean'],
|
||||
'busted_rst_policy' => ['sometimes', 'in:ZERO_POINTS,PENALTY'],
|
||||
'penalty_busted_rst_points' => ['sometimes', 'integer', 'min:0'],
|
||||
|
||||
'match_tiebreak_order' => ['sometimes', 'nullable', 'array'],
|
||||
'match_require_locator_match' => ['sometimes', 'boolean'],
|
||||
'match_require_exchange_match' => ['sometimes', 'boolean'],
|
||||
|
||||
'multiplier_scope' => ['sometimes', 'in:PER_BAND,OVERALL'],
|
||||
'multiplier_source' => ['sometimes', 'in:VALID_ONLY,ALL_MATCHED'],
|
||||
'wwl_multiplier_level' => ['sometimes', 'in:LOCATOR_2,LOCATOR_4,LOCATOR_6'],
|
||||
|
||||
'checklog_matching' => ['sometimes', 'boolean'],
|
||||
'out_of_window_dq_threshold' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'time_diff_dq_threshold_percent' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:100'],
|
||||
'time_diff_dq_threshold_sec' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'bad_qso_dq_threshold_percent' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:100'],
|
||||
|
||||
'time_tolerance_sec' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'require_unique_qso' => ['sometimes', 'boolean'],
|
||||
'allow_time_shift_one_hour' => ['sometimes', 'boolean'],
|
||||
'time_shift_seconds' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'time_mismatch_policy' => ['sometimes', 'nullable', 'in:INVALID,ZERO_POINTS,FLAG_ONLY,PENALTY'],
|
||||
'allow_time_mismatch_pairing' => ['sometimes', 'boolean'],
|
||||
'time_mismatch_max_sec' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'callsign_suffix_max_len' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'callsign_levenshtein_max' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:2'],
|
||||
'dup_resolution_strategy' => ['sometimes', 'nullable', 'array'],
|
||||
'operating_window_mode' => ['sometimes', 'in:NONE,BEST_CONTIGUOUS'],
|
||||
'operating_window_hours' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:24'],
|
||||
'sixhr_ranking_mode' => ['sometimes', 'in:IARU,CRK'],
|
||||
|
||||
'options' => ['sometimes', 'nullable', 'array'],
|
||||
]);
|
||||
|
||||
if (array_key_exists('operating_window_hours', $data) && ! array_key_exists('operating_window_mode', $data)) {
|
||||
$data['operating_window_mode'] = 'BEST_CONTIGUOUS';
|
||||
}
|
||||
|
||||
if (array_key_exists('operating_window_mode', $data)) {
|
||||
if ($data['operating_window_mode'] === 'BEST_CONTIGUOUS') {
|
||||
$data['operating_window_hours'] = 6;
|
||||
} else {
|
||||
$data['operating_window_hours'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
338
app/Http/Controllers/EvaluationRunController.php
Normal file
338
app/Http/Controllers/EvaluationRunController.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\EvaluationLock;
|
||||
use App\Models\EvaluationRunEvent;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
/**
|
||||
* Controller: EvaluationRunController
|
||||
*
|
||||
* Účel:
|
||||
* - HTTP API vrstva pro práci s vyhodnocovacími běhy (EvaluationRun).
|
||||
* - Slouží pro:
|
||||
* - monitoring průběhu
|
||||
* - zobrazení detailu běhu
|
||||
* - ruční řízení (resume/cancel) a CRUD nad záznamem běhu
|
||||
*
|
||||
* Kontext v architektuře:
|
||||
* - Controller je tenká vrstva mezi frontendem a aplikační logikou.
|
||||
* - Neobsahuje byznys logiku vyhodnocení.
|
||||
* - Orchestrace a výpočty jsou delegovány na background joby
|
||||
* (PrepareRunJob, ParseLogJob, …, FinalizeRunJob) a na RoundController,
|
||||
* který spouští celý pipeline.
|
||||
*
|
||||
* Typické endpointy (konceptuálně):
|
||||
* - POST /api/rounds/{round}/evaluation-runs/start
|
||||
* → spustí vyhodnocovací pipeline (RoundController)
|
||||
* - GET /api/evaluation-runs
|
||||
* → vrátí seznam běžících / dokončených běhů (monitoring)
|
||||
* - GET /api/evaluation-runs/{id}
|
||||
* → detail konkrétního běhu (stav, progress, kroky, chyby)
|
||||
* - POST /api/evaluation-runs/{id}/cancel
|
||||
* → (volitelně) požádá o zrušení běhu
|
||||
* - POST /api/evaluation-runs/{id}/resume
|
||||
* → pokračuje v pipeline po manuální kontrole
|
||||
*
|
||||
* Odpovědnosti controlleru:
|
||||
* - validace vstupních dat (request objekty)
|
||||
* - autorizace přístupu (Policies / Gates)
|
||||
* - vytvoření EvaluationRun záznamu
|
||||
* - nespouští pipeline přímo (to dělá RoundController)
|
||||
* - serializace odpovědí do JSON (DTO / Resource)
|
||||
*
|
||||
* Co controller NEDĚLÁ:
|
||||
* - neparsuje EDI logy
|
||||
* - neprovádí matching ani scoring
|
||||
* - nečeká synchronně na dokončení vyhodnocení
|
||||
*
|
||||
* Zásady návrhu:
|
||||
* - Všechny operace musí být rychlé (non-blocking).
|
||||
* - Spuštění vyhodnocení je vždy asynchronní.
|
||||
* - Stav běhu je čitelný pouze z EvaluationRun + souvisejících entit.
|
||||
*/
|
||||
|
||||
class EvaluationRunController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápisové operace jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam evaluation runů – filtrování podle round_id, is_official.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
// Seznam běhů slouží hlavně pro monitoring na RoundDetailPage.
|
||||
$query = EvaluationRun::query()
|
||||
->with(['round']);
|
||||
|
||||
if ($request->filled('round_id')) {
|
||||
$query->where('round_id', (int) $request->get('round_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('is_official')) {
|
||||
$query->where(
|
||||
'is_official',
|
||||
filter_var($request->get('is_official'), FILTER_VALIDATE_BOOL)
|
||||
);
|
||||
}
|
||||
if ($request->filled('result_type')) {
|
||||
$query->where('result_type', $request->get('result_type'));
|
||||
}
|
||||
|
||||
$items = $query
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nového evaluation runu.
|
||||
* Typicky před samotným spuštěním vyhodnocovače.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', EvaluationRun::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
// Samotné spuštění pipeline zajišťuje StartEvaluationRunJob.
|
||||
$run = EvaluationRun::create($data);
|
||||
|
||||
$run->load(['round']);
|
||||
|
||||
return response()->json($run, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho evaluation runu včetně vazeb a výsledků.
|
||||
*/
|
||||
public function show(EvaluationRun $evaluationRun): JsonResponse
|
||||
{
|
||||
// Detail běhu včetně výsledků je náročný – používej s rozumem (paging/limit).
|
||||
$evaluationRun->load([
|
||||
'round',
|
||||
'logResults',
|
||||
'qsoResults',
|
||||
]);
|
||||
|
||||
return response()->json($evaluationRun);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace evaluation runu (např. změna názvu, poznámky,
|
||||
* příznaku is_official).
|
||||
*/
|
||||
public function update(Request $request, EvaluationRun $evaluationRun): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $evaluationRun);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$evaluationRun->fill($data);
|
||||
$evaluationRun->save();
|
||||
|
||||
$evaluationRun->load(['round']);
|
||||
|
||||
return response()->json($evaluationRun);
|
||||
}
|
||||
|
||||
/**
|
||||
* Označí běh jako TEST/PRELIMINARY/FINAL a aktualizuje ukazatele v kole.
|
||||
*/
|
||||
public function setResultType(Request $request, EvaluationRun $evaluationRun): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $evaluationRun);
|
||||
|
||||
$data = $request->validate([
|
||||
'result_type' => ['required', 'string', 'in:TEST,PRELIMINARY,FINAL'],
|
||||
]);
|
||||
|
||||
$resultType = $data['result_type'];
|
||||
$evaluationRun->update([
|
||||
'result_type' => $resultType,
|
||||
'is_official' => $resultType === 'FINAL',
|
||||
]);
|
||||
|
||||
$round = $evaluationRun->round;
|
||||
if ($round) {
|
||||
if ($resultType === 'FINAL') {
|
||||
$round->official_evaluation_run_id = $evaluationRun->id;
|
||||
$round->preliminary_evaluation_run_id = null;
|
||||
$round->test_evaluation_run_id = null;
|
||||
} elseif ($resultType === 'PRELIMINARY') {
|
||||
$round->preliminary_evaluation_run_id = $evaluationRun->id;
|
||||
$round->official_evaluation_run_id = null;
|
||||
$round->test_evaluation_run_id = null;
|
||||
} elseif ($resultType === 'TEST') {
|
||||
$round->test_evaluation_run_id = $evaluationRun->id;
|
||||
$round->official_evaluation_run_id = null;
|
||||
$round->preliminary_evaluation_run_id = null;
|
||||
}
|
||||
$round->save();
|
||||
}
|
||||
|
||||
$evaluationRun->load(['round']);
|
||||
|
||||
return response()->json($evaluationRun);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání evaluation runu (včetně log_results / qso_results přes FK).
|
||||
*/
|
||||
public function destroy(EvaluationRun $evaluationRun): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $evaluationRun);
|
||||
|
||||
$evaluationRun->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zruší běh vyhodnocení (pokud je stále aktivní).
|
||||
*/
|
||||
public function cancel(EvaluationRun $evaluationRun): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $evaluationRun);
|
||||
|
||||
// Cancel je povolený jen pro běhy, které ještě neskončily.
|
||||
$activeStatuses = ['PENDING', 'RUNNING', 'WAITING_REVIEW_INPUT', 'WAITING_REVIEW_MATCH', 'WAITING_REVIEW_SCORE'];
|
||||
if (! in_array($evaluationRun->status, $activeStatuses, true)) {
|
||||
return response()->json([
|
||||
'message' => 'Běh nelze zrušit v aktuálním stavu.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$evaluationRun->update([
|
||||
'status' => 'CANCELED',
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
if ($evaluationRun->batch_id) {
|
||||
$batch = Bus::findBatch($evaluationRun->batch_id);
|
||||
if ($batch) {
|
||||
$batch->cancel();
|
||||
}
|
||||
}
|
||||
|
||||
EvaluationRunEvent::create([
|
||||
'evaluation_run_id' => $evaluationRun->id,
|
||||
'level' => 'warning',
|
||||
'message' => 'Vyhodnocení bylo zrušeno uživatelem.',
|
||||
'context' => [
|
||||
'step' => 'cancel',
|
||||
'round_id' => $evaluationRun->round_id,
|
||||
'user_id' => auth()->id(),
|
||||
],
|
||||
]);
|
||||
|
||||
// Uvolní lock, aby mohl běh navázat nebo se spustit nový.
|
||||
EvaluationLock::where('evaluation_run_id', $evaluationRun->id)->delete();
|
||||
|
||||
return response()->json([
|
||||
'status' => 'canceled',
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí poslední události běhu vyhodnocení.
|
||||
*/
|
||||
public function events(EvaluationRun $evaluationRun, Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $evaluationRun);
|
||||
|
||||
$limit = (int) $request->get('limit', 10);
|
||||
if ($limit < 1) {
|
||||
$limit = 1;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
|
||||
$minLevel = $request->get('min_level');
|
||||
$levels = ['debug', 'info', 'warning', 'error'];
|
||||
if (! in_array($minLevel, $levels, true)) {
|
||||
$minLevel = null;
|
||||
}
|
||||
|
||||
$events = $evaluationRun->events()
|
||||
->when($minLevel, function ($query) use ($minLevel, $levels) {
|
||||
$query->whereIn('level', array_slice($levels, array_search($minLevel, $levels, true)));
|
||||
})
|
||||
->orderByDesc('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
return response()->json($events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pokračuje v běhu vyhodnocení po manuální kontrole.
|
||||
*/
|
||||
public function resume(Request $request, EvaluationRun $evaluationRun): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $evaluationRun);
|
||||
|
||||
if ($evaluationRun->isCanceled()) {
|
||||
return response()->json([
|
||||
'message' => 'Běh byl zrušen.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$ok = app(EvaluationCoordinator::class)->resume($evaluationRun, [
|
||||
'rebuild_working_set' => $request->boolean('rebuild_working_set'),
|
||||
]);
|
||||
|
||||
if ($ok) {
|
||||
return response()->json([
|
||||
'status' => 'queued',
|
||||
], 202);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Běh není ve stavu čekání na kontrolu.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace vstupů pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'round_id' => [$required, 'integer', 'exists:rounds,id'],
|
||||
'rule_set_id' => ['sometimes', 'nullable', 'integer', 'exists:evaluation_rule_sets,id'],
|
||||
|
||||
'name' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'rules_version' => ['sometimes', 'nullable', 'string', 'in:OFFICIAL,TEST,CLAIMED'],
|
||||
'result_type' => ['sometimes', 'nullable', 'string', 'in:PRELIMINARY,FINAL,TEST'],
|
||||
'is_official' => ['sometimes', 'boolean'],
|
||||
'notes' => ['sometimes', 'nullable', 'string'],
|
||||
'scope' => ['sometimes', 'array'],
|
||||
'scope.band_ids' => ['sometimes', 'array'],
|
||||
'scope.band_ids.*' => ['integer', 'exists:bands,id'],
|
||||
'scope.category_ids' => ['sometimes', 'array'],
|
||||
'scope.category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
'scope.power_category_ids' => ['sometimes', 'array'],
|
||||
'scope.power_category_ids.*' => ['integer', 'exists:power_categories,id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
348
app/Http/Controllers/FileController.php
Normal file
348
app/Http/Controllers/FileController.php
Normal file
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\File;
|
||||
use App\Models\Log;
|
||||
use App\Models\Round;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use App\Services\Evaluation\ClaimedRunResolver;
|
||||
use App\Jobs\ParseLogJob;
|
||||
use App\Jobs\RecalculateClaimedRanksJob;
|
||||
|
||||
class FileController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth:sanctum')->only(['delete']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí seznam nahraných souborů (metadata) pro zobrazení v UI.
|
||||
* Výstupem je JSON kolekce záznamů bez interního pole "path".
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$files = File::query()
|
||||
->select([
|
||||
'id',
|
||||
'filename',
|
||||
'mimetype',
|
||||
'filesize',
|
||||
'hash',
|
||||
'uploaded_by',
|
||||
'created_at',
|
||||
])
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí metadata konkrétního souboru jako JSON.
|
||||
* Path k fyzickému souboru se z bezpečnostních důvodů nevrací.
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function show(File $file): JsonResponse
|
||||
{
|
||||
// schválně nevracím path, je to interní implementační detail
|
||||
return response()->json([
|
||||
'id' => $file->id,
|
||||
'filename' => $file->filename,
|
||||
'mimetype' => $file->mimetype,
|
||||
'filesize' => $file->filesize,
|
||||
'hash' => $file->hash,
|
||||
'uploaded_by' => $file->uploaded_by,
|
||||
'created_at' => $file->created_at,
|
||||
'updated_at' => $file->updated_at,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí soubor ke stažení (HTTP download) s Content-Disposition: attachment.
|
||||
* Pokud soubor fyzicky neexistuje, vrátí HTTP 404.
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return \Symfony\Component\HttpFoundation\StreamedResponse
|
||||
*/
|
||||
public function download(File $file): StreamedResponse
|
||||
{
|
||||
if (! Storage::exists($file->path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return Storage::download(
|
||||
$file->path,
|
||||
$this->buildDownloadName($file),
|
||||
['Content-Type' => $file->mimetype]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí obsah souboru v HTTP odpovědi (např. pro náhled nebo další zpracování).
|
||||
* Content-Type je převzat z uloženého mimetype v DB.
|
||||
* Pokud soubor neexistuje, vrátí HTTP 404.
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function content(File $file): Response
|
||||
{
|
||||
$content = $this->getFileContent($file);
|
||||
|
||||
return response($content, 200)
|
||||
->header('Content-Type', $file->mimetype);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interní helper pro načtení obsahu souboru pro interní použití v PHP kódu.
|
||||
* Při neexistenci souboru by měl konzistentně signalizovat chybu
|
||||
* stejně jako download()/content() – buď abort(404), nebo doménovou výjimkou.
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return string binární obsah souboru
|
||||
*
|
||||
* @throws \RuntimeException pokud soubor neexistuje (aktuální stav)
|
||||
*/
|
||||
protected function getFileContent(File $file): string
|
||||
{
|
||||
if (! Storage::exists($file->path)) {
|
||||
throw new \RuntimeException('File not found.');
|
||||
}
|
||||
|
||||
return Storage::get($file->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Přijme nahraný soubor z HTTP requestu, uloží ho na disk pod UUID názvem
|
||||
* do dvouúrovňové adresářové struktury (první znak / první dva znaky UUID),
|
||||
* spočítá hash obsahu a zapíše metadata do tabulky files.
|
||||
*
|
||||
* Vrací JSON s metadaty nově vytvořeného záznamu.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'file' => ['required', 'file', 'max:10240'],
|
||||
'round_id' => ['required', 'integer', 'exists:rounds,id'],
|
||||
]);
|
||||
|
||||
/** @var \Illuminate\Http\UploadedFile $uploaded */
|
||||
$uploaded = $validated['file'];
|
||||
$roundId = (int) $validated['round_id'];
|
||||
$round = Round::find($roundId);
|
||||
if (! $round) {
|
||||
return response()->json([
|
||||
'message' => 'Kolo nebylo nalezeno.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
if (! auth()->check()) {
|
||||
$deadline = $round->logs_deadline;
|
||||
if (! $deadline || now()->greaterThan($deadline)) {
|
||||
return response()->json([
|
||||
'message' => 'Termín pro nahrání logu již vypršel.',
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
|
||||
$hash = hash_file('sha256', $uploaded->getRealPath());
|
||||
|
||||
// pokus o načtení PCall z EDI pro případnou náhradu existujícího logu
|
||||
$pcall = $this->extractPcallFromUploaded($uploaded);
|
||||
|
||||
// ověř existenci v DB
|
||||
$existing = File::where('hash', $hash)->first();
|
||||
if ($existing) {
|
||||
return response()->json([
|
||||
'message' => 'Duplicitní soubor již existuje.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
// pokud existuje log se stejnou PCall v daném kole, ale jiným hashem, nahraď ho novým
|
||||
if ($pcall) {
|
||||
$existingLog = Log::with('file')
|
||||
->where('round_id', $roundId)
|
||||
->whereRaw('UPPER(pcall) = ?', [mb_strtoupper($pcall)])
|
||||
->first();
|
||||
|
||||
if ($existingLog && $existingLog->file && $existingLog->file->hash === $hash) {
|
||||
return response()->json([
|
||||
'message' => 'Duplicitní soubor již existuje.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
if ($existingLog) {
|
||||
$this->deleteLogWithFile($existingLog);
|
||||
}
|
||||
}
|
||||
|
||||
$uuid = (string) Str::uuid();
|
||||
$extension = $uploaded->getClientOriginalExtension();
|
||||
$storedFilename = $uuid . ($extension ? '.' . $extension : '');
|
||||
|
||||
$level1 = substr($uuid, 0, 1);
|
||||
$level2 = substr($uuid, 0, 2);
|
||||
$directory = "uploads/{$level1}/{$level2}";
|
||||
|
||||
if (! Storage::exists($directory)) {
|
||||
Storage::makeDirectory($directory);
|
||||
}
|
||||
|
||||
$storedPath = $uploaded->storeAs($directory, $storedFilename);
|
||||
|
||||
try {
|
||||
$file = File::create([
|
||||
'path' => $storedPath,
|
||||
'filename' => $uploaded->getClientOriginalName(),
|
||||
'mimetype' => $uploaded->getMimeType() ?? 'application/octet-stream',
|
||||
'filesize' => $uploaded->getSize(),
|
||||
'hash' => $hash,
|
||||
'uploaded_by' => auth()->check() ? (string) auth()->id() : null,
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
// hash už mezitím někdo vložil
|
||||
return response()->json([
|
||||
'message' => 'Duplicitní soubor již existuje.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$log = Log::create([
|
||||
'round_id' => $roundId,
|
||||
'file_id' => $file->id,
|
||||
'ip_address' => $request->ip(),
|
||||
]);
|
||||
|
||||
// Předej parsování do asynchronní pipeline (ParseLogJob),
|
||||
// aby logiku bylo možné volat jednotně z evaluace.
|
||||
$claimedRun = ClaimedRunResolver::forRound($roundId);
|
||||
ParseLogJob::dispatch($claimedRun->id, $log->id)->onQueue('evaluation');
|
||||
RecalculateClaimedRanksJob::dispatch($claimedRun->id)
|
||||
->delay(now()->addSeconds(10))
|
||||
->onQueue('evaluation');
|
||||
|
||||
return response()->json($file, 201);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Smaže fyzický soubor z disku a odpovídající metadata z DB.
|
||||
* Pokud soubor neexistuje, vrací 404 (pouze pokud nechceš tichý success).
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function destroy(File $file): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $file);
|
||||
|
||||
if (Storage::exists($file->path)) {
|
||||
Storage::delete($file->path);
|
||||
}
|
||||
|
||||
$file->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zkusí vytáhnout PCall z nahraného EDI souboru (bez plného parsování).
|
||||
*/
|
||||
protected function extractPcallFromUploaded(\Illuminate\Http\UploadedFile $uploaded): ?string
|
||||
{
|
||||
$contents = @file($uploaded->getRealPath(), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if (! $contents) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($contents as $line) {
|
||||
$trimmed = trim((string) $line);
|
||||
if (stripos($trimmed, 'PCALL=') === 0) {
|
||||
return trim(substr($trimmed, 6));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smaže log, jeho QSO a výsledky, navázaný soubor a fyzický obsah.
|
||||
*/
|
||||
protected function deleteLogWithFile(Log $log): void
|
||||
{
|
||||
$file = $log->file;
|
||||
$filePath = $file?->path;
|
||||
|
||||
DB::transaction(function () use ($log, $file) {
|
||||
$log->logResults()->delete();
|
||||
$log->qsos()->delete();
|
||||
$log->delete();
|
||||
if ($file) {
|
||||
$file->delete();
|
||||
}
|
||||
});
|
||||
|
||||
if ($filePath && Storage::exists($filePath)) {
|
||||
Storage::delete($filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoří název souboru pro download ve formátu XXCALLSIGN_HASH.edi
|
||||
*/
|
||||
protected function buildDownloadName(File $file): string
|
||||
{
|
||||
$log = Log::where('file_id', $file->id)->first();
|
||||
if (! $log) {
|
||||
return $file->filename;
|
||||
}
|
||||
|
||||
$pcall = strtoupper(trim($log->pcall ?? ''));
|
||||
if ($pcall === '') {
|
||||
return $file->filename;
|
||||
}
|
||||
$psect = strtoupper(trim($log->psect ?? ''));
|
||||
$tokens = preg_split('/[\s;,_-]+/', $psect) ?: [];
|
||||
$hasCheck = in_array('CHECK', $tokens, true) || $psect === 'CHECK';
|
||||
$sixHour = ($log->sixhr_category ?? false) || in_array('6H', $tokens, true);
|
||||
$isSO = in_array('SO', $tokens, true) || in_array('SOLO', $tokens, true);
|
||||
$isMO = in_array('MO', $tokens, true) || in_array('MULTI', $tokens, true);
|
||||
|
||||
$prefix = '';
|
||||
if (! $hasCheck) {
|
||||
if ($sixHour && $isSO) {
|
||||
$prefix = '61';
|
||||
} elseif ($sixHour && $isMO) {
|
||||
$prefix = '62';
|
||||
} elseif ($isSO) {
|
||||
$prefix = '01';
|
||||
} elseif ($isMO) {
|
||||
$prefix = '02';
|
||||
}
|
||||
}
|
||||
|
||||
$hashPart = strtoupper(substr(hash('crc32', $file->hash ?? (string) $file->id), 0, 8));
|
||||
|
||||
return ($prefix ? $prefix : '') . $pcall . '_' . $hashPart . '.edi';
|
||||
}
|
||||
}
|
||||
344
app/Http/Controllers/LogController.php
Normal file
344
app/Http/Controllers/LogController.php
Normal file
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Log;
|
||||
use App\Models\LogQso;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\QsoOverride;
|
||||
use App\Models\QsoResult;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class LogController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápisové operace jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam logů – s možností filtrování podle round_id, pcall, processed/accepted.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$query = Log::query()
|
||||
->with(['round', 'file'])
|
||||
->withExists(['logResults as parsed'])
|
||||
->withExists(['logResults as parsed_claimed' => function ($q) {
|
||||
$q->whereHas('evaluationRun', function ($runQuery) {
|
||||
$runQuery->where('rules_version', 'CLAIMED');
|
||||
});
|
||||
}]);
|
||||
|
||||
if ($request->filled('round_id')) {
|
||||
$query->where('round_id', (int) $request->get('round_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('pcall')) {
|
||||
$query->where('pcall', $request->get('pcall'));
|
||||
}
|
||||
|
||||
if ($request->filled('processed')) {
|
||||
$query->where('processed', filter_var($request->get('processed'), FILTER_VALIDATE_BOOL));
|
||||
}
|
||||
|
||||
if ($request->filled('accepted')) {
|
||||
$query->where('accepted', filter_var($request->get('accepted'), FILTER_VALIDATE_BOOL));
|
||||
}
|
||||
|
||||
$logs = $query
|
||||
->orderByRaw('parsed_claimed asc, pcall asc')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($logs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření logu.
|
||||
* Typicky voláno po úspěšném uploadu / parsování EDI ve službě.
|
||||
* Autorizace přes LogPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Log::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$log = Log::create($data);
|
||||
|
||||
$log->load(['round', 'file']);
|
||||
|
||||
return response()->json($log, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho logu včetně vazeb a počtu QSO.
|
||||
*/
|
||||
public function show(Request $request, Log $log): JsonResponse
|
||||
{
|
||||
$includeQsos = $request->boolean('include_qsos', false);
|
||||
$relations = ['round', 'file'];
|
||||
if ($includeQsos) {
|
||||
$relations[] = 'qsos';
|
||||
}
|
||||
$log->load($relations);
|
||||
|
||||
return response()->json($log);
|
||||
}
|
||||
|
||||
/**
|
||||
* QSO tabulka pro log: raw QSO + výsledky vyhodnocení + případné overrides.
|
||||
*/
|
||||
public function qsoTable(Request $request, Log $log): JsonResponse
|
||||
{
|
||||
$evalRunId = $request->filled('evaluation_run_id')
|
||||
? (int) $request->get('evaluation_run_id')
|
||||
: null;
|
||||
|
||||
if ($evalRunId) {
|
||||
$run = EvaluationRun::find($evalRunId);
|
||||
if (! $run) {
|
||||
$evalRunId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $evalRunId) {
|
||||
$run = EvaluationRun::query()
|
||||
->where('round_id', $log->round_id)
|
||||
->where('status', 'SUCCEEDED')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('rules_version')
|
||||
->orWhere('rules_version', '!=', 'CLAIMED');
|
||||
})
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
$evalRunId = $run?->id;
|
||||
}
|
||||
|
||||
$qsos = LogQso::query()
|
||||
->where('log_id', $log->id)
|
||||
->orderBy('qso_index')
|
||||
->orderBy('id')
|
||||
->get([
|
||||
'id',
|
||||
'qso_index',
|
||||
'time_on',
|
||||
'dx_call',
|
||||
'my_rst',
|
||||
'my_serial',
|
||||
'dx_rst',
|
||||
'dx_serial',
|
||||
'rx_wwl',
|
||||
'rx_exchange',
|
||||
'mode_code',
|
||||
'new_exchange',
|
||||
'new_wwl',
|
||||
'new_dxcc',
|
||||
'duplicate_qso',
|
||||
'points',
|
||||
]);
|
||||
|
||||
$qsoIds = $qsos->pluck('id')->all();
|
||||
$resultMap = collect();
|
||||
$overrideMap = collect();
|
||||
|
||||
if ($evalRunId && $qsoIds) {
|
||||
$resultMap = QsoResult::query()
|
||||
->where('evaluation_run_id', $evalRunId)
|
||||
->whereIn('log_qso_id', $qsoIds)
|
||||
->get([
|
||||
'log_qso_id',
|
||||
'points',
|
||||
'penalty_points',
|
||||
'error_code',
|
||||
'error_side',
|
||||
'match_confidence',
|
||||
'match_type',
|
||||
'error_flags',
|
||||
'is_valid',
|
||||
'is_duplicate',
|
||||
'is_nil',
|
||||
'is_busted_call',
|
||||
'is_busted_rst',
|
||||
'is_busted_exchange',
|
||||
'is_time_out_of_window',
|
||||
])
|
||||
->keyBy('log_qso_id');
|
||||
|
||||
$overrideMap = QsoOverride::query()
|
||||
->where('evaluation_run_id', $evalRunId)
|
||||
->whereIn('log_qso_id', $qsoIds)
|
||||
->get([
|
||||
'id',
|
||||
'log_qso_id',
|
||||
'forced_status',
|
||||
'forced_matched_log_qso_id',
|
||||
'forced_points',
|
||||
'forced_penalty',
|
||||
'reason',
|
||||
])
|
||||
->keyBy('log_qso_id');
|
||||
}
|
||||
|
||||
$data = $qsos->map(function (LogQso $qso) use ($resultMap, $overrideMap) {
|
||||
$result = $resultMap->get($qso->id);
|
||||
$override = $overrideMap->get($qso->id);
|
||||
|
||||
return [
|
||||
'id' => $qso->id,
|
||||
'qso_index' => $qso->qso_index,
|
||||
'time_on' => $qso->time_on,
|
||||
'dx_call' => $qso->dx_call,
|
||||
'my_rst' => $qso->my_rst,
|
||||
'my_serial' => $qso->my_serial,
|
||||
'dx_rst' => $qso->dx_rst,
|
||||
'dx_serial' => $qso->dx_serial,
|
||||
'rx_wwl' => $qso->rx_wwl,
|
||||
'rx_exchange' => $qso->rx_exchange,
|
||||
'mode_code' => $qso->mode_code,
|
||||
'new_exchange' => $qso->new_exchange,
|
||||
'new_wwl' => $qso->new_wwl,
|
||||
'new_dxcc' => $qso->new_dxcc,
|
||||
'duplicate_qso' => $qso->duplicate_qso,
|
||||
'points' => $qso->points,
|
||||
'remarks' => null,
|
||||
'result' => $result ? [
|
||||
'log_qso_id' => $result->log_qso_id,
|
||||
'points' => $result->points,
|
||||
'penalty_points' => $result->penalty_points,
|
||||
'error_code' => $result->error_code,
|
||||
'error_side' => $result->error_side,
|
||||
'match_confidence' => $result->match_confidence,
|
||||
'match_type' => $result->match_type,
|
||||
'error_flags' => $result->error_flags,
|
||||
'is_valid' => $result->is_valid,
|
||||
'is_duplicate' => $result->is_duplicate,
|
||||
'is_nil' => $result->is_nil,
|
||||
'is_busted_call' => $result->is_busted_call,
|
||||
'is_busted_rst' => $result->is_busted_rst,
|
||||
'is_busted_exchange' => $result->is_busted_exchange,
|
||||
'is_time_out_of_window' => $result->is_time_out_of_window,
|
||||
] : null,
|
||||
'override' => $override ? [
|
||||
'id' => $override->id,
|
||||
'log_qso_id' => $override->log_qso_id,
|
||||
'forced_status' => $override->forced_status,
|
||||
'forced_matched_log_qso_id' => $override->forced_matched_log_qso_id,
|
||||
'forced_points' => $override->forced_points,
|
||||
'forced_penalty' => $override->forced_penalty,
|
||||
'reason' => $override->reason,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'evaluation_run_id' => $evalRunId,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace logu (partial update).
|
||||
* Typicky pro ruční úpravu flagů accepted/processed, případně oprav hlavičky.
|
||||
* Autorizace přes LogPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, Log $log): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $log);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$log->fill($data);
|
||||
$log->save();
|
||||
|
||||
$log->load(['round', 'file']);
|
||||
|
||||
return response()->json($log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání logu (včetně QSO přes FK ON DELETE CASCADE).
|
||||
* Autorizace přes LogPolicy@delete.
|
||||
*/
|
||||
public function destroy(Log $log): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $log);
|
||||
|
||||
// pokud je navázaný soubor, smaž i jeho fyzický obsah a záznam
|
||||
if ($log->file) {
|
||||
if ($log->file->path && Storage::exists($log->file->path)) {
|
||||
Storage::delete($log->file->path);
|
||||
}
|
||||
$log->file->delete();
|
||||
}
|
||||
|
||||
$log->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jednoduchý parser nahraného souboru – aktuálně podporuje EDI.
|
||||
* Pokud jde o EDI, naplní základní pole Logu a uloží raw_header (bez sekce QSORecords).
|
||||
*/
|
||||
public static function parseUploadedFile(Log $log, string $path): void
|
||||
{
|
||||
app(\App\Services\Evaluation\EdiParserService::class)->parseLogFile($log, $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace vstupu pro store / update.
|
||||
* EDI parser bude typicky volat store/update s již připravenými daty.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'round_id' => [$required, 'integer', 'exists:rounds,id'],
|
||||
'file_id' => ['sometimes', 'nullable', 'integer', 'exists:files,id'],
|
||||
|
||||
'accepted' => ['sometimes', 'boolean'],
|
||||
'processed' => ['sometimes', 'boolean'],
|
||||
'ip_address' => ['sometimes', 'nullable', 'string', 'max:45'],
|
||||
|
||||
'tname' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'tdate' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
'pcall' => ['sometimes', 'nullable', 'string', 'max:20'],
|
||||
'pwwlo' => ['sometimes', 'nullable', 'string', 'max:6'],
|
||||
'pexch' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'psect' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'pband' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'pclub' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
|
||||
'country_name' => ['sometimes', 'nullable', 'string', 'max:150'],
|
||||
'operator_name' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'locator' => ['sometimes', 'nullable', 'string', 'max:6'],
|
||||
|
||||
'power_watt' => ['sometimes', 'nullable', 'numeric', 'min:0'],
|
||||
'power_category' => ['sometimes', 'nullable', 'string', 'max:3'],
|
||||
'power_category_id' => ['sometimes', 'nullable', 'integer', 'exists:power_categories,id'],
|
||||
'sixhr_category' => ['sometimes', 'nullable', 'boolean'],
|
||||
|
||||
'claimed_qso_count' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'claimed_score' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'claimed_wwl' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
'claimed_dxcc' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
|
||||
'remarks' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'remarks_eval' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
|
||||
'raw_header' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
416
app/Http/Controllers/LogOverrideController.php
Normal file
416
app/Http/Controllers/LogOverrideController.php
Normal file
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Band;
|
||||
use App\Models\EdiBand;
|
||||
use App\Models\EdiCategory;
|
||||
use App\Models\LogOverride;
|
||||
use App\Models\LogResult;
|
||||
use App\Models\Round;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Jobs\RecalculateOfficialRanksJob;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class LogOverrideController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápisové operace jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam override záznamů – lze filtrovat podle evaluation_run_id/log_id.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$query = LogOverride::query()
|
||||
->with([
|
||||
'evaluationRun',
|
||||
'log',
|
||||
'forcedBand',
|
||||
'forcedCategory',
|
||||
'forcedPowerCategory',
|
||||
'createdByUser',
|
||||
]);
|
||||
|
||||
if ($request->filled('evaluation_run_id')) {
|
||||
$query->where('evaluation_run_id', (int) $request->get('evaluation_run_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('log_id')) {
|
||||
$query->where('log_id', (int) $request->get('log_id'));
|
||||
}
|
||||
|
||||
$items = $query->orderByDesc('id')->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření override záznamu.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', LogOverride::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
$data['context'] = $this->mergeOriginalContext($data['context'] ?? null, $data['evaluation_run_id'], $data['log_id']);
|
||||
if (! isset($data['created_by_user_id']) && $request->user()) {
|
||||
$data['created_by_user_id'] = $request->user()->id;
|
||||
}
|
||||
|
||||
$item = LogOverride::create($data);
|
||||
$this->applyOverrideToLogResult($item);
|
||||
$statusChanged = array_key_exists('forced_log_status', $data);
|
||||
if ($this->shouldRecalculateRanks($item->evaluation_run_id, $statusChanged)) {
|
||||
RecalculateOfficialRanksJob::dispatch($item->evaluation_run_id)->onQueue('evaluation');
|
||||
}
|
||||
$item->load([
|
||||
'evaluationRun',
|
||||
'log',
|
||||
'forcedBand',
|
||||
'forcedCategory',
|
||||
'forcedPowerCategory',
|
||||
'createdByUser',
|
||||
]);
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail override záznamu.
|
||||
*/
|
||||
public function show(LogOverride $logOverride): JsonResponse
|
||||
{
|
||||
$logOverride->load([
|
||||
'evaluationRun',
|
||||
'log',
|
||||
'forcedBand',
|
||||
'forcedCategory',
|
||||
'forcedPowerCategory',
|
||||
'createdByUser',
|
||||
]);
|
||||
|
||||
return response()->json($logOverride);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace override záznamu.
|
||||
*/
|
||||
public function update(Request $request, LogOverride $logOverride): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $logOverride);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
if (! array_key_exists('context', $data)) {
|
||||
$data['context'] = $this->mergeOriginalContext($logOverride->context, $logOverride->evaluation_run_id, $logOverride->log_id);
|
||||
} else {
|
||||
$data['context'] = $this->mergeOriginalContext($data['context'], $logOverride->evaluation_run_id, $logOverride->log_id);
|
||||
}
|
||||
$statusChanged = array_key_exists('forced_log_status', $data);
|
||||
|
||||
$logOverride->fill($data);
|
||||
$logOverride->save();
|
||||
$this->applyOverrideToLogResult($logOverride);
|
||||
if ($this->shouldRecalculateRanks($logOverride->evaluation_run_id, $statusChanged)) {
|
||||
RecalculateOfficialRanksJob::dispatch($logOverride->evaluation_run_id)->onQueue('evaluation');
|
||||
}
|
||||
|
||||
$logOverride->load([
|
||||
'evaluationRun',
|
||||
'log',
|
||||
'forcedBand',
|
||||
'forcedCategory',
|
||||
'forcedPowerCategory',
|
||||
'createdByUser',
|
||||
]);
|
||||
|
||||
return response()->json($logOverride);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání override záznamu.
|
||||
*/
|
||||
public function destroy(LogOverride $logOverride): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $logOverride);
|
||||
|
||||
$log = $logOverride->log;
|
||||
$round = $log ? Round::with(['bands', 'categories', 'powerCategories'])->find($log->round_id) : null;
|
||||
$bandId = $log && $round ? $this->resolveBandId($log, $round) : null;
|
||||
$categoryId = $log && $round ? $this->resolveCategoryId($log, $round) : null;
|
||||
$powerCategoryId = $log?->power_category_id;
|
||||
|
||||
$logOverride->delete();
|
||||
LogResult::where('evaluation_run_id', $logOverride->evaluation_run_id)
|
||||
->where('log_id', $logOverride->log_id)
|
||||
->update([
|
||||
'status' => 'OK',
|
||||
'band_id' => $bandId,
|
||||
'category_id' => $categoryId,
|
||||
'power_category_id' => $powerCategoryId,
|
||||
'sixhr_category' => $log?->sixhr_category,
|
||||
]);
|
||||
$statusChanged = $logOverride->forced_log_status && $logOverride->forced_log_status !== 'AUTO';
|
||||
if ($this->shouldRecalculateRanks($logOverride->evaluation_run_id, $statusChanged)) {
|
||||
RecalculateOfficialRanksJob::dispatch($logOverride->evaluation_run_id)->onQueue('evaluation');
|
||||
}
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
protected function resolveCategoryId(\App\Models\Log $log, Round $round): ?int
|
||||
{
|
||||
$value = $log->psect;
|
||||
if (! $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ediCat = EdiCategory::whereRaw('LOWER(value) = ?', [mb_strtolower(trim($value))])->first();
|
||||
if (! $ediCat) {
|
||||
$ediCat = $this->matchEdiCategoryByRegex($value);
|
||||
}
|
||||
if (! $ediCat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mappedCategoryId = $ediCat->categories()->value('categories.id');
|
||||
if (! $mappedCategoryId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($round->categories()->count() === 0) {
|
||||
return $mappedCategoryId;
|
||||
}
|
||||
|
||||
return $round->categories()->where('categories.id', $mappedCategoryId)->exists()
|
||||
? $mappedCategoryId
|
||||
: null;
|
||||
}
|
||||
|
||||
protected function matchEdiCategoryByRegex(string $value): ?EdiCategory
|
||||
{
|
||||
$candidates = EdiCategory::whereNotNull('regex_pattern')->get();
|
||||
foreach ($candidates as $candidate) {
|
||||
$pattern = $candidate->regex_pattern;
|
||||
if (! $pattern) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$delimited = preg_match('/^([#\\/]).+\\1[imsxuADSUXJ]*$/', $pattern) ? $pattern : '/' . $pattern . '/i';
|
||||
set_error_handler(function () {
|
||||
});
|
||||
$matched = @preg_match($delimited, $value) === 1;
|
||||
restore_error_handler();
|
||||
|
||||
if ($matched) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveBandId(\App\Models\Log $log, Round $round): ?int
|
||||
{
|
||||
if (! $log->pband) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pbandVal = mb_strtolower(trim($log->pband));
|
||||
$ediBand = EdiBand::whereRaw('LOWER(value) = ?', [$pbandVal])->first();
|
||||
if ($ediBand) {
|
||||
$mappedBandId = $ediBand->bands()->value('bands.id');
|
||||
if (! $mappedBandId) {
|
||||
return null;
|
||||
}
|
||||
if ($round->bands()->count() === 0) {
|
||||
return $mappedBandId;
|
||||
}
|
||||
return $round->bands()->where('bands.id', $mappedBandId)->exists()
|
||||
? $mappedBandId
|
||||
: null;
|
||||
}
|
||||
|
||||
$num = is_numeric($pbandVal) ? (float) $pbandVal : null;
|
||||
if ($num === null && $log->pband) {
|
||||
if (preg_match('/([0-9]+(?:[\\.,][0-9]+)?)/', $log->pband, $m)) {
|
||||
$num = (float) str_replace(',', '.', $m[1]);
|
||||
}
|
||||
}
|
||||
if ($num === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bandMatch = Band::where('edi_band_begin', '<=', $num)
|
||||
->where('edi_band_end', '>=', $num)
|
||||
->first();
|
||||
if (! $bandMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($round->bands()->count() === 0) {
|
||||
return $bandMatch->id;
|
||||
}
|
||||
|
||||
return $round->bands()->where('bands.id', $bandMatch->id)->exists()
|
||||
? $bandMatch->id
|
||||
: null;
|
||||
}
|
||||
|
||||
protected function shouldRecalculateRanks(int $evaluationRunId, bool $statusChanged): bool
|
||||
{
|
||||
$run = EvaluationRun::find($evaluationRunId);
|
||||
if (! $run) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($run->status === 'SUCCEEDED') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ve WAITING_REVIEW_SCORE řešíme jen změny statutu (DQ/IGNORED/OK/CHECK),
|
||||
// aby se pořadí hned přepočítalo bez ručního pokračování pipeline.
|
||||
return $run->status === 'WAITING_REVIEW_SCORE' && $statusChanged;
|
||||
}
|
||||
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'evaluation_run_id' => [$required, 'integer', 'exists:evaluation_runs,id'],
|
||||
'log_id' => [$required, 'integer', 'exists:logs,id'],
|
||||
|
||||
'forced_log_status' => ['sometimes', 'string', 'in:AUTO,OK,CHECK,DQ,IGNORED'],
|
||||
|
||||
'forced_band_id' => ['sometimes', 'nullable', 'integer', 'exists:bands,id'],
|
||||
'forced_category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],
|
||||
'forced_power_category_id' => ['sometimes', 'nullable', 'integer', 'exists:power_categories,id'],
|
||||
'forced_sixhr_category' => ['sometimes', 'nullable', 'boolean'],
|
||||
|
||||
'forced_power_w' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
|
||||
'reason' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'context' => ['sometimes', 'nullable', 'array'],
|
||||
|
||||
'created_by_user_id' => ['sometimes', 'nullable', 'integer', 'exists:users,id'],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function applyOverrideToLogResult(LogOverride $override): void
|
||||
{
|
||||
$data = [];
|
||||
|
||||
if ($override->forced_log_status && $override->forced_log_status !== 'AUTO') {
|
||||
$data['status'] = $override->forced_log_status;
|
||||
if (in_array($override->forced_log_status, ['DQ', 'IGNORED', 'CHECK'], true)) {
|
||||
$data['rank_overall'] = null;
|
||||
$data['rank_in_category'] = null;
|
||||
$data['rank_overall_ok'] = null;
|
||||
$data['rank_in_category_ok'] = null;
|
||||
$data['status_reason'] = null;
|
||||
$data['official_score'] = 0;
|
||||
$data['penalty_score'] = 0;
|
||||
$data['base_score'] = 0;
|
||||
$data['multiplier_count'] = 0;
|
||||
$data['multiplier_score'] = 0;
|
||||
$data['valid_qso_count'] = 0;
|
||||
$data['dupe_qso_count'] = 0;
|
||||
$data['busted_qso_count'] = 0;
|
||||
$data['other_error_qso_count'] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ($override->forced_band_id !== null) {
|
||||
$data['band_id'] = $override->forced_band_id;
|
||||
}
|
||||
|
||||
if ($override->forced_category_id !== null) {
|
||||
$data['category_id'] = $override->forced_category_id;
|
||||
}
|
||||
|
||||
if ($override->forced_power_category_id !== null) {
|
||||
$data['power_category_id'] = $override->forced_power_category_id;
|
||||
}
|
||||
|
||||
if ($override->forced_sixhr_category !== null) {
|
||||
$data['sixhr_category'] = $override->forced_sixhr_category;
|
||||
}
|
||||
|
||||
if (! $data) {
|
||||
$this->resetLogResultToSource($override);
|
||||
return;
|
||||
}
|
||||
|
||||
LogResult::where('evaluation_run_id', $override->evaluation_run_id)
|
||||
->where('log_id', $override->log_id)
|
||||
->update($data);
|
||||
}
|
||||
|
||||
protected function resetLogResultToSource(LogOverride $override): void
|
||||
{
|
||||
$log = $override->log;
|
||||
if (! $log) {
|
||||
return;
|
||||
}
|
||||
$round = Round::with(['bands', 'categories', 'powerCategories'])->find($log->round_id);
|
||||
if (! $round) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bandId = $this->resolveBandId($log, $round);
|
||||
$categoryId = $this->resolveCategoryId($log, $round);
|
||||
$powerCategoryId = $log->power_category_id;
|
||||
|
||||
LogResult::where('evaluation_run_id', $override->evaluation_run_id)
|
||||
->where('log_id', $override->log_id)
|
||||
->update([
|
||||
'status' => 'OK',
|
||||
'status_reason' => null,
|
||||
'band_id' => $bandId,
|
||||
'category_id' => $categoryId,
|
||||
'power_category_id' => $powerCategoryId,
|
||||
'sixhr_category' => $log->sixhr_category,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function mergeOriginalContext(?array $context, int $evaluationRunId, int $logId): array
|
||||
{
|
||||
$context = $context ?? [];
|
||||
if (isset($context['original']) && is_array($context['original'])) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
$context['original'] = $this->snapshotLogResult($evaluationRunId, $logId);
|
||||
return $context;
|
||||
}
|
||||
|
||||
protected function snapshotLogResult(int $evaluationRunId, int $logId): array
|
||||
{
|
||||
$result = LogResult::where('evaluation_run_id', $evaluationRunId)
|
||||
->where('log_id', $logId)
|
||||
->first();
|
||||
if (! $result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $result->status,
|
||||
'band_id' => $result->band_id,
|
||||
'category_id' => $result->category_id,
|
||||
'power_category_id' => $result->power_category_id,
|
||||
'sixhr_category' => $result->sixhr_category,
|
||||
];
|
||||
}
|
||||
}
|
||||
171
app/Http/Controllers/LogQsoController.php
Normal file
171
app/Http/Controllers/LogQsoController.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\LogQso;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class LogQsoController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápisové operace jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam QSO – s filtrováním podle log_id, round_id, band, call_like, dx_call.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$query = LogQso::query()
|
||||
->with('log');
|
||||
|
||||
if ($request->filled('log_id')) {
|
||||
$query->where('log_id', (int) $request->get('log_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('round_id')) {
|
||||
$roundId = (int) $request->get('round_id');
|
||||
$query->whereHas('log', function ($q) use ($roundId) {
|
||||
$q->where('round_id', $roundId);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('band')) {
|
||||
$query->where('band', $request->get('band'));
|
||||
}
|
||||
|
||||
if ($request->filled('call_like')) {
|
||||
$raw = strtoupper((string) $request->get('call_like'));
|
||||
$pattern = str_replace(['*', '?'], ['%', '_'], $raw);
|
||||
if (strpos($pattern, '%') === false && strpos($pattern, '_') === false) {
|
||||
$pattern = '%' . $pattern . '%';
|
||||
}
|
||||
$query->where(function ($q) use ($pattern) {
|
||||
$q->whereRaw('UPPER(my_call) LIKE ?', [$pattern])
|
||||
->orWhereRaw('UPPER(dx_call) LIKE ?', [$pattern]);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('dx_call')) {
|
||||
$query->where('dx_call', $request->get('dx_call'));
|
||||
}
|
||||
|
||||
if ($request->filled('exclude_log_id')) {
|
||||
$query->where('log_id', '!=', (int) $request->get('exclude_log_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('exclude_log_qso_id')) {
|
||||
$query->where('id', '!=', (int) $request->get('exclude_log_qso_id'));
|
||||
}
|
||||
|
||||
$items = $query
|
||||
->orderBy('log_id')
|
||||
->orderBy('qso_index')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření QSO řádku.
|
||||
* Typicky voláno parserem EDI, ne přímo z UI.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', LogQso::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$item = LogQso::create($data);
|
||||
|
||||
$item->load('log');
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho QSO řádku.
|
||||
*/
|
||||
public function show(LogQso $logQso): JsonResponse
|
||||
{
|
||||
$logQso->load('log');
|
||||
|
||||
return response()->json($logQso);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace QSO (partial update).
|
||||
* Praktické pro ruční korekce / debug.
|
||||
*/
|
||||
public function update(Request $request, LogQso $logQso): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $logQso);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$logQso->fill($data);
|
||||
$logQso->save();
|
||||
|
||||
$logQso->load('log');
|
||||
|
||||
return response()->json($logQso);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání QSO.
|
||||
*/
|
||||
public function destroy(LogQso $logQso): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $logQso);
|
||||
|
||||
$logQso->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'log_id' => [$required, 'integer', 'exists:logs,id'],
|
||||
'qso_index' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
|
||||
'time_on' => ['sometimes', 'nullable', 'date'],
|
||||
'band' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'freq_khz' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'mode' => ['sometimes', 'nullable', 'string', 'max:5'],
|
||||
|
||||
'my_call' => ['sometimes', 'nullable', 'string', 'max:20'],
|
||||
'my_rst' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'my_serial' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'my_locator' => ['sometimes', 'nullable', 'string', 'max:6'],
|
||||
|
||||
'dx_call' => ['sometimes', 'nullable', 'string', 'max:20'],
|
||||
'dx_rst' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'dx_serial' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'dx_locator' => ['sometimes', 'nullable', 'string', 'max:6'],
|
||||
|
||||
'points' => ['sometimes', 'nullable', 'integer'],
|
||||
'wwl' => ['sometimes', 'nullable', 'string', 'max:6'],
|
||||
'dxcc' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'is_duplicate'=> ['sometimes', 'boolean'],
|
||||
'is_valid' => ['sometimes', 'boolean'],
|
||||
|
||||
'raw_line' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
239
app/Http/Controllers/LogResultController.php
Normal file
239
app/Http/Controllers/LogResultController.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\LogResult;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\Round;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LogResultController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápisové operace jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam výsledků logů – filtrování podle evaluation_run_id,
|
||||
* log_id, band_id, category_id, status.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
$statusParam = $request->get('status');
|
||||
$isClaimedRequest = $statusParam === 'CLAIMED';
|
||||
|
||||
$query = LogResult::query()
|
||||
->with([
|
||||
'evaluationRun.ruleSet:id,sixhr_ranking_mode',
|
||||
'log',
|
||||
'band:id,name,order',
|
||||
'category:id,name,order',
|
||||
'powerCategory:id,name,order',
|
||||
]);
|
||||
|
||||
if ($request->filled('evaluation_run_id')) {
|
||||
$query->where('evaluation_run_id', (int) $request->get('evaluation_run_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('round_id')) {
|
||||
$roundId = (int) $request->get('round_id');
|
||||
$query->whereHas('log', function ($q) use ($roundId) {
|
||||
$q->where('round_id', $roundId);
|
||||
});
|
||||
if (! $request->filled('evaluation_run_id') && $request->filled('result_type')) {
|
||||
$round = Round::find($roundId);
|
||||
$resultType = strtoupper((string) $request->get('result_type'));
|
||||
$selectedRunId = null;
|
||||
if ($round) {
|
||||
if ($resultType === 'FINAL') {
|
||||
$selectedRunId = $round->official_evaluation_run_id;
|
||||
} elseif ($resultType === 'PRELIMINARY') {
|
||||
$selectedRunId = $round->preliminary_evaluation_run_id;
|
||||
} elseif ($resultType === 'TEST') {
|
||||
$selectedRunId = $round->test_evaluation_run_id;
|
||||
} elseif ($resultType === 'AUTO') {
|
||||
$selectedRunId = $round->official_evaluation_run_id
|
||||
?? $round->preliminary_evaluation_run_id;
|
||||
}
|
||||
}
|
||||
if ($selectedRunId) {
|
||||
$query->where('evaluation_run_id', $selectedRunId);
|
||||
} else {
|
||||
$query->whereRaw('1=0');
|
||||
}
|
||||
}
|
||||
if (! $request->filled('evaluation_run_id') && $isClaimedRequest) {
|
||||
$latestClaimedRunId = EvaluationRun::where('round_id', $roundId)
|
||||
->where('rules_version', 'CLAIMED')
|
||||
->orderByDesc('id')
|
||||
->value('id');
|
||||
if ($latestClaimedRunId) {
|
||||
$query->where('evaluation_run_id', $latestClaimedRunId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('log_id')) {
|
||||
$query->where('log_id', (int) $request->get('log_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('band_id')) {
|
||||
$query->where('band_id', (int) $request->get('band_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$query->where('category_id', (int) $request->get('category_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('status') && ! $isClaimedRequest) {
|
||||
$query->where('status', $statusParam);
|
||||
}
|
||||
|
||||
if ($request->boolean('only_ok', false)) {
|
||||
$pcallExpr = "UPPER(REPLACE(TRIM(pcall), ' ', ''))";
|
||||
$query->whereHas('log', function ($q) use ($pcallExpr) {
|
||||
$q->where(function ($sub) use ($pcallExpr) {
|
||||
$sub->whereRaw("{$pcallExpr} LIKE ?", ['OK%'])
|
||||
->orWhereRaw("{$pcallExpr} LIKE ?", ['OL%'])
|
||||
->orWhereRaw("{$pcallExpr} LIKE ?", ['%/OK%'])
|
||||
->orWhereRaw("{$pcallExpr} LIKE ?", ['%/OL%']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// implicitně řadit podle oficiálního skóre
|
||||
$items = $query
|
||||
->orderByDesc('official_score')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření záznamu výsledku logu.
|
||||
* Typicky voláno vyhodnocovačem, ne přímo z UI.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', LogResult::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$result = LogResult::create($data);
|
||||
|
||||
$result->load([
|
||||
'evaluationRun.ruleSet:id,sixhr_ranking_mode',
|
||||
'log',
|
||||
'band:id,name,order',
|
||||
'category:id,name,order',
|
||||
'powerCategory:id,name,order',
|
||||
]);
|
||||
|
||||
return response()->json($result, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho výsledku.
|
||||
*/
|
||||
public function show(LogResult $logResult): JsonResponse
|
||||
{
|
||||
$logResult->load([
|
||||
'evaluationRun.ruleSet:id,sixhr_ranking_mode',
|
||||
'log',
|
||||
'band:id,name,order',
|
||||
'category:id,name,order',
|
||||
'powerCategory:id,name,order',
|
||||
]);
|
||||
|
||||
return response()->json($logResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace výsledku (partial).
|
||||
* Typicky pro ruční korekci statutu / poznámky.
|
||||
*/
|
||||
public function update(Request $request, LogResult $logResult): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $logResult);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$logResult->fill($data);
|
||||
$logResult->save();
|
||||
|
||||
$logResult->load([
|
||||
'evaluationRun.ruleSet:id,sixhr_ranking_mode',
|
||||
'log',
|
||||
'band:id,name,order',
|
||||
'category:id,name,order',
|
||||
'powerCategory:id,name,order',
|
||||
]);
|
||||
|
||||
return response()->json($logResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání výsledku.
|
||||
*/
|
||||
public function destroy(LogResult $logResult): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $logResult);
|
||||
|
||||
$logResult->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'evaluation_run_id' => [$required, 'integer', 'exists:evaluation_runs,id'],
|
||||
'log_id' => [$required, 'integer', 'exists:logs,id'],
|
||||
|
||||
'band_id' => ['sometimes', 'nullable', 'integer', 'exists:bands,id'],
|
||||
'category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],
|
||||
'power_category_id' => ['sometimes', 'nullable', 'integer', 'exists:power_categories,id'],
|
||||
|
||||
'claimed_qso_count' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'claimed_score' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
|
||||
'valid_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'dupe_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'busted_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'other_error_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'total_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'discarded_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'discarded_points' => ['sometimes', 'integer'],
|
||||
'discarded_qso_percent' => ['sometimes', 'numeric', 'min:0'],
|
||||
'unique_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
|
||||
'official_score' => ['sometimes', 'integer'],
|
||||
'penalty_score' => ['sometimes', 'integer'],
|
||||
'base_score' => ['sometimes', 'integer'],
|
||||
'multiplier_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'multiplier_score' => ['sometimes', 'integer'],
|
||||
'score_per_qso' => ['sometimes', 'numeric', 'min:0'],
|
||||
|
||||
'rank_overall' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'rank_in_category' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
|
||||
'status' => ['sometimes', 'string', 'max:20'],
|
||||
'status_reason' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
39
app/Http/Controllers/LoginController.php
Normal file
39
app/Http/Controllers/LoginController.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function authenticate(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
'remember' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$remember = $request->boolean('remember', false);
|
||||
|
||||
if (Auth::attempt([
|
||||
'email' => $credentials['email'],
|
||||
'password' => $credentials['password'],
|
||||
'is_active' => true,
|
||||
], $remember)) {
|
||||
if ($request->hasSession()) {
|
||||
$request->session()->regenerate();
|
||||
}
|
||||
$user = Auth::user();
|
||||
|
||||
return response()->json($user);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'errors' => [
|
||||
'email' => 'The provided credentials do not match our records.',
|
||||
]
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
309
app/Http/Controllers/NewsPostController.php
Normal file
309
app/Http/Controllers/NewsPostController.php
Normal file
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\NewsPost;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class NewsPostController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápis jen pro přihlášené (admin policy vyřešíš přes Policy)
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* Podporuje volitelný dotazový parametr ?lang=cs / ?lang=en
|
||||
* Pokud je lang zadán, title/content/excerpt budou vráceny jen v daném jazyce.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 10);
|
||||
$limit = (int) $request->get('limit', 0);
|
||||
$includeUnpublished = $request->boolean('include_unpublished', false);
|
||||
|
||||
// volitelný jazyk – pokud není, použije se app locale
|
||||
$lang = $request->query('lang');
|
||||
if (! is_string($lang) || $lang === '') {
|
||||
$lang = app()->getLocale();
|
||||
}
|
||||
|
||||
$query = NewsPost::query()
|
||||
->orderByDesc('published_at');
|
||||
|
||||
if (! $includeUnpublished) {
|
||||
$query->where('is_published', true)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now());
|
||||
}
|
||||
|
||||
if ($limit > 0) {
|
||||
$items = $query->limit($limit)->get();
|
||||
} else {
|
||||
$items = $query->paginate($perPage);
|
||||
}
|
||||
|
||||
$mapTranslation = function (NewsPost $post) use ($lang) {
|
||||
$data = $post->toArray();
|
||||
|
||||
// getTranslation(attr, lang, useFallback=true)
|
||||
$data['title'] = $post->getTranslation('title', $lang, true);
|
||||
$data['content'] = $post->getTranslation('content', $lang, true);
|
||||
$data['excerpt'] = $post->getTranslation('excerpt', $lang, true);
|
||||
|
||||
return $data;
|
||||
};
|
||||
|
||||
if ($limit > 0) {
|
||||
$items = $items->map($mapTranslation);
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
$items->getCollection()->transform($mapTranslation);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail novinky (přes slug).
|
||||
* Public – ale jen pokud je publikovaná, jinak 404.
|
||||
*/
|
||||
public function show(NewsPost $news): JsonResponse
|
||||
{
|
||||
if (
|
||||
! $news->is_published ||
|
||||
! $news->published_at ||
|
||||
$news->published_at->isFuture()
|
||||
) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return response()->json($news);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření novinky (admin).
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', NewsPost::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
if (empty($data['slug'])) {
|
||||
$data['slug'] = $this->makeSlugFromTitle($data['title'] ?? null);
|
||||
}
|
||||
|
||||
if (! empty($data['is_published']) && empty($data['published_at'])) {
|
||||
$data['published_at'] = now();
|
||||
}
|
||||
|
||||
$data['author_id'] = $request->user()?->id;
|
||||
|
||||
$news = NewsPost::create($data);
|
||||
|
||||
return response()->json($news, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace novinky (admin).
|
||||
*/
|
||||
public function update(Request $request, NewsPost $news): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $news);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
// pokud přišla změna title a není explicitně zadaný slug, dopočítej ho
|
||||
if (
|
||||
array_key_exists('title', $data) &&
|
||||
(! array_key_exists('slug', $data) || empty($data['slug']))
|
||||
) {
|
||||
$generated = $this->makeSlugFromTitle($data['title']);
|
||||
if ($generated !== null) {
|
||||
$data['slug'] = $generated;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
array_key_exists('is_published', $data) &&
|
||||
$data['is_published'] &&
|
||||
empty($data['published_at'])
|
||||
) {
|
||||
$data['published_at'] = $news->published_at ?? now();
|
||||
}
|
||||
|
||||
$news->fill($data);
|
||||
$news->save();
|
||||
|
||||
return response()->json($news);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání novinky (admin).
|
||||
*/
|
||||
public function destroy(NewsPost $news): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $news);
|
||||
|
||||
$news->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace dat.
|
||||
*
|
||||
* Podporuje:
|
||||
* - string hodnoty (jednotlivý překlad pro aktuální locale)
|
||||
* - pole překladů: { "cs": "...", "en": "..." }
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
/** @var NewsPost|null $routeNews */
|
||||
$routeNews = $request->route('news'); // může být null, nebo model díky route model bindingu
|
||||
|
||||
$rules = [
|
||||
'title' => [
|
||||
$required,
|
||||
function (string $attribute, $value, \Closure $fail) {
|
||||
if (is_string($value)) {
|
||||
if (mb_strlen($value) > 255) {
|
||||
$fail('The '.$attribute.' may not be greater than 255 characters.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $locale => $text) {
|
||||
if (! is_string($text)) {
|
||||
$fail("The {$attribute}.{$locale} must be a string.");
|
||||
return;
|
||||
}
|
||||
if (mb_strlen($text) > 255) {
|
||||
$fail("The {$attribute}.{$locale} may not be greater than 255 characters.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$fail('The '.$attribute.' must be a string or an array of translated strings.');
|
||||
},
|
||||
],
|
||||
|
||||
'slug' => [
|
||||
'sometimes',
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('news_posts', 'slug')->ignore($routeNews?->getKey()),
|
||||
],
|
||||
|
||||
'content' => [
|
||||
$required,
|
||||
function (string $attribute, $value, \Closure $fail) {
|
||||
if (is_string($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $locale => $text) {
|
||||
if (! is_string($text)) {
|
||||
$fail("The {$attribute}.{$locale} must be a string.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$fail('The '.$attribute.' must be a string or an array of translated strings.');
|
||||
},
|
||||
],
|
||||
|
||||
'excerpt' => [
|
||||
'sometimes',
|
||||
function (string $attribute, $value, \Closure $fail) {
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
if (mb_strlen($value) > 500) {
|
||||
$fail('The '.$attribute.' may not be greater than 500 characters.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $locale => $text) {
|
||||
if (! is_string($text)) {
|
||||
$fail("The {$attribute}.{$locale} must be a string.");
|
||||
return;
|
||||
}
|
||||
if (mb_strlen($text) > 500) {
|
||||
$fail("The {$attribute}.{$locale} may not be greater than 500 characters.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$fail('The '.$attribute.' must be null, a string, or an array of translated strings.');
|
||||
},
|
||||
],
|
||||
|
||||
'is_published' => ['sometimes', 'boolean'],
|
||||
'published_at' => ['sometimes', 'nullable', 'date'],
|
||||
];
|
||||
|
||||
return $request->validate($rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoří slug z titulku – umí pracovat jak se stringem, tak s polem překladů.
|
||||
*
|
||||
* - pokud je $title string → slug z něj
|
||||
* - pokud je $title array → použije se:
|
||||
* title[aktuální_locale] || title['en'] || první dostupná hodnota
|
||||
*/
|
||||
protected function makeSlugFromTitle(string|array|null $title): ?string
|
||||
{
|
||||
if ($title === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($title)) {
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$base = $title[$locale]
|
||||
?? $title['en']
|
||||
?? reset($title);
|
||||
|
||||
if (! is_string($base) || $base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::slug($base);
|
||||
}
|
||||
|
||||
if ($title === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::slug($title);
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/PowerCategoryController.php
Normal file
101
app/Http/Controllers/PowerCategoryController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\PowerCategory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class PowerCategoryController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// store / update / destroy pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam power kategorií.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 50);
|
||||
|
||||
$items = PowerCategory::query()
|
||||
->orderBy('order')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nové power kategorie.
|
||||
* Autorizace přes PowerCategoryPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', PowerCategory::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$item = PowerCategory::create($data);
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail power kategorie.
|
||||
*/
|
||||
public function show(PowerCategory $power_category): JsonResponse
|
||||
{
|
||||
return response()->json($power_category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace power kategorie (partial update).
|
||||
* Autorizace přes PowerCategoryPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, PowerCategory $power_category): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $power_category);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$power_category->fill($data);
|
||||
$power_category->save();
|
||||
|
||||
return response()->json($power_category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání power kategorie.
|
||||
* Autorizace přes PowerCategoryPolicy@delete.
|
||||
*/
|
||||
public function destroy(PowerCategory $power_category): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $power_category);
|
||||
|
||||
$power_category->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace dat pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'name' => [$required, 'string', 'max:255'],
|
||||
'order' => [$required, 'integer'],
|
||||
'power_level' => [$required, 'integer'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
126
app/Http/Controllers/QsoOverrideController.php
Normal file
126
app/Http/Controllers/QsoOverrideController.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\QsoOverride;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class QsoOverrideController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápisové operace jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam override záznamů – lze filtrovat podle evaluation_run_id/log_qso_id.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$query = QsoOverride::query()
|
||||
->with(['evaluationRun', 'logQso', 'forcedMatchedLogQso', 'createdByUser']);
|
||||
|
||||
if ($request->filled('evaluation_run_id')) {
|
||||
$query->where('evaluation_run_id', (int) $request->get('evaluation_run_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('log_qso_id')) {
|
||||
$query->where('log_qso_id', (int) $request->get('log_qso_id'));
|
||||
}
|
||||
|
||||
$items = $query->orderByDesc('id')->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření override záznamu.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', QsoOverride::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
if (! isset($data['created_by_user_id']) && $request->user()) {
|
||||
$data['created_by_user_id'] = $request->user()->id;
|
||||
}
|
||||
|
||||
$item = QsoOverride::create($data);
|
||||
$item->load(['evaluationRun', 'logQso', 'forcedMatchedLogQso', 'createdByUser']);
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail override záznamu.
|
||||
*/
|
||||
public function show(QsoOverride $qsoOverride): JsonResponse
|
||||
{
|
||||
$qsoOverride->load(['evaluationRun', 'logQso', 'forcedMatchedLogQso', 'createdByUser']);
|
||||
|
||||
return response()->json($qsoOverride);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace override záznamu.
|
||||
*/
|
||||
public function update(Request $request, QsoOverride $qsoOverride): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $qsoOverride);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$qsoOverride->fill($data);
|
||||
$qsoOverride->save();
|
||||
|
||||
$qsoOverride->load(['evaluationRun', 'logQso', 'forcedMatchedLogQso', 'createdByUser']);
|
||||
|
||||
return response()->json($qsoOverride);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání override záznamu.
|
||||
*/
|
||||
public function destroy(QsoOverride $qsoOverride): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $qsoOverride);
|
||||
|
||||
$qsoOverride->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'evaluation_run_id' => [$required, 'integer', 'exists:evaluation_runs,id'],
|
||||
'log_qso_id' => [$required, 'integer', 'exists:log_qsos,id'],
|
||||
|
||||
'forced_matched_log_qso_id' => ['sometimes', 'nullable', 'integer', 'exists:log_qsos,id'],
|
||||
'forced_status' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'in:AUTO,VALID,INVALID,NIL,DUPLICATE,BUSTED_CALL,BUSTED_EXCHANGE,OUT_OF_WINDOW',
|
||||
],
|
||||
|
||||
'forced_points' => ['sometimes', 'nullable', 'numeric'],
|
||||
'forced_penalty' => ['sometimes', 'nullable', 'numeric'],
|
||||
|
||||
'reason' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'context' => ['sometimes', 'nullable', 'array'],
|
||||
|
||||
'created_by_user_id' => ['sometimes', 'nullable', 'integer', 'exists:users,id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
272
app/Http/Controllers/QsoResultController.php
Normal file
272
app/Http/Controllers/QsoResultController.php
Normal file
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\QsoResult;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class QsoResultController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápis pouze pro autentizované
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam QSO výsledků.
|
||||
* Filtrování podle evaluation_run_id, log_qso_id, log_id, call_like, matched_qso_id,
|
||||
* error_code, is_valid, is_duplicate, is_nil, only_ok.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 200);
|
||||
|
||||
$evalRunId = $request->filled('evaluation_run_id')
|
||||
? (int) $request->get('evaluation_run_id')
|
||||
: null;
|
||||
|
||||
$query = QsoResult::query()
|
||||
->with(['evaluationRun', 'logQso', 'matchedQso'])
|
||||
->when($evalRunId, function ($q) use ($evalRunId) {
|
||||
$q->with(['workingQso' => function ($wq) use ($evalRunId) {
|
||||
$wq->where('evaluation_run_id', $evalRunId);
|
||||
}]);
|
||||
});
|
||||
|
||||
if ($evalRunId !== null) {
|
||||
$query->where('evaluation_run_id', $evalRunId);
|
||||
}
|
||||
|
||||
if ($request->filled('log_qso_id')) {
|
||||
$query->where('log_qso_id', (int) $request->get('log_qso_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('log_id')) {
|
||||
$logId = (int) $request->get('log_id');
|
||||
$query->whereHas('logQso', function ($q) use ($logId) {
|
||||
$q->where('log_id', $logId);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('call_like')) {
|
||||
$raw = strtoupper((string) $request->get('call_like'));
|
||||
$pattern = str_replace(['*', '?'], ['%', '_'], $raw);
|
||||
if (strpos($pattern, '%') === false && strpos($pattern, '_') === false) {
|
||||
$pattern = '%' . $pattern . '%';
|
||||
}
|
||||
$query->where(function ($q) use ($pattern) {
|
||||
$q->whereHas('logQso', function ($qq) use ($pattern) {
|
||||
$qq->whereRaw('UPPER(my_call) LIKE ?', [$pattern])
|
||||
->orWhereRaw('UPPER(dx_call) LIKE ?', [$pattern]);
|
||||
})->orWhereHas('matchedQso', function ($qq) use ($pattern) {
|
||||
$qq->whereRaw('UPPER(my_call) LIKE ?', [$pattern])
|
||||
->orWhereRaw('UPPER(dx_call) LIKE ?', [$pattern]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('matched_qso_id')) {
|
||||
$query->where('matched_qso_id', (int) $request->get('matched_qso_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('error_code')) {
|
||||
$query->where('error_code', $request->get('error_code'));
|
||||
}
|
||||
|
||||
if ($request->filled('is_valid')) {
|
||||
$query->where(
|
||||
'is_valid',
|
||||
filter_var($request->get('is_valid'), FILTER_VALIDATE_BOOL)
|
||||
);
|
||||
}
|
||||
|
||||
if ($request->filled('is_duplicate')) {
|
||||
$query->where(
|
||||
'is_duplicate',
|
||||
filter_var($request->get('is_duplicate'), FILTER_VALIDATE_BOOL)
|
||||
);
|
||||
}
|
||||
|
||||
if ($request->filled('is_nil')) {
|
||||
$query->where(
|
||||
'is_nil',
|
||||
filter_var($request->get('is_nil'), FILTER_VALIDATE_BOOL)
|
||||
);
|
||||
}
|
||||
|
||||
if ($request->filled('is_time_out_of_window')) {
|
||||
$query->where(
|
||||
'is_time_out_of_window',
|
||||
filter_var($request->get('is_time_out_of_window'), FILTER_VALIDATE_BOOL)
|
||||
);
|
||||
}
|
||||
|
||||
if (filter_var($request->get('only_problems'), FILTER_VALIDATE_BOOL)) {
|
||||
$query->where(function ($q) {
|
||||
$q->where(function ($qq) {
|
||||
$qq->whereNotNull('error_code')
|
||||
->where('error_code', '!=', 'OK');
|
||||
})
|
||||
->orWhere('is_nil', true)
|
||||
->orWhere('is_duplicate', true)
|
||||
->orWhere('is_busted_call', true)
|
||||
->orWhere('is_busted_exchange', true)
|
||||
->orWhere('is_time_out_of_window', true);
|
||||
});
|
||||
}
|
||||
|
||||
if (filter_var($request->get('only_ok'), FILTER_VALIDATE_BOOL)) {
|
||||
$query->where(function ($q) {
|
||||
$q->whereNull('error_code')
|
||||
->orWhere('error_code', 'OK');
|
||||
})
|
||||
->where('is_nil', false)
|
||||
->where('is_duplicate', false)
|
||||
->where('is_busted_call', false)
|
||||
->where('is_busted_exchange', false)
|
||||
->where('is_time_out_of_window', false);
|
||||
}
|
||||
|
||||
if (filter_var($request->get('missing_locator'), FILTER_VALIDATE_BOOL)) {
|
||||
$query->whereHas('workingQso', function ($q) use ($evalRunId) {
|
||||
if ($evalRunId !== null) {
|
||||
$q->where('evaluation_run_id', $evalRunId);
|
||||
}
|
||||
$q->whereNull('loc_norm')
|
||||
->orWhereNull('rloc_norm')
|
||||
->orWhereJsonContains('errors', 'INVALID_LOCATOR')
|
||||
->orWhereJsonContains('errors', 'INVALID_RLOCATOR');
|
||||
});
|
||||
}
|
||||
|
||||
$items = $query
|
||||
->orderBy('evaluation_run_id')
|
||||
->orderBy('log_qso_id')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření QSO výsledku.
|
||||
* Typicky voláno vyhodnocovačem, ne přímo z UI.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', QsoResult::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$result = QsoResult::create($data);
|
||||
|
||||
$result->load([
|
||||
'evaluationRun',
|
||||
'logQso',
|
||||
'matchedQso',
|
||||
'workingQso' => function ($q) use ($result) {
|
||||
$q->where('evaluation_run_id', $result->evaluation_run_id);
|
||||
},
|
||||
]);
|
||||
|
||||
return response()->json($result, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho QSO výsledku.
|
||||
*/
|
||||
public function show(QsoResult $qsoResult): JsonResponse
|
||||
{
|
||||
$qsoResult->load([
|
||||
'evaluationRun',
|
||||
'logQso',
|
||||
'matchedQso',
|
||||
'workingQso' => function ($q) use ($qsoResult) {
|
||||
$q->where('evaluation_run_id', $qsoResult->evaluation_run_id);
|
||||
},
|
||||
]);
|
||||
|
||||
return response()->json($qsoResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace QSO výsledku (partial update).
|
||||
* Praktické pro ruční korekce / override.
|
||||
*/
|
||||
public function update(Request $request, QsoResult $qsoResult): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $qsoResult);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$qsoResult->fill($data);
|
||||
$qsoResult->save();
|
||||
|
||||
$qsoResult->load([
|
||||
'evaluationRun',
|
||||
'logQso',
|
||||
'matchedQso',
|
||||
'workingQso' => function ($q) use ($qsoResult) {
|
||||
$q->where('evaluation_run_id', $qsoResult->evaluation_run_id);
|
||||
},
|
||||
]);
|
||||
|
||||
return response()->json($qsoResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání QSO výsledku.
|
||||
*/
|
||||
public function destroy(QsoResult $qsoResult): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $qsoResult);
|
||||
|
||||
$qsoResult->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace vstupu pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'evaluation_run_id' => [$required, 'integer', 'exists:evaluation_runs,id'],
|
||||
'log_qso_id' => [$required, 'integer', 'exists:log_qsos,id'],
|
||||
|
||||
'is_valid' => ['sometimes', 'boolean'],
|
||||
'is_duplicate' => ['sometimes', 'boolean'],
|
||||
'is_nil' => ['sometimes', 'boolean'],
|
||||
'is_busted_call' => ['sometimes', 'boolean'],
|
||||
'is_busted_rst' => ['sometimes', 'boolean'],
|
||||
'is_busted_exchange' => ['sometimes', 'boolean'],
|
||||
'is_time_out_of_window' => ['sometimes', 'boolean'],
|
||||
|
||||
'points' => ['sometimes', 'integer'],
|
||||
'penalty_points' => ['sometimes', 'integer'],
|
||||
'distance_km' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
|
||||
'wwl' => ['sometimes', 'nullable', 'string', 'max:6'],
|
||||
'dxcc' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'country' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'section' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
|
||||
'matched_qso_id' => ['sometimes', 'nullable', 'integer', 'exists:log_qsos,id'],
|
||||
'matched_log_qso_id' => ['sometimes', 'nullable', 'integer', 'exists:log_qsos,id'],
|
||||
'match_confidence' => ['sometimes', 'nullable', 'string', 'max:20'],
|
||||
|
||||
'error_code' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
'error_side' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'error_detail' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
392
app/Http/Controllers/RoundController.php
Normal file
392
app/Http/Controllers/RoundController.php
Normal file
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Round;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use App\Jobs\RebuildClaimedLogResultsJob;
|
||||
use App\Services\Evaluation\ClaimedRunResolver;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Jobs\StartEvaluationRunJob;
|
||||
use App\Models\Contest;
|
||||
use App\Models\LogOverride;
|
||||
use App\Models\QsoOverride;
|
||||
|
||||
class RoundController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
public function __construct()
|
||||
{
|
||||
// zápisové operace jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam kol (rounds) – stránkovaně.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
$contestId = $request->query('contest_id');
|
||||
$onlyActive = (bool) $request->query('only_active', false);
|
||||
$includeTests = filter_var($request->query('include_tests', true), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if ($includeTests === null) {
|
||||
$includeTests = true;
|
||||
}
|
||||
|
||||
$items = Round::query()
|
||||
->with([
|
||||
'contest',
|
||||
'bands',
|
||||
'categories',
|
||||
'powerCategories',
|
||||
'ruleSet',
|
||||
])
|
||||
->when($contestId, fn ($q) => $q->where('contest_id', $contestId))
|
||||
->when($onlyActive, fn ($q) => $q->where('is_active', true))
|
||||
->when(! $includeTests, fn ($q) => $q->where('is_test', false))
|
||||
->orderByDesc('start_time')
|
||||
->orderByDesc('end_time')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nového kola.
|
||||
* Autorizace přes RoundPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Round::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
if (! array_key_exists('rule_set_id', $data) || $data['rule_set_id'] === null) {
|
||||
$contestRuleSetId = Contest::where('id', $data['contest_id'])->value('rule_set_id');
|
||||
$data['rule_set_id'] = $contestRuleSetId;
|
||||
}
|
||||
|
||||
$round = Round::create($data);
|
||||
|
||||
$this->syncRelations($round, $relations);
|
||||
|
||||
$round->load([
|
||||
'contest',
|
||||
'bands',
|
||||
'categories',
|
||||
'powerCategories',
|
||||
'ruleSet',
|
||||
]);
|
||||
|
||||
return response()->json($round, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail kola.
|
||||
*/
|
||||
public function show(Round $round): JsonResponse
|
||||
{
|
||||
$round->load([
|
||||
'contest',
|
||||
'bands',
|
||||
'categories',
|
||||
'powerCategories',
|
||||
'ruleSet',
|
||||
]);
|
||||
|
||||
return response()->json($round);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace kola (partial update).
|
||||
* Autorizace přes RoundPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, Round $round): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $round);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
$round->fill($data);
|
||||
$round->save();
|
||||
|
||||
$this->syncRelations($round, $relations);
|
||||
|
||||
$round->load([
|
||||
'contest',
|
||||
'bands',
|
||||
'categories',
|
||||
'powerCategories',
|
||||
'ruleSet',
|
||||
]);
|
||||
|
||||
return response()->json($round);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání kola.
|
||||
* Autorizace přes RoundPolicy@delete.
|
||||
*/
|
||||
public function destroy(Round $round): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $round);
|
||||
|
||||
$round->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ručně spustí rebuild deklarovaných výsledků pro kolo.
|
||||
*/
|
||||
public function recalculateClaimed(Request $request, Round $round): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $round);
|
||||
|
||||
$run = ClaimedRunResolver::createNewForRound($round->id, auth()->id());
|
||||
RebuildClaimedLogResultsJob::dispatch($run->id)->onQueue('evaluation');
|
||||
|
||||
return response()->json([
|
||||
'status' => 'queued',
|
||||
'message' => 'Přepočet deklarovaných výsledků byl spuštěn.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spustí kompletní vyhodnocovací pipeline pro nové EvaluationRun.
|
||||
*/
|
||||
public function startEvaluation(Request $request, Round $round): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $round);
|
||||
|
||||
$data = $request->validate([
|
||||
'rule_set_id' => ['sometimes', 'nullable', 'integer', 'exists:evaluation_rule_sets,id'],
|
||||
'rules_version' => ['sometimes', 'nullable', 'string', 'in:OFFICIAL,TEST,CLAIMED'],
|
||||
'result_type' => ['sometimes', 'nullable', 'string', 'in:PRELIMINARY,FINAL,TEST'],
|
||||
'name' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'is_official' => ['sometimes', 'boolean'],
|
||||
'scope' => ['sometimes', 'array'],
|
||||
'scope.band_ids' => ['sometimes', 'array'],
|
||||
'scope.band_ids.*' => ['integer', 'exists:bands,id'],
|
||||
'scope.category_ids' => ['sometimes', 'array'],
|
||||
'scope.category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
'scope.power_category_ids' => ['sometimes', 'array'],
|
||||
'scope.power_category_ids.*' => ['integer', 'exists:power_categories,id'],
|
||||
]);
|
||||
|
||||
$rulesVersion = $data['rules_version'] ?? 'OFFICIAL';
|
||||
$resultType = $data['result_type']
|
||||
?? ($rulesVersion === 'TEST' ? 'TEST' : 'PRELIMINARY');
|
||||
|
||||
$run = EvaluationRun::create([
|
||||
'round_id' => $round->id,
|
||||
'rule_set_id' => $data['rule_set_id'] ?? $round->rule_set_id,
|
||||
'rules_version' => $rulesVersion,
|
||||
'result_type' => $rulesVersion === 'CLAIMED' ? null : $resultType,
|
||||
'name' => $data['name'] ?? 'Vyhodnocení',
|
||||
'is_official' => $data['is_official'] ?? ($resultType === 'FINAL'),
|
||||
'scope' => $data['scope'] ?? null,
|
||||
'status' => 'PENDING',
|
||||
'created_by_user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
StartEvaluationRunJob::dispatch($run->id)->onQueue('evaluation');
|
||||
|
||||
$run->load(['round']);
|
||||
|
||||
return response()->json($run, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spustí nový EvaluationRun jako re-run s převzetím override z posledního běhu.
|
||||
*/
|
||||
public function startEvaluationIncremental(Request $request, Round $round): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $round);
|
||||
|
||||
$data = $request->validate([
|
||||
'source_run_id' => ['sometimes', 'nullable', 'integer', 'exists:evaluation_runs,id'],
|
||||
'rule_set_id' => ['sometimes', 'nullable', 'integer', 'exists:evaluation_rule_sets,id'],
|
||||
'rules_version' => ['sometimes', 'nullable', 'string', 'in:OFFICIAL,TEST'],
|
||||
'result_type' => ['sometimes', 'nullable', 'string', 'in:PRELIMINARY,FINAL,TEST'],
|
||||
'name' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'scope' => ['sometimes', 'array'],
|
||||
'scope.band_ids' => ['sometimes', 'array'],
|
||||
'scope.band_ids.*' => ['integer', 'exists:bands,id'],
|
||||
'scope.category_ids' => ['sometimes', 'array'],
|
||||
'scope.category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
'scope.power_category_ids' => ['sometimes', 'array'],
|
||||
'scope.power_category_ids.*' => ['integer', 'exists:power_categories,id'],
|
||||
]);
|
||||
|
||||
$sourceRun = null;
|
||||
if (! empty($data['source_run_id'])) {
|
||||
$sourceRun = EvaluationRun::where('round_id', $round->id)
|
||||
->where('id', (int) $data['source_run_id'])
|
||||
->first();
|
||||
}
|
||||
if (! $sourceRun) {
|
||||
$sourceRun = EvaluationRun::where('round_id', $round->id)
|
||||
->where('rules_version', '!=', 'CLAIMED')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
$rulesVersion = $data['rules_version']
|
||||
?? ($sourceRun?->rules_version ?? 'OFFICIAL');
|
||||
$resultType = $data['result_type']
|
||||
?? ($rulesVersion === 'TEST' ? 'TEST' : 'PRELIMINARY');
|
||||
|
||||
$run = EvaluationRun::create([
|
||||
'round_id' => $round->id,
|
||||
'rule_set_id' => $data['rule_set_id'] ?? ($sourceRun?->rule_set_id ?? $round->rule_set_id),
|
||||
'rules_version' => $rulesVersion,
|
||||
'result_type' => $resultType,
|
||||
'name' => $data['name'] ?? 'Vyhodnocení (re-run)',
|
||||
'is_official' => $resultType === 'FINAL',
|
||||
'scope' => $data['scope'] ?? ($sourceRun?->scope ?? null),
|
||||
'status' => 'PENDING',
|
||||
'created_by_user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
if ($sourceRun) {
|
||||
$this->cloneOverrides($sourceRun->id, $run->id, auth()->id());
|
||||
}
|
||||
|
||||
StartEvaluationRunJob::dispatch($run->id)->onQueue('evaluation');
|
||||
|
||||
$run->load(['round']);
|
||||
|
||||
return response()->json($run, 201);
|
||||
}
|
||||
|
||||
protected function cloneOverrides(int $sourceRunId, int $targetRunId, ?int $userId = null): void
|
||||
{
|
||||
$logOverrides = LogOverride::where('evaluation_run_id', $sourceRunId)->get();
|
||||
if ($logOverrides->isNotEmpty()) {
|
||||
$rows = $logOverrides->map(function ($override) use ($targetRunId, $userId) {
|
||||
return [
|
||||
'evaluation_run_id' => $targetRunId,
|
||||
'log_id' => $override->log_id,
|
||||
'forced_log_status' => $override->forced_log_status,
|
||||
'forced_band_id' => $override->forced_band_id,
|
||||
'forced_category_id' => $override->forced_category_id,
|
||||
'forced_power_category_id' => $override->forced_power_category_id,
|
||||
'forced_sixhr_category' => $override->forced_sixhr_category,
|
||||
'forced_power_w' => $override->forced_power_w,
|
||||
'reason' => $override->reason,
|
||||
'context' => $this->encodeContext($override->context),
|
||||
'created_by_user_id' => $override->created_by_user_id ?? $userId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
})->all();
|
||||
LogOverride::insert($rows);
|
||||
}
|
||||
|
||||
$qsoOverrides = QsoOverride::where('evaluation_run_id', $sourceRunId)->get();
|
||||
if ($qsoOverrides->isNotEmpty()) {
|
||||
$rows = $qsoOverrides->map(function ($override) use ($targetRunId, $userId) {
|
||||
return [
|
||||
'evaluation_run_id' => $targetRunId,
|
||||
'log_qso_id' => $override->log_qso_id,
|
||||
'forced_matched_log_qso_id' => $override->forced_matched_log_qso_id,
|
||||
'forced_status' => $override->forced_status,
|
||||
'forced_points' => $override->forced_points,
|
||||
'forced_penalty' => $override->forced_penalty,
|
||||
'reason' => $override->reason,
|
||||
'context' => $this->encodeContext($override->context),
|
||||
'created_by_user_id' => $override->created_by_user_id ?? $userId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
})->all();
|
||||
QsoOverride::insert($rows);
|
||||
}
|
||||
}
|
||||
|
||||
protected function encodeContext(mixed $context): ?string
|
||||
{
|
||||
if ($context === null) {
|
||||
return null;
|
||||
}
|
||||
$encoded = json_encode($context);
|
||||
return $encoded === false ? null : $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace vstupu pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'contest_id' => [$required, 'integer', 'exists:contests,id'],
|
||||
|
||||
// name/description – pokud používáš překlady jako u Contest:
|
||||
'name' => [$required, 'array'],
|
||||
'name.*' => ['string', 'max:255'],
|
||||
'description' => ['sometimes', 'nullable', 'array'],
|
||||
'description.*' => ['string'],
|
||||
|
||||
'start_time' => [$required, 'date'],
|
||||
'end_time' => [$required, 'date', 'after:start_time'],
|
||||
'logs_deadline' => [$required, 'date'],
|
||||
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'is_test' => ['sometimes', 'boolean'],
|
||||
'is_sixhr' => ['sometimes', 'boolean'],
|
||||
|
||||
'first_check' => ['sometimes', 'nullable', 'date'],
|
||||
'second_check' => ['sometimes', 'nullable', 'date'],
|
||||
'unique_qso_check' => ['sometimes', 'nullable', 'date'],
|
||||
'third_check' => ['sometimes', 'nullable', 'date'],
|
||||
'fourth_check' => ['sometimes', 'nullable', 'date'],
|
||||
'prelimitary_results'=> ['sometimes', 'nullable', 'date'],
|
||||
'rule_set_id' => ['sometimes', 'nullable', 'integer', 'exists:evaluation_rule_sets,id'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace ID navázaných entit pro belongsToMany vztahy.
|
||||
*/
|
||||
protected function validateRelations(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'band_ids' => ['sometimes', 'array'],
|
||||
'band_ids.*' => ['integer', 'exists:bands,id'],
|
||||
|
||||
'category_ids' => ['sometimes', 'array'],
|
||||
'category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
|
||||
'power_category_ids' => ['sometimes', 'array'],
|
||||
'power_category_ids.*' => ['integer', 'exists:power_categories,id'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync vazeb pro belongsToMany vztahy.
|
||||
*/
|
||||
protected function syncRelations(Round $round, array $relations): void
|
||||
{
|
||||
if (array_key_exists('band_ids', $relations)) {
|
||||
$round->bands()->sync($relations['band_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('category_ids', $relations)) {
|
||||
$round->categories()->sync($relations['category_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('power_category_ids', $relations)) {
|
||||
$round->powerCategories()->sync($relations['power_category_ids']);
|
||||
}
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/UserController.php
Normal file
114
app/Http/Controllers/UserController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UserController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth:sanctum');
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', User::class);
|
||||
|
||||
$perPage = (int) $request->get('per_page', 20);
|
||||
$query = trim((string) $request->get('query', ''));
|
||||
|
||||
$users = User::query()
|
||||
->when($query !== '', function ($q) use ($query) {
|
||||
$q->where('name', 'like', '%' . $query . '%')
|
||||
->orWhere('email', 'like', '%' . $query . '%');
|
||||
})
|
||||
->orderBy('name')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($users);
|
||||
}
|
||||
|
||||
public function show(User $user): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $user);
|
||||
|
||||
return response()->json($user);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', User::class);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'string', 'min:8'],
|
||||
'is_admin' => ['sometimes', 'boolean'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
'is_admin' => (bool) ($data['is_admin'] ?? false),
|
||||
'is_active' => (bool) ($data['is_active'] ?? true),
|
||||
]);
|
||||
|
||||
return response()->json($user, 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $user);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users', 'email')->ignore($user->id),
|
||||
],
|
||||
'password' => ['nullable', 'string', 'min:8'],
|
||||
'is_admin' => ['sometimes', 'boolean'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'is_admin' => (bool) ($data['is_admin'] ?? $user->is_admin),
|
||||
'is_active' => (bool) ($data['is_active'] ?? $user->is_active),
|
||||
];
|
||||
if (! empty($data['password'])) {
|
||||
$payload['password'] = $data['password'];
|
||||
}
|
||||
|
||||
$user->update($payload);
|
||||
|
||||
return response()->json($user);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $user);
|
||||
|
||||
if ($request->user()?->id === $user->id) {
|
||||
return response()->json(['message' => 'Nelze deaktivovat vlastního uživatele.'], 422);
|
||||
}
|
||||
|
||||
$user->update(['is_active' => false]);
|
||||
|
||||
return response()->json($user);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user