diff --git a/app/Http/Controllers/LogController.php b/app/Http/Controllers/LogController.php index cd32cb9..2daf6c3 100644 --- a/app/Http/Controllers/LogController.php +++ b/app/Http/Controllers/LogController.php @@ -7,6 +7,7 @@ use App\Models\LogQso; use App\Models\EvaluationRun; use App\Models\QsoOverride; use App\Models\QsoResult; +use App\Models\Round; use Illuminate\Support\Facades\Storage; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; @@ -86,6 +87,8 @@ class LogController extends BaseController */ public function show(Request $request, Log $log): JsonResponse { + $this->authorize('view', $log); + $includeQsos = $request->boolean('include_qsos', false); $relations = ['round', 'file']; if ($includeQsos) { @@ -96,11 +99,54 @@ class LogController extends BaseController return response()->json($log); } + /** + * Veřejný detail logu – pouze ne-citlivá hlavička + deklarované výsledky. + */ + public function publicShow(Request $request, Log $log): JsonResponse + { + $log->load(['round']); + + if (! $this->hasOfficialResultsPublished($log->round)) { + return response()->json([ + 'message' => 'Detail logu bude dostupný po zveřejnění finálních výsledků.', + ], 403); + } + + return response()->json([ + 'id' => $log->id, + 'round_id' => $log->round_id, + 'tname' => $log->tname, + 'tdate' => $log->tdate, + 'pcall' => $log->pcall, + 'pband' => $log->pband, + 'psect' => $log->psect, + 'power_watt' => $log->power_watt, + 'sante' => $log->sante, + 'santh' => $log->santh, + 'stxeq' => $log->stxeq, + 'srxeq' => $log->srxeq, + 'claimed_qso_count' => $log->claimed_qso_count, + 'claimed_score' => $log->claimed_score, + 'claimed_wwl' => $log->claimed_wwl, + 'claimed_dxcc' => $log->claimed_dxcc, + 'round' => $log->round ? [ + 'id' => $log->round->id, + 'contest_id' => $log->round->contest_id, + 'name' => $log->round->name, + 'start_time' => $log->round->start_time, + 'end_time' => $log->round->end_time, + 'logs_deadline' => $log->round->logs_deadline, + ] : null, + ]); + } + /** * QSO tabulka pro log: raw QSO + výsledky vyhodnocení + případné overrides. */ public function qsoTable(Request $request, Log $log): JsonResponse { + $this->authorize('view', $log); + $evalRunId = $request->filled('evaluation_run_id') ? (int) $request->get('evaluation_run_id') : null; @@ -341,4 +387,17 @@ class LogController extends BaseController 'raw_header' => ['sometimes', 'nullable', 'string'], ]); } + + private function hasOfficialResultsPublished(?Round $round): bool + { + if (! $round || ! $round->official_evaluation_run_id) { + return false; + } + + return EvaluationRun::query() + ->where('id', $round->official_evaluation_run_id) + ->where('status', 'SUCCEEDED') + ->where('result_type', 'FINAL') + ->exists(); + } } diff --git a/app/Policies/LogPolicy.php b/app/Policies/LogPolicy.php index bfa11eb..2728c6b 100644 --- a/app/Policies/LogPolicy.php +++ b/app/Policies/LogPolicy.php @@ -8,6 +8,16 @@ use Illuminate\Auth\Access\Response; class LogPolicy { + public function viewAny(User $user): bool + { + return (bool) $user->is_admin; + } + + public function view(User $user, Log $log): bool + { + return (bool) $user->is_admin; + } + public function create(User $user): bool { return (bool) $user->is_admin; diff --git a/resources/js/components/LogDetail.tsx b/resources/js/components/LogDetail.tsx index ef2bd07..e462149 100644 --- a/resources/js/components/LogDetail.tsx +++ b/resources/js/components/LogDetail.tsx @@ -11,6 +11,7 @@ import { import { useTranslation } from "react-i18next"; import { useContestStore } from "@/stores/contestStore"; import { useLanguageStore } from "@/stores/languageStore"; +import { useUserStore } from "@/stores/userStore"; import LogQsoTable from "@/components/LogQsoTable"; type LogDetailData = { @@ -190,6 +191,8 @@ export default function LogDetail({ logId }: LogDetailProps) { const { t } = useTranslation("common"); const locale = useLanguageStore((s) => s.locale) || navigator.language || "cs-CZ"; const setSelectedRound = useContestStore((s) => s.setSelectedRound); + const user = useUserStore((s) => s.user); + const isAdmin = !!user?.is_admin; const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); @@ -209,8 +212,9 @@ export default function LogDetail({ logId }: LogDetailProps) { try { setLoading(true); setError(null); - const res = await axios.get(`/api/logs/${logId}`, { - params: { include_qsos: 0 }, + const endpoint = isAdmin ? `/api/logs/${logId}` : `/api/logs/${logId}/public`; + const res = await axios.get(endpoint, { + params: isAdmin ? { include_qsos: 0 } : undefined, headers: { Accept: "application/json" }, withCredentials: true, }); @@ -230,9 +234,13 @@ export default function LogDetail({ logId }: LogDetailProps) { logs_deadline: res.data.round.logs_deadline ?? null, }); } - } catch { + } catch (e: any) { if (!active) return; - setError(t("unable_to_load_log") ?? "Nepodařilo se načíst log."); + const fallback = isAdmin + ? t("unable_to_load_log") ?? "Nepodařilo se načíst log." + : "Detail logu bude dostupný po zveřejnění finálních výsledků."; + const message = e?.response?.data?.message || fallback; + setError(message); } finally { if (active) setLoading(false); } @@ -240,10 +248,10 @@ export default function LogDetail({ logId }: LogDetailProps) { return () => { active = false; }; - }, [logId, t, setSelectedRound]); + }, [logId, t, setSelectedRound, isAdmin]); useEffect(() => { - if (!detail?.id) return; + if (!detail?.id || !isAdmin) return; let active = true; (async () => { @@ -319,10 +327,13 @@ export default function LogDetail({ logId }: LogDetailProps) { return () => { active = false; }; - }, [detail?.id]); + }, [detail?.id, isAdmin]); const title = (() => { const pcall = detail?.pcall ?? ""; + if (!isAdmin) { + return pcall || (t("log") ?? "Log"); + } const rcall = detail?.rcall ?? ""; if (pcall && rcall && pcall !== rcall) { return `${pcall}-${rcall}`; @@ -368,134 +379,190 @@ export default function LogDetail({ logId }: LogDetailProps) { {loading &&
{t("loading") ?? "Načítám..."}
} {detail && !loading && (
-
-
-
- - PCall: - - - {detail.pcall || "—"} - {detail.pclub ? ` (${detail.pclub})` : ""} - -
- {detail.pband && ( + {isAdmin ? ( +
+
- PBand: - {detail.pband} -
- )} - {detail.psect && ( -
- PSect: - {detail.psect} -
- )} - {detail.padr1 && ( -
- PAdr1: - {detail.padr1} -
- )} - {detail.padr2 && ( -
- PAdr2: - {detail.padr2} -
- )} - {detail.mope1 && ( -
- MOpe1: - {detail.mope1} -
- )} - {detail.mope2 && ( -
- MOpe2: - {detail.mope2} -
- )} -
- RCall: - {detail.rcall || "—"} -
- {detail.radr1 && ( -
- RAdr1: - {detail.radr1} -
- )} - {detail.radr2 && ( -
- RAdr2: - {detail.radr2} -
- )} - {(detail.rpoco || detail.rcity) && ( -
- RPoCo/RCity: + + PCall: + - {detail.rpoco ?? ""} {detail.rcity ?? ""} + {detail.pcall || "—"} + {detail.pclub ? ` (${detail.pclub})` : ""}
- )} - {detail.rcoun && ( + {detail.pband && ( +
+ PBand: + {detail.pband} +
+ )} + {detail.psect && ( +
+ PSect: + {detail.psect} +
+ )} + {detail.padr1 && ( +
+ PAdr1: + {detail.padr1} +
+ )} + {detail.padr2 && ( +
+ PAdr2: + {detail.padr2} +
+ )} + {detail.mope1 && ( +
+ MOpe1: + {detail.mope1} +
+ )} + {detail.mope2 && ( +
+ MOpe2: + {detail.mope2} +
+ )}
- RCoun: - {detail.rcoun} + RCall: + {detail.rcall || "—"}
- )} - {detail.rphon && ( -
- RPhon: - {detail.rphon} -
- )} - {detail.rhbbs && ( -
- RHBBS: - {detail.rhbbs} -
- )} -
+ {detail.radr1 && ( +
+ RAdr1: + {detail.radr1} +
+ )} + {detail.radr2 && ( +
+ RAdr2: + {detail.radr2} +
+ )} + {(detail.rpoco || detail.rcity) && ( +
+ RPoCo/RCity: + + {detail.rpoco ?? ""} {detail.rcity ?? ""} + +
+ )} + {detail.rcoun && ( +
+ RCoun: + {detail.rcoun} +
+ )} + {detail.rphon && ( +
+ RPhon: + {detail.rphon} +
+ )} + {detail.rhbbs && ( +
+ RHBBS: + {detail.rhbbs} +
+ )} +
-
- {detail.stxeq && ( -
- STXEq: - {detail.stxeq} -
- )} - {detail.srxeq && ( -
- SRXEq: - {detail.srxeq} -
- )} - {detail.power_watt && ( -
- SPowe: - {detail.power_watt} +
+ {detail.stxeq && ( +
+ STXEq: + {detail.stxeq} +
+ )} + {detail.srxeq && ( +
+ SRXEq: + {detail.srxeq} +
+ )} + {detail.power_watt && ( +
+ SPowe: + {detail.power_watt} +
+ )} + {detail.sante && ( +
+ SAntenna: + {detail.sante} +
+ )} + {detail.santh && ( +
+ SAntH: + {detail.santh} +
+ )}
- )} - {detail.sante && ( -
- SAntenna: - {detail.sante} -
- )} - {detail.santh && ( -
- SAntH: - {detail.santh} -
- )}
-
- + ) : ( +
+
+
+ + PCall: + + {detail.pcall || "—"} +
+ {detail.pband && ( +
+ PBand: + {detail.pband} +
+ )} + {detail.psect && ( +
+ PSect: + {detail.psect} +
+ )} + {detail.power_watt && ( +
+ SPowe: + {detail.power_watt} +
+ )} +
+
+ {detail.stxeq && ( +
+ STXEq: + {detail.stxeq} +
+ )} + {detail.srxeq && ( +
+ SRXEq: + {detail.srxeq} +
+ )} + {detail.sante && ( +
+ SAntenna: + {detail.sante} +
+ )} + {detail.santh && ( +
+ SAntH: + {detail.santh} +
+ )} +
+
+ )}
-
+

Deklarované výsledky

@@ -515,43 +582,45 @@ export default function LogDetail({ logId }: LogDetailProps) { {detail.claimed_dxcc ?? "—"}
-
-

Zkontrolované výsledky

-
- Počet QSO: - - {officialLoading ? "…" : officialResult?.valid_qso_count ?? "—"} - + {isAdmin && ( +
+

Zkontrolované výsledky

+
+ Počet QSO: + + {officialLoading ? "…" : officialResult?.valid_qso_count ?? "—"} + +
+
+ Body: + + {officialLoading ? "…" : officialResult?.official_score ?? "—"} + +
+
+ Unikátních WWL: + + {officialLoading ? "…" : officialResult?.multiplier_count ?? "—"} + +
+
+ Penalizace: + + {officialLoading ? "…" : officialResult?.penalty_score ?? "—"} + +
+ {officialError && ( +
{officialError}
+ )}
-
- Body: - - {officialLoading ? "…" : officialResult?.official_score ?? "—"} - -
-
- Unikátních WWL: - - {officialLoading ? "…" : officialResult?.multiplier_count ?? "—"} - -
-
- Penalizace: - - {officialLoading ? "…" : officialResult?.penalty_score ?? "—"} - -
- {officialError && ( -
{officialError}
- )} -
+ )}
- {detail.remarks_eval && ( + {isAdmin && detail.remarks_eval && (
{renderRemarksEval(detail.remarks_eval)}
)} - {detail.raw_header && ( + {isAdmin && detail.raw_header && ( - {(logOverrideReason || Object.keys(qsoOverrides).length > 0) && ( + {isAdmin && (logOverrideReason || Object.keys(qsoOverrides).length > 0) && ( Zásahy rozhodčího @@ -598,24 +667,26 @@ export default function LogDetail({ logId }: LogDetailProps) { )} - - - QSO - - - - - - + {isAdmin && ( + + + QSO + + + + + + + )}
); } diff --git a/resources/js/components/LogsTable.tsx b/resources/js/components/LogsTable.tsx index 833d40e..916e259 100644 --- a/resources/js/components/LogsTable.tsx +++ b/resources/js/components/LogsTable.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate, useLocation } from "react-router-dom"; import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, Pagination } from "@heroui/react"; import { useUserStore } from "@/stores/userStore"; +import { useContestStore } from "@/stores/contestStore"; type LogItem = { id: number; @@ -14,13 +15,11 @@ type LogItem = { tdate?: string | null; pcall?: string | null; rcall?: string | null; // pokud backend vrátí, jinak zobrazíme prázdně - pwwlo?: string | null; psect?: string | null; pband?: string | null; power_watt?: number | null; claimed_qso_count?: number | null; claimed_score?: number | null; - remarks_eval?: string | null; file_id?: number | null; file?: { id: number; @@ -46,6 +45,7 @@ type LogsTableProps = { export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, contestId = null }: LogsTableProps) { const { t } = useTranslation("common"); const user = useUserStore((s) => s.user); + const selectedRound = useContestStore((s) => s.selectedRound); const navigate = useNavigate(); const location = useLocation(); const [items, setItems] = useState([]); @@ -53,6 +53,49 @@ export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, conte const [error, setError] = useState(null); const [page, setPage] = useState(1); const [lastPage, setLastPage] = useState(1); + const [finalPublished, setFinalPublished] = useState(false); + const isAdmin = !!user?.is_admin; + + useEffect(() => { + if (isAdmin) { + setFinalPublished(true); + return; + } + if (!roundId) { + setFinalPublished(false); + return; + } + const officialRunId = + selectedRound?.id === roundId ? selectedRound?.official_evaluation_run_id ?? null : null; + if (!officialRunId) { + setFinalPublished(false); + return; + } + + let active = true; + (async () => { + try { + const res = await axios.get<{ status?: string | null; result_type?: string | null }>( + `/api/evaluation-runs/${officialRunId}`, + { + headers: { Accept: "application/json" }, + withCredentials: true, + } + ); + if (!active) return; + const isFinal = + res.data?.status === "SUCCEEDED" && res.data?.result_type === "FINAL"; + setFinalPublished(isFinal); + } catch { + if (!active) return; + setFinalPublished(false); + } + })(); + + return () => { + active = false; + }; + }, [roundId, selectedRound?.official_evaluation_run_id, isAdmin]); useEffect(() => { if (!roundId) return; @@ -101,13 +144,11 @@ export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, conte const columns = [ { key: "parsed", label: "" }, { key: "pcall", label: "PCall" }, - { key: "pwwlo", label: "PWWLo" }, { key: "pband", label: "PBand" }, { key: "psect", label: "PSect" }, { key: "power_watt", label: "SPowe" }, { key: "claimed_qso_count", label: "QSO" }, - { key: "claimed_score", label: "Body" }, - { key: "remarks_eval", label: "remarks_eval" }, + { key: "claimed_score", label: "Deklarované body" }, ...(user ? [{ key: "actions", label: "" }] : []), ]; @@ -116,23 +157,7 @@ export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, conte waiting ? (t("logs_waiting_processing") as string) || "Čekám na zpracování" : value || "—"; const formatNumber = (value: number | null | undefined) => (value === null || value === undefined ? "—" : String(value)); - const renderRemarksEval = (raw: string | null | undefined) => { - if (!raw) return "—"; - - try { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - const lines = parsed - .filter((item) => typeof item === "string" && item.trim() !== "") - .map((item, idx) =>
{item}
); - if (lines.length > 0) return lines; - } - } catch { - // fall through to show raw string - } - - return
{raw}
; - }; + const canNavigate = isAdmin || finalPublished; const handleDelete = async (id: number, e?: React.MouseEvent) => { e?.stopPropagation(); @@ -162,13 +187,15 @@ export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, conte { - if (contestId && roundId) { + if (contestId && roundId && canNavigate) { navigate(`/contests/${contestId}/rounds/${roundId}/logs/${item.id}`, { state: { from: `${location.pathname}${location.search}` }, }); } }} - className={contestId && roundId ? "cursor-pointer hover:bg-default-100" : undefined} + className={ + contestId && roundId && canNavigate ? "cursor-pointer hover:bg-default-100" : undefined + } > {(columnKey) => { if (columnKey === "actions") { @@ -206,7 +233,6 @@ export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, conte } if (columnKey === "parsed") { const parsedClaimed = !!item.parsed_claimed; - const parsedAny = !!item.parsed; const symbol = parsedClaimed ? "✓" : "↻"; const color = parsedClaimed ? "text-green-600" : "text-blue-600"; return ( @@ -215,9 +241,6 @@ export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, conte ); } - if (columnKey === "remarks_eval") { - return {renderRemarksEval(item.remarks_eval)}; - } if (columnKey === "power_watt" || columnKey === "claimed_qso_count" || columnKey === "claimed_score") { return {formatNumber((item as any)[columnKey as string])}; } diff --git a/routes/api.php b/routes/api.php index 06f054e..ef4dd3f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -40,6 +40,7 @@ Route::get('files/{file}/download', [FileController::class, 'download']); Route::get('files/{file}/content', [FileController::class, 'content']); Route::post('login', [LoginController::class, 'authenticate']); Route::apiResource('logs', LogController::class)->only(['index', 'show']); +Route::get('logs/{log}/public', [LogController::class, 'publicShow']); Route::get('logs/{log}/qso-table', [LogController::class, 'qsoTable']); Route::apiResource('log-qsos', LogQsoController::class)->only(['index', 'show']); Route::apiResource('log-results', LogResultController::class)->only(['index', 'show']); diff --git a/tests/Feature/Logs/LogControllerTest.php b/tests/Feature/Logs/LogControllerTest.php index c1296d8..8fbae27 100644 --- a/tests/Feature/Logs/LogControllerTest.php +++ b/tests/Feature/Logs/LogControllerTest.php @@ -35,16 +35,66 @@ class LogControllerTest extends TestCase $this->assertCount(1, $ids); } - public function test_show_returns_log(): void + public function test_show_requires_admin(): void { $log = $this->createLog(); + $this->getJson("/api/logs/{$log->id}") + ->assertStatus(403); + } + + public function test_admin_can_view_log(): void + { + $this->actingAsAdmin(); + $log = $this->createLog(); + $response = $this->getJson("/api/logs/{$log->id}"); $response->assertStatus(200) ->assertJsonFragment(['id' => $log->id]); } + public function test_public_show_requires_final_results(): void + { + $log = $this->createLog(); + + $this->getJson("/api/logs/{$log->id}/public") + ->assertStatus(403); + } + + public function test_public_show_returns_whitelisted_fields(): void + { + $round = $this->createRound(); + $log = $this->createLog([ + 'round_id' => $round->id, + 'pcall' => 'OK1PUBLIC', + 'rcall' => 'OK1SECRET', + 'padr1' => 'Secret Street', + 'pband' => '144', + 'claimed_qso_count' => 50, + ]); + $run = $this->createEvaluationRun([ + 'round_id' => $round->id, + 'status' => 'SUCCEEDED', + 'result_type' => 'FINAL', + 'rules_version' => 'OFFICIAL', + ]); + $round->official_evaluation_run_id = $run->id; + $round->save(); + + $response = $this->getJson("/api/logs/{$log->id}/public"); + + $response->assertStatus(200) + ->assertJsonFragment([ + 'id' => $log->id, + 'pcall' => 'OK1PUBLIC', + 'pband' => '144', + 'claimed_qso_count' => 50, + ]) + ->assertJsonMissing(['rcall' => 'OK1SECRET']) + ->assertJsonMissing(['padr1' => 'Secret Street']); + } + public function test_admin_can_create_update_and_delete_log(): void { $this->actingAsAdmin(); diff --git a/tests/Feature/Logs/LogQsoTableTest.php b/tests/Feature/Logs/LogQsoTableTest.php index 00df6d1..0c64346 100644 --- a/tests/Feature/Logs/LogQsoTableTest.php +++ b/tests/Feature/Logs/LogQsoTableTest.php @@ -11,6 +11,7 @@ class LogQsoTableTest extends TestCase public function test_qso_table_uses_latest_succeeded_non_claimed_run_by_default(): void { + $this->actingAsAdmin(); $round = $this->createRound(); $log = $this->createLog(['round_id' => $round->id]); @@ -77,6 +78,7 @@ class LogQsoTableTest extends TestCase public function test_qso_table_respects_explicit_evaluation_run_id(): void { + $this->actingAsAdmin(); $round = $this->createRound(); $log = $this->createLog(['round_id' => $round->id]);