Skrytí osobních údajů #1
Nezobrazovat detail logu anonymnímu uživateli #2
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,6 +379,7 @@ 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">
|
||||||
|
{isAdmin ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -492,10 +504,65 @@ export default function LogDetail({ logId }: LogDetailProps) {
|
|||||||
)}
|
)}
|
||||||
</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,6 +582,7 @@ export default function LogDetail({ logId }: LogDetailProps) {
|
|||||||
<span>{detail.claimed_dxcc ?? "—"}</span>
|
<span>{detail.claimed_dxcc ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h4 className="font-semibold">Zkontrolované výsledky</h4>
|
<h4 className="font-semibold">Zkontrolované výsledky</h4>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -545,13 +613,14 @@ export default function LogDetail({ logId }: LogDetailProps) {
|
|||||||
<div className="text-xs text-red-600">{officialError}</div>
|
<div className="text-xs text-red-600">{officialError}</div>
|
||||||
)}
|
)}
|
||||||
</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,6 +667,7 @@ export default function LogDetail({ logId }: LogDetailProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<span className="text-md font-semibold">QSO</span>
|
<span className="text-md font-semibold">QSO</span>
|
||||||
@@ -616,6 +686,7 @@ export default function LogDetail({ logId }: LogDetailProps) {
|
|||||||
/>
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user