Initial commit

This commit is contained in:
Zdeněk Burda
2026-01-09 21:26:40 +01:00
parent e83aec6dca
commit 41e3ce6f25
404 changed files with 61250 additions and 28 deletions

View 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'],
]);
}
}

View 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'],
]);
}
}

View 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']);
}
}
}

View 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'],
]);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View 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'],
]);
}
}

View 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'],
]);
}
}

View 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);
}
}

View 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'],
]);
}
}

View 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;
}
}

View 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'],
]);
}
}

View 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';
}
}

View 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'],
]);
}
}

View 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,
];
}
}

View 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'],
]);
}
}

View 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'],
]);
}
}

View 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);
}
}

View 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);
}
}

View 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'],
]);
}
}

View 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'],
]);
}
}

View 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'],
]);
}
}

View 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']);
}
}
}

View 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);
}
}