Skrytí osobních údajů #1

Nezobrazovat detail logu anonymnímu uživateli #2
This commit is contained in:
Zdeněk Burda
2026-01-10 12:50:45 +01:00
parent 41e3ce6f25
commit 1e484aef47
7 changed files with 419 additions and 203 deletions

View File

@@ -7,6 +7,7 @@ use App\Models\LogQso;
use App\Models\EvaluationRun; use App\Models\EvaluationRun;
use App\Models\QsoOverride; use App\Models\QsoOverride;
use App\Models\QsoResult; use App\Models\QsoResult;
use App\Models\Round;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -86,6 +87,8 @@ class LogController extends BaseController
*/ */
public function show(Request $request, Log $log): JsonResponse public function show(Request $request, Log $log): JsonResponse
{ {
$this->authorize('view', $log);
$includeQsos = $request->boolean('include_qsos', false); $includeQsos = $request->boolean('include_qsos', false);
$relations = ['round', 'file']; $relations = ['round', 'file'];
if ($includeQsos) { if ($includeQsos) {
@@ -96,11 +99,54 @@ class LogController extends BaseController
return response()->json($log); 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. * QSO tabulka pro log: raw QSO + výsledky vyhodnocení + případné overrides.
*/ */
public function qsoTable(Request $request, Log $log): JsonResponse public function qsoTable(Request $request, Log $log): JsonResponse
{ {
$this->authorize('view', $log);
$evalRunId = $request->filled('evaluation_run_id') $evalRunId = $request->filled('evaluation_run_id')
? (int) $request->get('evaluation_run_id') ? (int) $request->get('evaluation_run_id')
: null; : null;
@@ -341,4 +387,17 @@ class LogController extends BaseController
'raw_header' => ['sometimes', 'nullable', 'string'], '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();
}
} }

View File

@@ -8,6 +8,16 @@ use Illuminate\Auth\Access\Response;
class LogPolicy 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 public function create(User $user): bool
{ {
return (bool) $user->is_admin; return (bool) $user->is_admin;

View File

@@ -11,6 +11,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useContestStore } from "@/stores/contestStore"; import { useContestStore } from "@/stores/contestStore";
import { useLanguageStore } from "@/stores/languageStore"; import { useLanguageStore } from "@/stores/languageStore";
import { useUserStore } from "@/stores/userStore";
import LogQsoTable from "@/components/LogQsoTable"; import LogQsoTable from "@/components/LogQsoTable";
type LogDetailData = { type LogDetailData = {
@@ -190,6 +191,8 @@ export default function LogDetail({ logId }: LogDetailProps) {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
const locale = useLanguageStore((s) => s.locale) || navigator.language || "cs-CZ"; const locale = useLanguageStore((s) => s.locale) || navigator.language || "cs-CZ";
const setSelectedRound = useContestStore((s) => s.setSelectedRound); const setSelectedRound = useContestStore((s) => s.setSelectedRound);
const user = useUserStore((s) => s.user);
const isAdmin = !!user?.is_admin;
const [detail, setDetail] = useState<LogDetailData | null>(null); const [detail, setDetail] = useState<LogDetailData | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -209,8 +212,9 @@ export default function LogDetail({ logId }: LogDetailProps) {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await axios.get<LogDetailData>(`/api/logs/${logId}`, { const endpoint = isAdmin ? `/api/logs/${logId}` : `/api/logs/${logId}/public`;
params: { include_qsos: 0 }, const res = await axios.get<LogDetailData>(endpoint, {
params: isAdmin ? { include_qsos: 0 } : undefined,
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
withCredentials: true, withCredentials: true,
}); });
@@ -230,9 +234,13 @@ export default function LogDetail({ logId }: LogDetailProps) {
logs_deadline: res.data.round.logs_deadline ?? null, logs_deadline: res.data.round.logs_deadline ?? null,
}); });
} }
} catch { } catch (e: any) {
if (!active) return; 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 { } finally {
if (active) setLoading(false); if (active) setLoading(false);
} }
@@ -240,10 +248,10 @@ export default function LogDetail({ logId }: LogDetailProps) {
return () => { return () => {
active = false; active = false;
}; };
}, [logId, t, setSelectedRound]); }, [logId, t, setSelectedRound, isAdmin]);
useEffect(() => { useEffect(() => {
if (!detail?.id) return; if (!detail?.id || !isAdmin) return;
let active = true; let active = true;
(async () => { (async () => {
@@ -319,10 +327,13 @@ export default function LogDetail({ logId }: LogDetailProps) {
return () => { return () => {
active = false; active = false;
}; };
}, [detail?.id]); }, [detail?.id, isAdmin]);
const title = (() => { const title = (() => {
const pcall = detail?.pcall ?? ""; const pcall = detail?.pcall ?? "";
if (!isAdmin) {
return pcall || (t("log") ?? "Log");
}
const rcall = detail?.rcall ?? ""; const rcall = detail?.rcall ?? "";
if (pcall && rcall && pcall !== rcall) { if (pcall && rcall && pcall !== rcall) {
return `${pcall}-${rcall}`; return `${pcall}-${rcall}`;
@@ -368,134 +379,190 @@ export default function LogDetail({ logId }: LogDetailProps) {
{loading && <div>{t("loading") ?? "Načítám..."}</div>} {loading && <div>{t("loading") ?? "Načítám..."}</div>}
{detail && !loading && ( {detail && !loading && (
<div className="space-y-4 text-sm"> <div className="space-y-4 text-sm">
<div className="grid gap-4 md:grid-cols-2"> {isAdmin ? (
<div className="space-y-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="flex gap-2"> <div className="space-y-2">
<span className="font-semibold" title={t("edi_pcall_hint") ?? "Call used during contest"}>
PCall:
</span>
<span>
{detail.pcall || "—"}
{detail.pclub ? ` (${detail.pclub})` : ""}
</span>
</div>
{detail.pband && (
<div className="flex gap-2"> <div className="flex gap-2">
<span className="font-semibold" title={t("edi_pband_hint") ?? "Band"}>PBand:</span> <span className="font-semibold" title={t("edi_pcall_hint") ?? "Call used during contest"}>
<span>{detail.pband}</span> PCall:
</div> </span>
)}
{detail.psect && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_psect_hint") ?? "Section / category"}>PSect:</span>
<span>{detail.psect}</span>
</div>
)}
{detail.padr1 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_padr1_hint") ?? "Address line 1 (QTH)"}>PAdr1:</span>
<span>{detail.padr1}</span>
</div>
)}
{detail.padr2 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_padr2_hint") ?? "Address line 2 (QTH)"}>PAdr2:</span>
<span>{detail.padr2}</span>
</div>
)}
{detail.mope1 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_mope1_hint") ?? "Multi operator line 1"}>MOpe1:</span>
<span>{detail.mope1}</span>
</div>
)}
{detail.mope2 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_mope2_hint") ?? "Multi operator line 2"}>MOpe2:</span>
<span>{detail.mope2}</span>
</div>
)}
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_rcall_hint") ?? "Responsible operator callsign"}>RCall:</span>
<span>{detail.rcall || "—"}</span>
</div>
{detail.radr1 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_radr1_hint") ?? "Address line 1 of responsible operator"}>RAdr1:</span>
<span>{detail.radr1}</span>
</div>
)}
{detail.radr2 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_radr2_hint") ?? "Address line 2 of responsible operator"}>RAdr2:</span>
<span>{detail.radr2}</span>
</div>
)}
{(detail.rpoco || detail.rcity) && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_rpoco_rcity_hint") ?? "Postal code / city of responsible operator"}>RPoCo/RCity:</span>
<span> <span>
{detail.rpoco ?? ""} {detail.rcity ?? ""} {detail.pcall || ""}
{detail.pclub ? ` (${detail.pclub})` : ""}
</span> </span>
</div> </div>
)} {detail.pband && (
{detail.rcoun && ( <div className="flex gap-2">
<span className="font-semibold" title={t("edi_pband_hint") ?? "Band"}>PBand:</span>
<span>{detail.pband}</span>
</div>
)}
{detail.psect && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_psect_hint") ?? "Section / category"}>PSect:</span>
<span>{detail.psect}</span>
</div>
)}
{detail.padr1 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_padr1_hint") ?? "Address line 1 (QTH)"}>PAdr1:</span>
<span>{detail.padr1}</span>
</div>
)}
{detail.padr2 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_padr2_hint") ?? "Address line 2 (QTH)"}>PAdr2:</span>
<span>{detail.padr2}</span>
</div>
)}
{detail.mope1 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_mope1_hint") ?? "Multi operator line 1"}>MOpe1:</span>
<span>{detail.mope1}</span>
</div>
)}
{detail.mope2 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_mope2_hint") ?? "Multi operator line 2"}>MOpe2:</span>
<span>{detail.mope2}</span>
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<span className="font-semibold" title={t("edi_rcoun_hint") ?? "Country of responsible operator"}>RCoun:</span> <span className="font-semibold" title={t("edi_rcall_hint") ?? "Responsible operator callsign"}>RCall:</span>
<span>{detail.rcoun}</span> <span>{detail.rcall || "—"}</span>
</div> </div>
)} {detail.radr1 && (
{detail.rphon && ( <div className="flex gap-2">
<div className="flex gap-2"> <span className="font-semibold" title={t("edi_radr1_hint") ?? "Address line 1 of responsible operator"}>RAdr1:</span>
<span className="font-semibold" title={t("edi_rphon_hint") ?? "Phone of responsible operator"}>RPhon:</span> <span>{detail.radr1}</span>
<span>{detail.rphon}</span> </div>
</div> )}
)} {detail.radr2 && (
{detail.rhbbs && ( <div className="flex gap-2">
<div className="flex gap-2"> <span className="font-semibold" title={t("edi_radr2_hint") ?? "Address line 2 of responsible operator"}>RAdr2:</span>
<span className="font-semibold" title={t("edi_rhbbs_hint") ?? "Home BBS of responsible operator"}>RHBBS:</span> <span>{detail.radr2}</span>
<span>{detail.rhbbs}</span> </div>
</div> )}
)} {(detail.rpoco || detail.rcity) && (
</div> <div className="flex gap-2">
<span className="font-semibold" title={t("edi_rpoco_rcity_hint") ?? "Postal code / city of responsible operator"}>RPoCo/RCity:</span>
<span>
{detail.rpoco ?? ""} {detail.rcity ?? ""}
</span>
</div>
)}
{detail.rcoun && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_rcoun_hint") ?? "Country of responsible operator"}>RCoun:</span>
<span>{detail.rcoun}</span>
</div>
)}
{detail.rphon && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_rphon_hint") ?? "Phone of responsible operator"}>RPhon:</span>
<span>{detail.rphon}</span>
</div>
)}
{detail.rhbbs && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_rhbbs_hint") ?? "Home BBS of responsible operator"}>RHBBS:</span>
<span>{detail.rhbbs}</span>
</div>
)}
</div>
<div className="space-y-2"> <div className="space-y-2">
{detail.stxeq && ( {detail.stxeq && (
<div className="flex gap-2"> <div className="flex gap-2">
<span className="font-semibold" title={t("edi_stxeq_hint") ?? "TX equipment"}>STXEq:</span> <span className="font-semibold" title={t("edi_stxeq_hint") ?? "TX equipment"}>STXEq:</span>
<span>{detail.stxeq}</span> <span>{detail.stxeq}</span>
</div> </div>
)} )}
{detail.srxeq && ( {detail.srxeq && (
<div className="flex gap-2"> <div className="flex gap-2">
<span className="font-semibold" title={t("edi_srxeq_hint") ?? "RX equipment"}>SRXEq:</span> <span className="font-semibold" title={t("edi_srxeq_hint") ?? "RX equipment"}>SRXEq:</span>
<span>{detail.srxeq}</span> <span>{detail.srxeq}</span>
</div> </div>
)} )}
{detail.power_watt && ( {detail.power_watt && (
<div className="flex gap-2"> <div className="flex gap-2">
<span className="font-semibold" title={t("edi_spowe_hint") ?? "TX power [W]"}>SPowe:</span> <span className="font-semibold" title={t("edi_spowe_hint") ?? "TX power [W]"}>SPowe:</span>
<span>{detail.power_watt}</span> <span>{detail.power_watt}</span>
</div>
)}
{detail.sante && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_sante_hint") ?? "Antenna"}>SAntenna:</span>
<span>{detail.sante}</span>
</div>
)}
{detail.santh && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_santh_hint") ?? "Antenna height [m] / ASL [m]"}>SAntH:</span>
<span>{detail.santh}</span>
</div>
)}
</div> </div>
)}
{detail.sante && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_sante_hint") ?? "Antenna"}>SAntenna:</span>
<span>{detail.sante}</span>
</div>
)}
{detail.santh && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_santh_hint") ?? "Antenna height [m] / ASL [m]"}>SAntH:</span>
<span>{detail.santh}</span>
</div>
)}
</div> </div>
</div> ) : (
<Divider /> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_pcall_hint") ?? "Call used during contest"}>
PCall:
</span>
<span>{detail.pcall || "—"}</span>
</div>
{detail.pband && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_pband_hint") ?? "Band"}>PBand:</span>
<span>{detail.pband}</span>
</div>
)}
{detail.psect && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_psect_hint") ?? "Section / category"}>PSect:</span>
<span>{detail.psect}</span>
</div>
)}
{detail.power_watt && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_spowe_hint") ?? "TX power [W]"}>SPowe:</span>
<span>{detail.power_watt}</span>
</div>
)}
</div>
<div className="space-y-2">
{detail.stxeq && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_stxeq_hint") ?? "TX equipment"}>STXEq:</span>
<span>{detail.stxeq}</span>
</div>
)}
{detail.srxeq && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_srxeq_hint") ?? "RX equipment"}>SRXEq:</span>
<span>{detail.srxeq}</span>
</div>
)}
{detail.sante && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_sante_hint") ?? "Antenna"}>SAntenna:</span>
<span>{detail.sante}</span>
</div>
)}
{detail.santh && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_santh_hint") ?? "Antenna height [m] / ASL [m]"}>SAntH:</span>
<span>{detail.santh}</span>
</div>
)}
</div>
</div>
)}
<Divider /> <Divider />
<div className="space-y-3"> <div className="space-y-3">
<div className="grid gap-4 md:grid-cols-2 text-sm"> <div className={isAdmin ? "grid gap-4 md:grid-cols-2 text-sm" : "text-sm"}>
<div className="space-y-1"> <div className="space-y-1">
<h4 className="font-semibold">Deklarované výsledky</h4> <h4 className="font-semibold">Deklarované výsledky</h4>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -515,43 +582,45 @@ export default function LogDetail({ logId }: LogDetailProps) {
<span>{detail.claimed_dxcc ?? "—"}</span> <span>{detail.claimed_dxcc ?? "—"}</span>
</div> </div>
</div> </div>
<div className="space-y-1"> {isAdmin && (
<h4 className="font-semibold">Zkontrolované výsledky</h4> <div className="space-y-1">
<div className="flex gap-2"> <h4 className="font-semibold">Zkontrolované výsledky</h4>
<span className="font-semibold">Počet QSO:</span> <div className="flex gap-2">
<span> <span className="font-semibold">Počet QSO:</span>
{officialLoading ? "…" : officialResult?.valid_qso_count ?? "—"} <span>
</span> {officialLoading ? "…" : officialResult?.valid_qso_count ?? "—"}
</span>
</div>
<div className="flex gap-2">
<span className="font-semibold">Body:</span>
<span>
{officialLoading ? "…" : officialResult?.official_score ?? "—"}
</span>
</div>
<div className="flex gap-2">
<span className="font-semibold">Unikátních WWL:</span>
<span>
{officialLoading ? "…" : officialResult?.multiplier_count ?? "—"}
</span>
</div>
<div className="flex gap-2">
<span className="font-semibold">Penalizace:</span>
<span>
{officialLoading ? "…" : officialResult?.penalty_score ?? "—"}
</span>
</div>
{officialError && (
<div className="text-xs text-red-600">{officialError}</div>
)}
</div> </div>
<div className="flex gap-2"> )}
<span className="font-semibold">Body:</span>
<span>
{officialLoading ? "…" : officialResult?.official_score ?? "—"}
</span>
</div>
<div className="flex gap-2">
<span className="font-semibold">Unikátních WWL:</span>
<span>
{officialLoading ? "…" : officialResult?.multiplier_count ?? "—"}
</span>
</div>
<div className="flex gap-2">
<span className="font-semibold">Penalizace:</span>
<span>
{officialLoading ? "…" : officialResult?.penalty_score ?? "—"}
</span>
</div>
{officialError && (
<div className="text-xs text-red-600">{officialError}</div>
)}
</div>
</div> </div>
{detail.remarks_eval && ( {isAdmin && detail.remarks_eval && (
<div className="text-sm text-red-600"> <div className="text-sm text-red-600">
{renderRemarksEval(detail.remarks_eval)} {renderRemarksEval(detail.remarks_eval)}
</div> </div>
)} )}
{detail.raw_header && ( {isAdmin && detail.raw_header && (
<Accordion> <Accordion>
<AccordionItem <AccordionItem
key="raw" key="raw"
@@ -570,7 +639,7 @@ export default function LogDetail({ logId }: LogDetailProps) {
</CardBody> </CardBody>
</Card> </Card>
{(logOverrideReason || Object.keys(qsoOverrides).length > 0) && ( {isAdmin && (logOverrideReason || Object.keys(qsoOverrides).length > 0) && (
<Card> <Card>
<CardHeader> <CardHeader>
<span className="text-md font-semibold">Zásahy rozhodčího</span> <span className="text-md font-semibold">Zásahy rozhodčího</span>
@@ -598,24 +667,26 @@ export default function LogDetail({ logId }: LogDetailProps) {
</Card> </Card>
)} )}
<Card> {isAdmin && (
<CardHeader> <Card>
<span className="text-md font-semibold">QSO</span> <CardHeader>
</CardHeader> <span className="text-md font-semibold">QSO</span>
<Divider /> </CardHeader>
<CardBody> <Divider />
<LogQsoTable <CardBody>
key={`${detail?.id ?? "log"}-${qsoTableRows.length}-${Object.keys(qsoOverrides).length}-${Object.keys(officialQsoResults).length}`} <LogQsoTable
qsos={qsoTableRows} key={`${detail?.id ?? "log"}-${qsoTableRows.length}-${Object.keys(qsoOverrides).length}-${Object.keys(officialQsoResults).length}`}
locale={locale} qsos={qsoTableRows}
formatDateTime={formatDateTime} locale={locale}
officialQsoResults={officialQsoResults} formatDateTime={formatDateTime}
qsoOverrides={qsoOverrides} officialQsoResults={officialQsoResults}
emptyLabel={t("logs_empty") ?? "Žádné QSO záznamy."} qsoOverrides={qsoOverrides}
callsign={title} emptyLabel={t("logs_empty") ?? "Žádné QSO záznamy."}
/> callsign={title}
</CardBody> />
</Card> </CardBody>
</Card>
)}
</div> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, Pagination } from "@heroui/react"; import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, Pagination } from "@heroui/react";
import { useUserStore } from "@/stores/userStore"; import { useUserStore } from "@/stores/userStore";
import { useContestStore } from "@/stores/contestStore";
type LogItem = { type LogItem = {
id: number; id: number;
@@ -14,13 +15,11 @@ type LogItem = {
tdate?: string | null; tdate?: string | null;
pcall?: string | null; pcall?: string | null;
rcall?: string | null; // pokud backend vrátí, jinak zobrazíme prázdně rcall?: string | null; // pokud backend vrátí, jinak zobrazíme prázdně
pwwlo?: string | null;
psect?: string | null; psect?: string | null;
pband?: string | null; pband?: string | null;
power_watt?: number | null; power_watt?: number | null;
claimed_qso_count?: number | null; claimed_qso_count?: number | null;
claimed_score?: number | null; claimed_score?: number | null;
remarks_eval?: string | null;
file_id?: number | null; file_id?: number | null;
file?: { file?: {
id: number; id: number;
@@ -46,6 +45,7 @@ type LogsTableProps = {
export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, contestId = null }: LogsTableProps) { export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, contestId = null }: LogsTableProps) {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
const user = useUserStore((s) => s.user); const user = useUserStore((s) => s.user);
const selectedRound = useContestStore((s) => s.selectedRound);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [items, setItems] = useState<LogItem[]>([]); const [items, setItems] = useState<LogItem[]>([]);
@@ -53,6 +53,49 @@ export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, conte
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [lastPage, setLastPage] = 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(() => { useEffect(() => {
if (!roundId) return; if (!roundId) return;
@@ -101,13 +144,11 @@ export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, conte
const columns = [ const columns = [
{ key: "parsed", label: "" }, { key: "parsed", label: "" },
{ key: "pcall", label: "PCall" }, { key: "pcall", label: "PCall" },
{ key: "pwwlo", label: "PWWLo" },
{ key: "pband", label: "PBand" }, { key: "pband", label: "PBand" },
{ key: "psect", label: "PSect" }, { key: "psect", label: "PSect" },
{ key: "power_watt", label: "SPowe" }, { key: "power_watt", label: "SPowe" },
{ key: "claimed_qso_count", label: "QSO" }, { key: "claimed_qso_count", label: "QSO" },
{ key: "claimed_score", label: "Body" }, { key: "claimed_score", label: "Deklarované body" },
{ key: "remarks_eval", label: "remarks_eval" },
...(user ? [{ key: "actions", label: "" }] : []), ...(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 || "—"; 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 formatNumber = (value: number | null | undefined) => (value === null || value === undefined ? "—" : String(value));
const renderRemarksEval = (raw: string | null | undefined) => { const canNavigate = isAdmin || finalPublished;
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) => <div key={idx}>{item}</div>);
if (lines.length > 0) return lines;
}
} catch {
// fall through to show raw string
}
return <div>{raw}</div>;
};
const handleDelete = async (id: number, e?: React.MouseEvent) => { const handleDelete = async (id: number, e?: React.MouseEvent) => {
e?.stopPropagation(); e?.stopPropagation();
@@ -162,13 +187,15 @@ export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, conte
<TableRow <TableRow
key={item.id} key={item.id}
onClick={() => { onClick={() => {
if (contestId && roundId) { if (contestId && roundId && canNavigate) {
navigate(`/contests/${contestId}/rounds/${roundId}/logs/${item.id}`, { navigate(`/contests/${contestId}/rounds/${roundId}/logs/${item.id}`, {
state: { from: `${location.pathname}${location.search}` }, 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) => { {(columnKey) => {
if (columnKey === "actions") { if (columnKey === "actions") {
@@ -206,7 +233,6 @@ export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, conte
} }
if (columnKey === "parsed") { if (columnKey === "parsed") {
const parsedClaimed = !!item.parsed_claimed; const parsedClaimed = !!item.parsed_claimed;
const parsedAny = !!item.parsed;
const symbol = parsedClaimed ? "✓" : "↻"; const symbol = parsedClaimed ? "✓" : "↻";
const color = parsedClaimed ? "text-green-600" : "text-blue-600"; const color = parsedClaimed ? "text-green-600" : "text-blue-600";
return ( return (
@@ -215,9 +241,6 @@ export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, conte
</TableCell> </TableCell>
); );
} }
if (columnKey === "remarks_eval") {
return <TableCell>{renderRemarksEval(item.remarks_eval)}</TableCell>;
}
if (columnKey === "power_watt" || columnKey === "claimed_qso_count" || columnKey === "claimed_score") { if (columnKey === "power_watt" || columnKey === "claimed_qso_count" || columnKey === "claimed_score") {
return <TableCell>{formatNumber((item as any)[columnKey as string])}</TableCell>; return <TableCell>{formatNumber((item as any)[columnKey as string])}</TableCell>;
} }

View File

@@ -40,6 +40,7 @@ Route::get('files/{file}/download', [FileController::class, 'download']);
Route::get('files/{file}/content', [FileController::class, 'content']); Route::get('files/{file}/content', [FileController::class, 'content']);
Route::post('login', [LoginController::class, 'authenticate']); Route::post('login', [LoginController::class, 'authenticate']);
Route::apiResource('logs', LogController::class)->only(['index', 'show']); 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::get('logs/{log}/qso-table', [LogController::class, 'qsoTable']);
Route::apiResource('log-qsos', LogQsoController::class)->only(['index', 'show']); Route::apiResource('log-qsos', LogQsoController::class)->only(['index', 'show']);
Route::apiResource('log-results', LogResultController::class)->only(['index', 'show']); Route::apiResource('log-results', LogResultController::class)->only(['index', 'show']);

View File

@@ -35,16 +35,66 @@ class LogControllerTest extends TestCase
$this->assertCount(1, $ids); $this->assertCount(1, $ids);
} }
public function test_show_returns_log(): void public function test_show_requires_admin(): void
{ {
$log = $this->createLog(); $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 = $this->getJson("/api/logs/{$log->id}");
$response->assertStatus(200) $response->assertStatus(200)
->assertJsonFragment(['id' => $log->id]); ->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 public function test_admin_can_create_update_and_delete_log(): void
{ {
$this->actingAsAdmin(); $this->actingAsAdmin();

View File

@@ -11,6 +11,7 @@ class LogQsoTableTest extends TestCase
public function test_qso_table_uses_latest_succeeded_non_claimed_run_by_default(): void public function test_qso_table_uses_latest_succeeded_non_claimed_run_by_default(): void
{ {
$this->actingAsAdmin();
$round = $this->createRound(); $round = $this->createRound();
$log = $this->createLog(['round_id' => $round->id]); $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 public function test_qso_table_respects_explicit_evaluation_run_id(): void
{ {
$this->actingAsAdmin();
$round = $this->createRound(); $round = $this->createRound();
$log = $this->createLog(['round_id' => $round->id]); $log = $this->createLog(['round_id' => $round->id]);