ymse endringer
This commit is contained in:
parent
6252879bbd
commit
f5d620db03
2 changed files with 209 additions and 1 deletions
|
|
@ -15,6 +15,7 @@ except ImportError:
|
||||||
|
|
||||||
from google import genai
|
from google import genai
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from course_status_history import ensure_course_status_history_table, log_course_status_change
|
||||||
from env_config import get_database_url
|
from env_config import get_database_url
|
||||||
from scrape_utils import ProgressCallback, emit_progress, make_progress_event
|
from scrape_utils import ProgressCallback, emit_progress, make_progress_event
|
||||||
|
|
||||||
|
|
@ -152,6 +153,7 @@ def send_report(changes, warnings, successes):
|
||||||
async def run_daily_scraping(facility_ids=None, progress_callback: ProgressCallback | None = None):
|
async def run_daily_scraping(facility_ids=None, progress_callback: ProgressCallback | None = None):
|
||||||
print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...")
|
print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...")
|
||||||
conn = await asyncpg.connect(DB_URL)
|
conn = await asyncpg.connect(DB_URL)
|
||||||
|
await ensure_course_status_history_table(conn)
|
||||||
|
|
||||||
if facility_ids:
|
if facility_ids:
|
||||||
print(f"📌 Kjører skraping KUN for anlegg-ID(er): {facility_ids}")
|
print(f"📌 Kjører skraping KUN for anlegg-ID(er): {facility_ids}")
|
||||||
|
|
@ -491,6 +493,15 @@ async def run_daily_scraping(facility_ids=None, progress_callback: ProgressCallb
|
||||||
print(f" 🟡 KONKLUSJON: Fant ikke status i teksten (Sikkerhetsnett). Beholder gammel status ({old_status.upper()}).")
|
print(f" 🟡 KONKLUSJON: Fant ikke status i teksten (Sikkerhetsnett). Beholder gammel status ({old_status.upper()}).")
|
||||||
facility_unresolved += 1
|
facility_unresolved += 1
|
||||||
elif new_status != old_status:
|
elif new_status != old_status:
|
||||||
|
await log_course_status_change(
|
||||||
|
conn,
|
||||||
|
course_id=int(c["id"]),
|
||||||
|
facility_id=int(f["id"]),
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=new_status,
|
||||||
|
change_source="scraper",
|
||||||
|
changed_by="scraper",
|
||||||
|
)
|
||||||
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c['id'])
|
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c['id'])
|
||||||
changes.append(f"🔹 {f['name']} ({c['name']}): {old_status.upper()} ➔ {new_status.upper()}")
|
changes.append(f"🔹 {f['name']} ({c['name']}): {old_status.upper()} ➔ {new_status.upper()}")
|
||||||
print(f" 🟢 KONKLUSJON: Status endret fra {old_status.upper()} til {new_status.upper()}")
|
print(f" 🟢 KONKLUSJON: Status endret fra {old_status.upper()} til {new_status.upper()}")
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,20 @@ type ScrapeJob = {
|
||||||
finished_at?: string | null;
|
finished_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CourseStatusHistoryEntry = {
|
||||||
|
id: number;
|
||||||
|
course_id: number;
|
||||||
|
facility_id: number;
|
||||||
|
old_status: string | null;
|
||||||
|
new_status: string | null;
|
||||||
|
change_source: string;
|
||||||
|
changed_by?: string | null;
|
||||||
|
changed_at?: string | null;
|
||||||
|
course_name?: string | null;
|
||||||
|
facility_name?: string | null;
|
||||||
|
facility_slug?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type QueueFeedback = {
|
type QueueFeedback = {
|
||||||
tone: 'success' | 'warning' | 'info' | 'error';
|
tone: 'success' | 'warning' | 'info' | 'error';
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -128,6 +142,33 @@ const JOB_EVENT_TONE_CLASSES: Record<string, string> = {
|
||||||
info: 'bg-slate-50 text-slate-700 border-slate-200',
|
info: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
aapen: 'Åpen',
|
||||||
|
stengt: 'Stengt',
|
||||||
|
aapen_med_vintergreener: 'Vintergreener',
|
||||||
|
aapner_snart: 'Åpner snart',
|
||||||
|
stenger_snart: 'Stenger snart',
|
||||||
|
under_utvikling: 'Under utvikling',
|
||||||
|
nedlagt: 'Nedlagt',
|
||||||
|
ukjent: 'Ukjent',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||||
|
aapen: 'bg-green-100 text-green-700',
|
||||||
|
stengt: 'bg-red-100 text-red-700',
|
||||||
|
nedlagt: 'bg-red-100 text-red-700',
|
||||||
|
aapen_med_vintergreener: 'bg-yellow-100 text-yellow-700',
|
||||||
|
aapner_snart: 'bg-yellow-100 text-yellow-700',
|
||||||
|
stenger_snart: 'bg-orange-100 text-orange-700',
|
||||||
|
under_utvikling: 'bg-slate-200 text-slate-700',
|
||||||
|
ukjent: 'bg-gray-100 text-gray-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANGE_SOURCE_LABELS: Record<string, string> = {
|
||||||
|
scraper: 'Skraper',
|
||||||
|
manual: 'Manuell',
|
||||||
|
};
|
||||||
|
|
||||||
const EMPTY_GREENFEE_ROW: GreenfeeRow = {
|
const EMPTY_GREENFEE_ROW: GreenfeeRow = {
|
||||||
banenavn: '',
|
banenavn: '',
|
||||||
priskategori: '',
|
priskategori: '',
|
||||||
|
|
@ -315,6 +356,9 @@ export default function AdminDashboard() {
|
||||||
const [facilityJumpSlug, setFacilityJumpSlug] = useState('');
|
const [facilityJumpSlug, setFacilityJumpSlug] = useState('');
|
||||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||||
const [scrapeJobs, setScrapeJobs] = useState<ScrapeJob[]>([]);
|
const [scrapeJobs, setScrapeJobs] = useState<ScrapeJob[]>([]);
|
||||||
|
const [courseStatusHistory, setCourseStatusHistory] = useState<CourseStatusHistoryEntry[]>([]);
|
||||||
|
const [statusHistoryDate, setStatusHistoryDate] = useState('');
|
||||||
|
const [isLoadingStatusHistory, setIsLoadingStatusHistory] = useState(false);
|
||||||
const [isQueueing, setIsQueueing] = useState(false);
|
const [isQueueing, setIsQueueing] = useState(false);
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
const [showMobileAdminMenu, setShowMobileAdminMenu] = useState(false);
|
const [showMobileAdminMenu, setShowMobileAdminMenu] = useState(false);
|
||||||
|
|
@ -357,6 +401,22 @@ export default function AdminDashboard() {
|
||||||
.catch(() => setScrapeJobs([]));
|
.catch(() => setScrapeJobs([]));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchCourseStatusHistory = (dateFilter: string = statusHistoryDate) => {
|
||||||
|
const params = new URLSearchParams({ limit: '100' });
|
||||||
|
if (dateFilter) {
|
||||||
|
params.set('changed_on', dateFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingStatusHistory(true);
|
||||||
|
adminFetch(`${API_URL}/admin/course-status-history?${params.toString()}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setCourseStatusHistory(Array.isArray(data) ? data : []);
|
||||||
|
})
|
||||||
|
.catch(() => setCourseStatusHistory([]))
|
||||||
|
.finally(() => setIsLoadingStatusHistory(false));
|
||||||
|
};
|
||||||
|
|
||||||
const activeJob = useMemo(
|
const activeJob = useMemo(
|
||||||
() => scrapeJobs.find(job => job.status === 'pending' || job.status === 'running') || null,
|
() => scrapeJobs.find(job => job.status === 'pending' || job.status === 'running') || null,
|
||||||
[scrapeJobs]
|
[scrapeJobs]
|
||||||
|
|
@ -387,6 +447,17 @@ export default function AdminDashboard() {
|
||||||
fetchScrapeJobs(activeTab);
|
fetchScrapeJobs(activeTab);
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'banestatus') return;
|
||||||
|
fetchCourseStatusHistory();
|
||||||
|
}, [activeTab, statusHistoryDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'banestatus') return;
|
||||||
|
const interval = setInterval(() => fetchCourseStatusHistory(), 15000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [activeTab, statusHistoryDate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isScraping) return;
|
if (!isScraping) return;
|
||||||
const interval = setInterval(() => fetchFacilities(), 10000);
|
const interval = setInterval(() => fetchFacilities(), 10000);
|
||||||
|
|
@ -408,8 +479,11 @@ export default function AdminDashboard() {
|
||||||
) {
|
) {
|
||||||
fetchFacilities();
|
fetchFacilities();
|
||||||
fetchScrapeJobs(activeTab);
|
fetchScrapeJobs(activeTab);
|
||||||
|
if (activeTab === 'banestatus') {
|
||||||
|
fetchCourseStatusHistory();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [activeTab, latestJob]);
|
}, [activeTab, latestJob, statusHistoryDate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showTwoFactorModal) return;
|
if (!showTwoFactorModal) return;
|
||||||
|
|
@ -529,6 +603,13 @@ export default function AdminDashboard() {
|
||||||
return scrapeJobs.filter(job => job.id !== latestJob.id).slice(0, 4);
|
return scrapeJobs.filter(job => job.id !== latestJob.id).slice(0, 4);
|
||||||
}, [latestJob, scrapeJobs]);
|
}, [latestJob, scrapeJobs]);
|
||||||
const showJobHistory = recentJobs.length > 0 && !isLatestJobDismissed;
|
const showJobHistory = recentJobs.length > 0 && !isLatestJobDismissed;
|
||||||
|
const statusHistoryFacilityCount = useMemo(
|
||||||
|
() => new Set(courseStatusHistory.map(entry => entry.facility_id)).size,
|
||||||
|
[courseStatusHistory]
|
||||||
|
);
|
||||||
|
const statusHistoryHeading = statusHistoryDate
|
||||||
|
? `Banestatus endret ${new Date(`${statusHistoryDate}T00:00:00`).toLocaleDateString('nb-NO')}`
|
||||||
|
: 'Banestatus endret i dag';
|
||||||
|
|
||||||
const latestJobProgress = useMemo(() => {
|
const latestJobProgress = useMemo(() => {
|
||||||
const total = latestJob?.progress_total ?? latestJob?.total_facilities ?? 0;
|
const total = latestJob?.progress_total ?? latestJob?.total_facilities ?? 0;
|
||||||
|
|
@ -1774,6 +1855,122 @@ export default function AdminDashboard() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'banestatus' && (
|
||||||
|
<section className="mb-8 rounded-[1.75rem] border border-[#dbe7cf] bg-[#f8fbf4] p-5 md:p-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Endringshistorikk</p>
|
||||||
|
<h3 className="text-lg font-black tracking-tight text-[#11280f]">{statusHistoryHeading}</h3>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-600">
|
||||||
|
Viser faktiske banestatusendringer fra skraper og manuelle overstyringer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-gray-500">
|
||||||
|
Dato
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={statusHistoryDate}
|
||||||
|
onChange={(e) => setStatusHistoryDate(e.target.value)}
|
||||||
|
className="rounded-2xl border-2 border-gray-200 bg-white px-4 py-3 text-sm font-bold text-[#11280f] outline-none transition-colors focus:border-[#8bc34a]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusHistoryDate('');
|
||||||
|
fetchCourseStatusHistory('');
|
||||||
|
}}
|
||||||
|
className="btn btn-md btn-secondary whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Tilbake til i dag
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fetchCourseStatusHistory()}
|
||||||
|
className="btn btn-md btn-secondary whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Oppdater
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
<span className="inline-flex rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600 border border-[#dbe7cf]">
|
||||||
|
{courseStatusHistory.length} endringer
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600 border border-[#dbe7cf]">
|
||||||
|
{statusHistoryFacilityCount} anlegg
|
||||||
|
</span>
|
||||||
|
{isLoadingStatusHistory && (
|
||||||
|
<span className="inline-flex rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-widest text-[#7ca982] border border-[#dbe7cf]">
|
||||||
|
Laster...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{courseStatusHistory.length === 0 ? (
|
||||||
|
<div className="mt-5 rounded-[1.5rem] border border-dashed border-[#dbe7cf] bg-white px-5 py-6 text-sm text-gray-500">
|
||||||
|
{isLoadingStatusHistory
|
||||||
|
? 'Laster historikk...'
|
||||||
|
: 'Ingen registrerte banestatusendringer for valgt dato.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||||
|
{courseStatusHistory.map((entry) => {
|
||||||
|
const oldStatus = entry.old_status || 'ukjent';
|
||||||
|
const newStatus = entry.new_status || 'ukjent';
|
||||||
|
const oldStatusClass = STATUS_BADGE_CLASSES[oldStatus] || STATUS_BADGE_CLASSES.ukjent;
|
||||||
|
const newStatusClass = STATUS_BADGE_CLASSES[newStatus] || STATUS_BADGE_CLASSES.ukjent;
|
||||||
|
const sourceLabel = CHANGE_SOURCE_LABELS[entry.change_source] || entry.change_source;
|
||||||
|
const sourceTone =
|
||||||
|
entry.change_source === 'manual'
|
||||||
|
? 'bg-[#eef5e5] text-[#11280f] border-[#d8e8c8]'
|
||||||
|
: 'bg-slate-50 text-slate-700 border-slate-200';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={entry.id}
|
||||||
|
className="rounded-[1.5rem] border border-[#dbe7cf] bg-white p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`inline-flex rounded-xl border px-3 py-1 text-[10px] font-black uppercase tracking-widest ${sourceTone}`}>
|
||||||
|
{sourceLabel}
|
||||||
|
</span>
|
||||||
|
{entry.changed_at && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{new Date(entry.changed_at).toLocaleString('nb-NO')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-sm font-black text-[#11280f]">{entry.facility_name || 'Ukjent anlegg'}</p>
|
||||||
|
<p className="text-xs uppercase tracking-widest text-[#7ca982]">{entry.course_name || 'Ukjent bane'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs font-bold text-gray-500">
|
||||||
|
<span className={`inline-flex rounded-xl px-3 py-1 text-[10px] font-black uppercase tracking-widest ${oldStatusClass}`}>
|
||||||
|
{STATUS_LABELS[oldStatus] || oldStatus.replaceAll('_', ' ')}
|
||||||
|
</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span className={`inline-flex rounded-xl px-3 py-1 text-[10px] font-black uppercase tracking-widest ${newStatusClass}`}>
|
||||||
|
{STATUS_LABELS[newStatus] || newStatus.replaceAll('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{entry.change_source === 'manual' && (
|
||||||
|
<p className="mt-3 text-xs text-gray-500">
|
||||||
|
Endret av {entry.changed_by || 'ukjent admin'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* VELDIG SYNLIGE FANER */}
|
{/* VELDIG SYNLIGE FANER */}
|
||||||
<div className="mb-8 flex flex-wrap gap-2 border-b-2 border-gray-100 pb-3">
|
<div className="mb-8 flex flex-wrap gap-2 border-b-2 border-gray-100 pb-3">
|
||||||
<button onClick={() => setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Banestatus</button>
|
<button onClick={() => setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Banestatus</button>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue