Nye-TeeOff/kode_eksport_1/backend_main_py.txt

638 lines
26 KiB
Text
Raw Normal View History

"""
TEE OFF BACKEND API v3.7.0 - KOBLET PÅ FULL ADMIN REDIGERING
---------------------------------------------------------------------------
REGEL 1: Bruk str (ikke string) for type-hinting.
REGEL 2: Inkluder alle subqueries for banestatus og hull-data.
REGEL 3: Robust JSON-parsing (format_row) for å hindre Frontend-krasj.
REGEL 4: JWT-sesjoner lagres i HTTP-only cookies.
LOV: Aldri trunker eller slett logikk for "effektivitet".
---------------------------------------------------------------------------
"""
from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import asyncpg
import json
import pyotp
import os
from datetime import datetime, date, timedelta
from jose import jwt, JWTError
from passlib.context import CryptContext
from dotenv import load_dotenv
# NYE IMPORTER FOR ADMIN PANELET OG BAKGRUNNSJOBBER
from pydantic import BaseModel
from typing import Optional, List, Any
import subprocess
load_dotenv()
# --- KONFIGURASJON ---
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production")
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
# --- PYDANTIC MODELLER ---
class CourseStatusUpdate(BaseModel):
id: int
status: str
class ScrapeSettingsUpdate(BaseModel):
scrape_method: Optional[str] = None
scrape_status_url: Optional[str] = None
scrape_status_selector: Optional[str] = None
ai_instruction: Optional[str] = None
courses: Optional[List[CourseStatusUpdate]] = []
# NY MODELL FOR Å TA IMOT IDER FOR SCRAPING
class ScrapeRunRequest(BaseModel):
facility_ids: List[int]
class MembershipDraftApproval(BaseModel):
facility_id: int
navn_standard_medlemskap: Optional[str] = None
standard_medlemskap: Optional[int] = None
standard_medlemskap_kommentarer: Optional[str] = None
navn_rimeligste_alternativ: Optional[str] = None
rimeligste_alternativ: Optional[int] = None
class BulkApprovalRequest(BaseModel):
approvals: List[MembershipDraftApproval]
2026-04-10 09:52:34 +02:00
class QuickEditRequest(BaseModel):
field: str
value: str
class GreenfeeApproval(BaseModel):
facility_id: int
greenfee: List[dict]
class VtgApproval(BaseModel):
facility_id: int
vtg_pris: int | None
vtg_beskrivelse: str | None
vtg_datoer: List[dict] | None
class BulkVtgRequest(BaseModel):
approvals: List[VtgApproval]
# --- FUNKSJONER ---
def format_row(row):
"""
Vasker data fra databasen:
1. Konverterer datoer til ISO-format.
2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister.
3. Sikrer at lister og objekter aldri er None for å hindre Frontend-krasj.
"""
if row is None:
return None
d = dict(row)
for key in ['status_updated_at', 'created_at', 'slope_valid_until', 'membership_updated_at']:
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee',
2026-04-10 09:52:34 +02:00
'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer'
]
json_dict_fields = [
'amenities', 'vtg', 'nsg_data', 'golfamore_data', 'membership_draft'
]
for field in json_list_fields:
if field in d:
val = d[field]
if val is None:
d[field] = []
elif isinstance(val, str):
try:
d[field] = json.loads(val)
except:
d[field] = []
elif not isinstance(val, list):
d[field] = []
for field in json_dict_fields:
if field in d:
val = d[field]
if val is None:
d[field] = {}
elif isinstance(val, str):
try:
d[field] = json.loads(val)
except:
d[field] = {}
elif not isinstance(val, dict):
d[field] = {}
return d
# --- BAKGRUNNSARBEIDER: FUNKSJON SOM KJØRER SKRAPEREN I BAKGRUNNEN ---
def run_scrape_worker(facility_ids: List[int]):
"""
Kjører selve skraping-scriptet i bakgrunnen.
Slik kan frontenden få et umiddelbart svar, mens skraperen jobber.
"""
print(f"🔄 STARTER BAKGRUNNSSKRAPING FOR FØLGENDE IDER: {facility_ids}")
try:
ids_arg = ",".join(map(str, facility_ids))
# NYTT: Bruker "python -u" for LIVE logging, og fjerner "> /dev/null 2>&1"
command = f"python -u scrape_status.py --ids {ids_arg}"
subprocess.run(command, shell=True, check=True)
print(f"✅ BAKGRUNNSSKRAPING FULLFØRT FOR IDER: {facility_ids}")
except subprocess.CalledProcessError as e:
print(f"❌ FEIL UNDER BAKGRUNNSSKRAPING: {e}")
except Exception as e:
print(f"🔥 UFORUTSETT FEIL UNDER BAKGRUNNSSKRAPING: {e}")
2026-04-10 09:52:34 +02:00
def run_membership_worker(facility_ids: List[int]):
"""Kjører medlemskap-skraperen i bakgrunnen."""
print(f"🔄 STARTER MEDLEMSKAP-SKRAPING FOR IDER: {facility_ids}")
try:
ids_arg = ",".join(map(str, facility_ids))
command = f"python -u scrape_membership.py --ids {ids_arg}"
subprocess.run(command, shell=True, check=True)
print(f"✅ MEDLEMSKAP-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
except Exception as e:
print(f"🔥 FEIL UNDER MEDLEMSKAP-SKRAPING: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Opprett database-pool ved start
try:
print(f"📡 Forsøker å koble til database på: {DB_URL}")
app.state.pool = await asyncpg.create_pool(
DB_URL,
min_size=5,
max_size=20,
command_timeout=60
)
print("✅ Database tilkoblet og pool opprettet")
except Exception as e:
print(f"❌ Databasefeil under oppstart: {e}")
raise e
yield
# Lukk pool ved avslutning
await app.state.pool.close()
app = FastAPI(title="TeeOff API v3.7.0", lifespan=lifespan)
# CORS - Tillater både lokal utvikling og produksjonsdomene
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://nye.teeoff.no",
"http://nye.teeoff.no",
"http://localhost:3000"
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- AUTH ENDPOINTS ---
@app.post("/api/auth/login")
async def login(data: dict):
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
print(f"🔐 Loggin-forsøk for: {data.get('username')}")
async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow(
"SELECT * FROM admins WHERE username = $1 OR email = $1",
data.get('username')
)
if not admin:
print(" - ❌ Bruker ikke funnet i databasen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
h = admin['password_hash']
print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)")
try:
is_valid = pwd_context.verify(data.get('password'), h)
except Exception as e:
print(f" - 🔥 FEIL VED LESING AV HASH: {e}")
raise HTTPException(status_code=500, detail="Internt problem med passord-format")
if not is_valid:
print(" - ❌ Passordet samsvarer ikke med hashen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
temp_token = jwt.encode(
{"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
SECRET_KEY, algorithm=ALGORITHM
)
print(" - ✅ Steg 1 fullført. Temp-token generert.")
return {"step": "2fa", "temp_token": temp_token}
@app.post("/api/auth/verify-2fa")
async def verify_2fa(data: dict, response: Response):
"""Steg 2: Verifiser TOTP-kode og sett session cookie."""
try:
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
if not payload.get("partial"):
raise JWTError()
username = payload.get("sub")
except JWTError:
raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig")
async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow("SELECT otp_secret FROM admins WHERE username = $1", username)
totp = pyotp.TOTP(admin['otp_secret'])
if not totp.verify(data.get('code')):
print(f" - ❌ Feil 2FA-kode oppgitt for {username}")
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
final_token = jwt.encode(
{"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)},
SECRET_KEY, algorithm=ALGORITHM
)
# Sett som HTTP-only cookie
response.set_cookie(
key="admin_session",
value=final_token,
httponly=True,
samesite="lax",
secure=False # Sett til True i produksjon (HTTPS)
)
return {"status": "success"}
# --- DATA ENDPOINTS ---
@app.get("/api/facilities")
async def get_facilities():
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT f.*, (
SELECT jsonb_agg(cs) FROM (
SELECT id, name, status FROM courses
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
ORDER BY is_main_course DESC, id ASC
) cs
) as course_statuses
FROM facilities f
ORDER BY f.name ASC
""")
return [format_row(row) for row in rows]
@app.get("/api/facilities/{slug}")
async def get_facility(slug: str):
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT f.*, (
SELECT jsonb_agg(c_data) FROM (
SELECT c.*, (
SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC)
FROM (SELECT * FROM holes WHERE course_id = c.id) h_data
) as holes
FROM courses c
WHERE c.facility_id = f.id
AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'ukjent')))
ORDER BY c.is_main_course DESC, c.id ASC
) c_data
) as courses
FROM facilities f WHERE f.slug = $1
""", slug)
if not row:
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
return format_row(row)
# --- ADMIN ENDPOINTS ---
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""
async with app.state.pool.acquire() as conn:
try:
# Sjekk først at anlegget eksisterer
facility = await conn.fetchrow("SELECT id FROM facilities WHERE id = $1", facility_id)
if not facility:
raise HTTPException(status_code=404, detail="Anlegget finnes ikke.")
# Oppdater verdiene i databasen inkludert AI instruks
await conn.execute("""
UPDATE facilities
SET scrape_method = $1,
scrape_status_url = $2,
scrape_status_selector = $3,
ai_instruction = $4
WHERE id = $5
""",
settings.scrape_method,
settings.scrape_status_url,
settings.scrape_status_selector,
settings.ai_instruction,
facility_id)
# Hvis metoden er manuell, tvinger vi gjennom de nye banestatusene direkte
if settings.scrape_method == 'manual' and settings.courses:
for c in settings.courses:
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", c.status, c.id)
return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
except Exception as e:
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=500, detail=str(e))
# --- NYTT ADMIN ENDPOINT FOR FULL OPPDATERING (JSON-EDITOR) ---
@app.put("/api/admin/facilities/{facility_id}/full")
async def update_facility_full(facility_id: int, request: Request):
"""Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren)."""
data = await request.json()
# Felter som er trygge å oppdatere manuelt på anlegget
allowed_fields = [
'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters',
'address', 'zipcode', 'city', 'county', 'lat', 'lng',
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
'nsg_url', 'nsg_data', 'golfamore', 'golfamore_data',
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
'vtg_presentasjon', 'vtg_lenke', 'vtg_pris', 'vtg_kursdatoer',
'guest_requirements', 'scrape_method', 'scrape_status_url',
2026-04-10 09:52:34 +02:00
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
'greenfee_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector', 'vtg_lenke'
]
update_data = {k: v for k, v in data.items() if k in allowed_fields}
async with app.state.pool.acquire() as conn:
async with conn.transaction(): # Sikrer at alt lagres samlet
# 1. OPPDATER ANLEGG (FACILITIES)
if update_data:
set_clauses = []
values = []
for i, (k, v) in enumerate(update_data.items(), 1):
if isinstance(v, (dict, list)):
set_clauses.append(f"{k} = ${i}::jsonb")
values.append(json.dumps(v))
else:
set_clauses.append(f"{k} = ${i}")
values.append(v)
values.append(facility_id)
query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}"
await conn.execute(query, *values)
# 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
courses = data.get('courses', [])
for course in courses:
course_id = course.get('id')
if course_id:
# Rens datoformat for PostgreSQL (håndterer Next.js date input)
valid_until = course.get('slope_valid_until')
if valid_until == "" or valid_until is None:
valid_until = None
await conn.execute("""
UPDATE courses
SET name=$1, par=$2, length_meters=$3, architect=$4,
status=$5, is_main_course=$6, tee_boxes=$7::jsonb,
slope_valid_until=$8
WHERE id=$9 AND facility_id=$10
""",
course.get('name'), course.get('par'), course.get('length_meters'),
course.get('architect'), course.get('status'), course.get('is_main_course'),
json.dumps(course.get('tee_boxes', {})), valid_until, course_id, facility_id)
# 3. OPPDATER HULL PÅ BANEN (HOLES)
holes = course.get('holes', [])
for hole in holes:
hole_id = hole.get('id')
if hole_id:
await conn.execute("""
UPDATE holes
SET par=$1, hcp_index=$2, lengths=$3::jsonb
WHERE id=$4 AND course_id=$5
""",
hole.get('par'), hole.get('hcp_index'),
json.dumps(hole.get('lengths', {})), hole_id, course_id)
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
# --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER ---
@app.post("/api/admin/run-scraper")
async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
"""
Tar imot IDer for skraping, og starter en bakgrunnsjobb.
Gir et umiddelbart svar tilbake til frontenden slik at den slipper å vente.
"""
if not request.facility_ids:
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
print(f"📡 API mottok forespørsel om å kjøre skraping for IDer: {request.facility_ids}")
background_tasks.add_task(run_scrape_worker, request.facility_ids)
return {"status": "queued", "message": f"Skraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
2026-04-10 09:52:34 +02:00
@app.post("/api/admin/run-membership-scraper")
async def run_membership_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
"""Tar imot IDer for medlemskapsskraping og legger jobben i kø."""
if not request.facility_ids:
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
print(f"📡 API mottok forespørsel om medlemskapsskraping for IDer: {request.facility_ids}")
background_tasks.add_task(run_membership_worker, request.facility_ids)
return {"status": "queued", "message": f"Medlemskapsskraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
@app.get("/api/health")
async def health_check():
"""Enkel sjekk for å se at API og DB lever."""
try:
async with app.state.pool.acquire() as conn:
await conn.execute("SELECT 1")
return {"status": "healthy", "database": "connected"}
except Exception as e:
return {"status": "unhealthy", "error": str(e)}
# --- MEDLEMSKAP "VASKERI" ENDEPUNKTER ---
@app.get("/api/admin/membership/drafts")
async def get_membership_drafts():
"""Henter alle anlegg som har et ventende forslag fra AI-skraperen."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT id, name, slug, medlemskap_url,
navn_standard_medlemskap, standard_medlemskap,
navn_rimeligste_alternativ, rimeligste_alternativ,
membership_draft
FROM facilities
WHERE membership_draft IS NOT NULL
AND membership_draft::text != '{}'
ORDER BY name ASC
""")
return [format_row(row) for row in rows]
@app.post("/api/admin/membership/approve-bulk")
async def approve_membership_bulk(request: BulkApprovalRequest):
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
async with app.state.pool.acquire() as conn:
async with conn.transaction():
for approval in request.approvals:
await conn.execute("""
UPDATE facilities
SET navn_standard_medlemskap = $1,
standard_medlemskap = $2,
standard_medlemskap_kommentarer = $3,
navn_rimeligste_alternativ = $4,
rimeligste_alternativ = $5,
membership_updated_at = NOW(),
membership_draft = NULL
WHERE id = $6
""",
approval.navn_standard_medlemskap,
approval.standard_medlemskap,
approval.standard_medlemskap_kommentarer,
approval.navn_rimeligste_alternativ,
approval.rimeligste_alternativ,
approval.facility_id)
return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"}
2026-04-10 09:52:34 +02:00
@app.patch("/api/admin/facilities/{facility_id}/quick-edit")
async def quick_edit_facility(facility_id: int, request: QuickEditRequest):
"""Lyn-redigering av enkle URL-felter fra admin-dashbordet."""
# Sikkerhet: Tillat KUN disse tre feltene for hurtigredigering
allowed_fields = ['scrape_status_url', 'medlemskap_url', 'scrape_status_selector']
if request.field not in allowed_fields:
raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.")
async with app.state.pool.acquire() as conn:
# F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen
await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2",
request.value, facility_id)
return {"status": "success"}
# --- GREENFEE "VASKERI" ENDEPUNKTER ---
@app.get("/api/admin/greenfee/drafts")
async def get_greenfee_drafts():
"""Henter alle anlegg som har et ventende greenfee-forslag fra AI-skraperen."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT id, name, slug, greenfee_url, greenfee, greenfee_draft
FROM facilities
WHERE greenfee_draft IS NOT NULL
AND greenfee_draft::text != '{}'
ORDER BY name ASC
""")
return [format_row(row) for row in rows]
class BulkGreenfeeRequest(BaseModel):
approvals: List[GreenfeeApproval]
@app.post("/api/admin/greenfee/approve-bulk")
async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
async with app.state.pool.acquire() as conn:
async with conn.transaction():
for approval in request.approvals:
await conn.execute("""
UPDATE facilities
SET greenfee = $1::jsonb,
greenfee_updated_at = NOW(),
greenfee_draft = NULL
WHERE id = $2
""", json.dumps(approval.greenfee), approval.facility_id)
return {"status": "success"}
def run_greenfee_worker(facility_ids: List[int]):
"""Kjører greenfee-skraperen i bakgrunnen."""
print(f"🔄 STARTER GREENFEE-SKRAPING FOR IDER: {facility_ids}")
try:
import subprocess
ids_arg = ",".join(map(str, facility_ids))
command = f"python -u scrape_greenfee.py --ids {ids_arg}"
subprocess.run(command, shell=True, check=True)
print(f"✅ GREENFEE-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
except Exception as e:
print(f"🔥 FEIL UNDER GREENFEE-SKRAPING: {e}")
@app.post("/api/admin/run-greenfee-scraper")
async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
"""Tar imot IDer for greenfeeskraping og legger jobben i kø."""
if not request.facility_ids:
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
background_tasks.add_task(run_greenfee_worker, request.facility_ids)
return {"status": "queued", "message": "Skraping startet"}
# --- VEIEN TIL GOLF (VTG) "VASKERI" ENDEPUNKTER ---
@app.get("/api/admin/vtg/drafts")
async def get_vtg_drafts():
"""Henter alle anlegg som har et ventende VTG-forslag."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT id, name, slug, vtg_lenke, vtg_pris, vtg_beskrivelse, vtg_datoer, vtg_draft
FROM facilities
WHERE vtg_draft IS NOT NULL
AND vtg_draft::text != '{}'
ORDER BY name ASC
""")
return [format_row(row) for row in rows]
@app.post("/api/admin/vtg/approve-bulk")
async def approve_vtg_bulk(request: BulkVtgRequest):
"""Godkjenner AI-forslag for VTG, setter oppdatert-dato og sletter utkastet."""
async with app.state.pool.acquire() as conn:
async with conn.transaction():
for approval in request.approvals:
datoer_json = json.dumps(approval.vtg_datoer) if approval.vtg_datoer is not None else '[]'
await conn.execute("""
UPDATE facilities
SET vtg_pris = $1,
vtg_beskrivelse = $2,
vtg_datoer = $3::jsonb,
vtg_updated_at = NOW(),
vtg_draft = NULL
WHERE id = $4
""", approval.vtg_pris, approval.vtg_beskrivelse, datoer_json, approval.facility_id)
return {"status": "success"}
def run_vtg_worker(facility_ids: List[int]):
"""Kjører VTG-skraperen i bakgrunnen."""
print(f"🔄 STARTER VTG-SKRAPING FOR IDER: {facility_ids}")
try:
import subprocess
ids_arg = ",".join(map(str, facility_ids))
command = f"python -u scrape_vtg.py --ids {ids_arg}"
subprocess.run(command, shell=True, check=True)
print(f"✅ VTG-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
except Exception as e:
print(f"🔥 FEIL UNDER VTG-SKRAPING: {e}")
@app.post("/api/admin/run-vtg-scraper")
async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
"""Tar imot IDer for VTG-skraping og legger jobben i kø."""
if not request.facility_ids:
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
background_tasks.add_task(run_vtg_worker, request.facility_ids)
return {"status": "queued", "message": "Skraping startet"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)