Før ny agent. Jobber med innlogging
This commit is contained in:
parent
ebd4e40a41
commit
67d1c2ff8c
19 changed files with 586 additions and 17725 deletions
Binary file not shown.
|
|
@ -1,8 +1,7 @@
|
|||
"""
|
||||
TEE OFF ADMIN GENERATOR v1.2 (PBKDF2)
|
||||
TEE OFF ADMIN GENERATOR v1.3 (PBKDF2 ONLY)
|
||||
---------------------------------------------------------------------------
|
||||
FUNKSJON: Genererer SQL for å sette inn en admin med PBKDF2-hash.
|
||||
BRUK: docker exec -it teeoff_api python create_admin.py
|
||||
FUNKSJON: Genererer SQL-kommando for PBKDF2-basert administrator.
|
||||
---------------------------------------------------------------------------
|
||||
"""
|
||||
import pyotp
|
||||
|
|
@ -11,33 +10,34 @@ import getpass
|
|||
|
||||
def generate_admin():
|
||||
print("\n" + "="*50)
|
||||
print(" TEE OFF ADMIN GENERATOR v1.2 (PBKDF2)")
|
||||
print(" TEE OFF ADMIN GENERATOR v1.3")
|
||||
print("="*50)
|
||||
|
||||
username = input("Brukernavn: ").strip()
|
||||
username = input("Brukernavn (f.eks Envide Webutvikling): ").strip()
|
||||
email = input("E-post: ").strip()
|
||||
password = getpass.getpass("Passord (Ingen lengdebegrensning): ")
|
||||
password = getpass.getpass("Passord: ")
|
||||
|
||||
# Generer 2FA hemmelighet
|
||||
otp_secret = pyotp.random_base32()
|
||||
|
||||
# Lag hash med PBKDF2
|
||||
print("⏳ Genererer sikker hash...")
|
||||
print("⏳ Genererer PBKDF2-hash...")
|
||||
# Dette vil produsere en streng som starter med $pbkdf2-sha256$...
|
||||
password_hash = pbkdf2_sha256.hash(password)
|
||||
|
||||
print("\n" + "✅ GENERERING VELLYKKET!")
|
||||
print("\n✅ GENERERING VELLYKKET!")
|
||||
print("-" * 50)
|
||||
print("KJØR DENNE KOMMANDOEN FOR Å OPPRETTE BRUKEREN:")
|
||||
print("1. KJØR DISSE TO KOMMANDOENE I REKKEFØLGE:")
|
||||
print("-" * 50)
|
||||
|
||||
# Kommando 1: Tøm tabellen for å fjerne gamle, inkompatible hasher
|
||||
print(f'docker exec -it teeoff_db psql -U teeoff_admin -d teeoff -c "TRUNCATE admins;"')
|
||||
|
||||
# Kommando 2: Sett inn den nye brukeren
|
||||
sql = f"INSERT INTO admins (username, email, password_hash, otp_secret) VALUES ('{username}', '{email}', '{password_hash}', '{otp_secret}');"
|
||||
|
||||
print(f"\ndocker exec -it teeoff_db psql -U teeoff_admin -d teeoff -c \"{sql}\"")
|
||||
print(f'\ndocker exec -it teeoff_db psql -U teeoff_admin -d teeoff -c "{sql}"')
|
||||
|
||||
print("\n" + "-" * 50)
|
||||
print("2FA KONFIGURASJON (Viktig!):")
|
||||
print(f"Brukernavn: {email}")
|
||||
print(f"Nøkkel (Secret): {otp_secret}")
|
||||
print("2. KONFIGURER 2FA I GOOGLE AUTHENTICATOR:")
|
||||
print(f"Nøkkel: {otp_secret}")
|
||||
print("-" * 50 + "\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
113
backend/main.py
113
backend/main.py
|
|
@ -1,10 +1,11 @@
|
|||
"""
|
||||
TEE OFF BACKEND API v3.6.5 - THE FINAL MASTER VERSION
|
||||
TEE OFF BACKEND API v3.6.8 - THE RESTORED MASTER VERSION
|
||||
---------------------------------------------------------------------------
|
||||
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".
|
||||
---------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
|
|
@ -26,66 +27,86 @@ load_dotenv()
|
|||
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"
|
||||
|
||||
# VIKTIG: Vi bruker PBKDF2-SHA256 for å unngå Bcrypt-begrensninger
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
def format_row(row):
|
||||
"""
|
||||
Vasker data fra databasen:
|
||||
1. Konverterer datoer til ISO-format.
|
||||
2. Parser stringified JSON til ekte Python-objekter.
|
||||
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)
|
||||
|
||||
# 1. Datoer
|
||||
# 1. Håndter dato- og tidsformater for JSON-serialisering
|
||||
for key in ['status_updated_at', 'created_at']:
|
||||
if isinstance(d.get(key), (date, datetime)):
|
||||
d[key] = d[key].isoformat()
|
||||
|
||||
# 2. JSON-felter (Lister)
|
||||
# 2. Definer alle felter som inneholder JSON-data
|
||||
json_list_fields = [
|
||||
'course_statuses', 'courses', 'gallery', 'greenfee',
|
||||
'faqs', 'shotzoom', 'social_links', 'holes'
|
||||
]
|
||||
json_dict_fields = [
|
||||
'amenities', 'vtg', 'nsg_data', 'golfamore_data'
|
||||
]
|
||||
|
||||
# Vask list-felter
|
||||
for field in json_list_fields:
|
||||
if field in d:
|
||||
val = d[field]
|
||||
if val is None: 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] = []
|
||||
try:
|
||||
d[field] = json.loads(val)
|
||||
except:
|
||||
d[field] = []
|
||||
elif not isinstance(val, list):
|
||||
d[field] = []
|
||||
|
||||
# 3. JSON-felter (Objekter)
|
||||
json_dict_fields = ['amenities', 'vtg', 'nsg_data', 'golfamore_data']
|
||||
# Vask objekt-felter
|
||||
for field in json_dict_fields:
|
||||
if field in d:
|
||||
val = d[field]
|
||||
if val is None: 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] = {}
|
||||
try:
|
||||
d[field] = json.loads(val)
|
||||
except:
|
||||
d[field] = {}
|
||||
elif not isinstance(val, dict):
|
||||
d[field] = {}
|
||||
|
||||
return d
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Opprett database-pool
|
||||
# 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
|
||||
DB_URL,
|
||||
min_size=5,
|
||||
max_size=20,
|
||||
command_timeout=60
|
||||
)
|
||||
print("✅ Database pool opprettet")
|
||||
print("✅ Database tilkoblet og pool opprettet")
|
||||
except Exception as e:
|
||||
print(f"❌ Databasefeil: {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.6.5", lifespan=lifespan)
|
||||
app = FastAPI(title="TeeOff API v3.6.8", lifespan=lifespan)
|
||||
|
||||
# CORS - Tillater både lokal utvikling og produksjonsdomene
|
||||
app.add_middleware(
|
||||
|
|
@ -105,34 +126,54 @@ app.add_middleware(
|
|||
@app.post("/api/auth/login")
|
||||
async def login(data: dict):
|
||||
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
|
||||
print(f"🔐 Login-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 or not pwd_context.verify(data.get('password'), admin['password_hash']):
|
||||
if not admin:
|
||||
print(" - ❌ Bruker ikke funnet i databasen")
|
||||
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
||||
|
||||
# DEBUG: Printer starten på hashen for å verifisere formatet ($pbkdf2-sha256$...)
|
||||
h = admin['password_hash']
|
||||
print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)")
|
||||
|
||||
try:
|
||||
if not pwd_context.verify(data.get('password'), h):
|
||||
print(" - ❌ Passordet samsvarer ikke med hashen")
|
||||
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
||||
except Exception as e:
|
||||
print(f" - 🔥 FEIL VED VERIFISERING: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internt problem med passord-format")
|
||||
|
||||
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: Sjekk TOTP og sett session cookie."""
|
||||
"""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:
|
||||
raise HTTPException(status_code=401, detail="Sesjonen har utløpt")
|
||||
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(
|
||||
|
|
@ -140,9 +181,13 @@ async def verify_2fa(data: dict, response: Response):
|
|||
SECRET_KEY, algorithm=ALGORITHM
|
||||
)
|
||||
|
||||
# Sett som HTTP-only cookie
|
||||
response.set_cookie(
|
||||
key="admin_session", value=final_token,
|
||||
httponly=True, samesite="lax", secure=False # False for utvikling
|
||||
key="admin_session",
|
||||
value=final_token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False # Sett til True i produksjon (HTTPS)
|
||||
)
|
||||
return {"status": "success"}
|
||||
|
||||
|
|
@ -150,7 +195,7 @@ async def verify_2fa(data: dict, response: Response):
|
|||
|
||||
@app.get("/api/facilities")
|
||||
async def get_facilities():
|
||||
"""Henter alle anlegg med aggregert banestatus for kortene."""
|
||||
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT f.*, (
|
||||
|
|
@ -167,7 +212,7 @@ async def get_facilities():
|
|||
|
||||
@app.get("/api/facilities/{slug}")
|
||||
async def get_facility(slug: str):
|
||||
"""Henter ett anlegg med alle baner og hull (brukes i FacilityDetailView)."""
|
||||
"""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.*, (
|
||||
|
|
@ -186,10 +231,20 @@ async def get_facility(slug: str):
|
|||
""", slug)
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Banen finnes ikke")
|
||||
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
|
||||
|
||||
return format_row(row)
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
"""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)}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import asyncio
|
||||
import asyncpg
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import json
|
||||
|
||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||
|
||||
def clean_name(text):
|
||||
if not text: return ""
|
||||
# Vasker navnet for matching (fjerner alt unntatt bokstaver)
|
||||
s = text.lower().replace("golfklubb", "").replace("gk", "").replace(" & ", "").strip()
|
||||
return re.sub(r'[^a-z]', '', s)
|
||||
|
||||
async def get_golfamore_links(client):
|
||||
"""Henter ALLE norske klubblenker fra Golfamore sin sitemap"""
|
||||
print("🕵️ Henter komplett liste fra Golfamore...")
|
||||
try:
|
||||
# Golfamore har egne sitemaps for hvert land
|
||||
resp = await client.get("https://www.golfamore.com/sitemaps/courses-no.xml")
|
||||
if resp.status_code == 200:
|
||||
links = re.findall(r'<loc>(https://www.golfamore.com/no/golfklubb/.*?/)</loc>', resp.text)
|
||||
return list(set(links))
|
||||
except Exception as e:
|
||||
print(f"❌ Kunne ikke hente sitemap: {e}")
|
||||
return []
|
||||
|
||||
async def scrape_golfamore():
|
||||
print("\n******************************************")
|
||||
print("🚀 STARTER GOLFAMORE-SYNKRONISERING v1.0")
|
||||
print("******************************************\n")
|
||||
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
facilities = await conn.fetch("SELECT id, name FROM facilities")
|
||||
|
||||
async with httpx.AsyncClient(timeout=20.0, headers={'User-Agent': 'Mozilla/5.0'}) as client:
|
||||
ga_links = await get_golfamore_links(client)
|
||||
# Map vaskede navn fra URL-en til selve URL-en
|
||||
link_map = {clean_name(l.split('/')[-2].replace('-', ' ')): l for l in ga_links}
|
||||
|
||||
matches_found = 0
|
||||
for fac in facilities:
|
||||
fac_id = fac['id']
|
||||
fac_name = fac['name']
|
||||
fac_clean = clean_name(fac_name)
|
||||
|
||||
match_url = link_map.get(fac_clean)
|
||||
|
||||
# Prøv delvis match hvis ikke eksakt (f.eks "Arendal" i "Arendal og Omegn")
|
||||
if not match_url:
|
||||
for slug, url in link_map.items():
|
||||
if len(fac_clean) > 4 and (fac_clean in slug or slug in fac_clean):
|
||||
match_url = url
|
||||
break
|
||||
|
||||
if match_url:
|
||||
try:
|
||||
# Gå til klubbsiden for å finne vilkårene
|
||||
f_resp = await client.get(match_url)
|
||||
soup = BeautifulSoup(f_resp.text, 'html.parser')
|
||||
|
||||
# Finn teksten om når kortet gjelder.
|
||||
# Golfamore bruker ofte spesifikke klasser for "rules" eller "conditions"
|
||||
rules_section = soup.find('div', {'class': 'course-rules'}) or \
|
||||
soup.find('div', {'class': 'course-info__rules'}) or \
|
||||
soup.find(text=re.compile(r'Golfamore gjelder', re.I))
|
||||
|
||||
validity = "Gjelder alle dager" # Standard
|
||||
if rules_section:
|
||||
# Rydd opp i teksten
|
||||
validity = rules_section.get_text(separator=' ').replace('\n', ' ')
|
||||
validity = re.sub(r'\s+', ' ', validity).strip()
|
||||
|
||||
ga_data = {
|
||||
"validity": validity,
|
||||
"source_url": match_url
|
||||
}
|
||||
|
||||
# Oppdater databasen
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET golfamore = true, golfamore_data = $1
|
||||
WHERE id = $2
|
||||
""", json.dumps(ga_data), fac_id)
|
||||
|
||||
print(f"✅ MATCH: {fac_name} ({validity[:50]}...)")
|
||||
matches_found += 1
|
||||
except:
|
||||
# Hvis vi ikke klarer å lese detaljene, markerer vi den i hvert fall som aktiv
|
||||
await conn.execute("UPDATE facilities SET golfamore = true WHERE id = $1", fac_id)
|
||||
else:
|
||||
# Hvis den ikke finnes på Golfamore, sett til false
|
||||
await conn.execute("UPDATE facilities SET golfamore = false, golfamore_data = '{}' WHERE id = $1", fac_id)
|
||||
|
||||
await conn.close()
|
||||
print(f"\n🎉 Ferdig! {matches_found} baner er nå bekreftet hos Golfamore.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(scrape_golfamore())
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import asyncio
|
||||
import asyncpg
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import json
|
||||
|
||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||
|
||||
def clean(text):
|
||||
if not text: return ""
|
||||
# Fjerner alt som ikke er bokstaver for å matche navn på tvers av systemer
|
||||
return re.sub(r'[^a-z0-9]', '', text.lower().replace("golfklubb", "").replace("gk", "").replace(" og ", "").replace("&", ""))
|
||||
|
||||
async def scrape_golfamore():
|
||||
print("\n******************************************")
|
||||
print("🚀 GOLFAMORE ULTIMATE SYNC v1.2")
|
||||
print("******************************************\n")
|
||||
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
facilities = await conn.fetch("SELECT id, name FROM facilities")
|
||||
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36',
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, headers=headers, follow_redirects=True) as client:
|
||||
print("🕵️ Henter baneliste fra Golfamore (Norge)...")
|
||||
url = "https://www.golfamore.com/no/golfbaner/?country=NO"
|
||||
resp = await client.get(url)
|
||||
|
||||
# 1. Finn alle klubb-lenker i kildekoden (omgår lazyload)
|
||||
# Vi leter etter mønsteret "/no/golfklubb/navn-pa-klubb/"
|
||||
all_slugs = re.findall(r'/no/golfklubb/([^/\"\s]+)/', resp.text)
|
||||
found_links = list(set([f"https://www.golfamore.com/no/golfklubb/{s}/" for s in all_slugs]))
|
||||
|
||||
print(f"📍 Fant {len(found_links)} potensielle norske klubber i kildekoden.")
|
||||
|
||||
if not found_links:
|
||||
print("❌ Klarte ikke å finne noen baner. Sjekk om Golfamore har endret URL-struktur.")
|
||||
await conn.close()
|
||||
return
|
||||
|
||||
# Lag et map for rask matching {vasket_navn: url}
|
||||
link_map = {clean(l.split('/')[-2].replace('-', ' ')): l for l in found_links}
|
||||
|
||||
matches_found = 0
|
||||
for fac in facilities:
|
||||
fac_id = fac['id']
|
||||
fac_name = fac['name']
|
||||
fac_clean = clean(fac_name)
|
||||
|
||||
match_url = link_map.get(fac_clean)
|
||||
|
||||
# Prøv delvis match (f.eks "Arendal" i "Arendal og Omegn")
|
||||
if not match_url:
|
||||
for key, url in link_map.items():
|
||||
if len(fac_clean) > 4 and (fac_clean in key or key in fac_clean):
|
||||
match_url = url
|
||||
break
|
||||
|
||||
if match_url:
|
||||
try:
|
||||
print(f"✅ Match: {fac_name}")
|
||||
f_resp = await client.get(match_url)
|
||||
soup = BeautifulSoup(f_resp.text, 'html.parser')
|
||||
|
||||
# Finn teksten om når kortet gjelder
|
||||
# Vi leter etter div-er som inneholder "Gjelder"
|
||||
validity = "Gjelder én gang pr. sesong."
|
||||
|
||||
# Golfamore bruker ofte en liste (ul/li) eller en spesifikk div for regler
|
||||
rules_container = soup.find('div', class_=re.compile(r'rules|conditions|terms', re.I))
|
||||
if not rules_container:
|
||||
# Fallback: Let etter tekstblokken manuelt
|
||||
for div in soup.find_all('div'):
|
||||
if div.text and "Gjelder" in div.text and len(div.text) < 200:
|
||||
validity = div.text.strip()
|
||||
break
|
||||
else:
|
||||
validity = rules_container.get_text(separator=' ').strip()
|
||||
|
||||
# Vask teksten for linjeskift
|
||||
validity = re.sub(r'\s+', ' ', validity).replace('"', '').strip()
|
||||
|
||||
ga_data = {
|
||||
"url": match_url,
|
||||
"validity": validity
|
||||
}
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET golfamore = true, golfamore_data = $1
|
||||
WHERE id = $2
|
||||
""", json.dumps(ga_data), fac_id)
|
||||
|
||||
matches_found += 1
|
||||
await asyncio.sleep(0.2) # Vær snill
|
||||
except Exception as e:
|
||||
print(f"⚠️ Feil ved parsing av {fac_name}: {e}")
|
||||
await conn.execute("UPDATE facilities SET golfamore = true WHERE id = $1", fac_id)
|
||||
else:
|
||||
# Hvis ikke funnet, sett til false
|
||||
await conn.execute("UPDATE facilities SET golfamore = false, golfamore_data = '{}' WHERE id = $1", fac_id)
|
||||
|
||||
await conn.close()
|
||||
print(f"\n🎉 Ferdig! {matches_found} baner er nå synkronisert med Golfamore.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(scrape_golfamore())
|
||||
15
fil-tre.txt
15
fil-tre.txt
|
|
@ -1,11 +1,9 @@
|
|||
📁 teeoff/
|
||||
📄 fil-tre.txt
|
||||
📄 struktur2_dump.txt
|
||||
📄 seed.sql
|
||||
📄 teeoff_backup_2.sql
|
||||
📄 eksport_script.py
|
||||
📄 update_golfbox.sql
|
||||
📄 teeoff_backup_1.sql
|
||||
📄 teeoff_backup.sql
|
||||
📄 docker-compose.yml
|
||||
📄 schema.sql
|
||||
📄 init.sql
|
||||
|
|
@ -661,6 +659,7 @@
|
|||
📄 main_selje-golfklubb.jpg
|
||||
📁 src/
|
||||
📄 struktur_dump.txt
|
||||
📄 middleware.ts
|
||||
📁 components/
|
||||
📄 Header.tsx
|
||||
📁 config/
|
||||
|
|
@ -677,6 +676,10 @@
|
|||
📄 CourseDisplay.tsx
|
||||
📄 page.tsx
|
||||
📄 FacilityDetailView.tsx
|
||||
📁 admin/
|
||||
📄 page.tsx
|
||||
📁 login/
|
||||
📄 page.tsx
|
||||
📁 kode_eksport_1/
|
||||
📄 frontend_src_components_Header_tsx.txt
|
||||
📄 frontend_next-env_d_ts.txt
|
||||
|
|
@ -684,9 +687,12 @@
|
|||
📄 frontend_src_app_page_tsx.txt
|
||||
📄 eksport_script_py.txt
|
||||
📄 frontend_src_app_golfbaner_[slug]_page_tsx.txt
|
||||
📄 frontend_src_middleware_ts.txt
|
||||
📄 frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt
|
||||
📄 frontend_next_config_ts.txt
|
||||
📄 frontend_src_app_admin_login_page_tsx.txt
|
||||
📄 frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
|
||||
📄 frontend_src_app_admin_page_tsx.txt
|
||||
📄 frontend_src_app_HeroSlider_tsx.txt
|
||||
📄 frontend_src_app_FacilitySearch_tsx.txt
|
||||
📄 frontend_src_config_constants_ts.txt
|
||||
|
|
@ -694,14 +700,13 @@
|
|||
📄 scrape_nsg_3.py
|
||||
📄 import_gallery.py
|
||||
📄 .env
|
||||
📄 scrape_golfamore1.2.py
|
||||
📄 sync_greenfee.py
|
||||
📄 scrape_status.py
|
||||
📄 scrape_golfamore1.3.py
|
||||
📄 requirements.txt
|
||||
📄 import_wp.py
|
||||
📄 create_admin.py
|
||||
📄 main.py
|
||||
📄 Dockerfile
|
||||
📄 scrape_golfamore.py
|
||||
📁 public/
|
||||
📁 media/
|
||||
44
kode_eksport_1/backend_create_admin_py.txt
Normal file
44
kode_eksport_1/backend_create_admin_py.txt
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""
|
||||
TEE OFF ADMIN GENERATOR v1.3 (PBKDF2 ONLY)
|
||||
---------------------------------------------------------------------------
|
||||
FUNKSJON: Genererer SQL-kommando for PBKDF2-basert administrator.
|
||||
---------------------------------------------------------------------------
|
||||
"""
|
||||
import pyotp
|
||||
from passlib.hash import pbkdf2_sha256
|
||||
import getpass
|
||||
|
||||
def generate_admin():
|
||||
print("\n" + "="*50)
|
||||
print(" TEE OFF ADMIN GENERATOR v1.3")
|
||||
print("="*50)
|
||||
|
||||
username = input("Brukernavn (f.eks Envide Webutvikling): ").strip()
|
||||
email = input("E-post: ").strip()
|
||||
password = getpass.getpass("Passord: ")
|
||||
|
||||
otp_secret = pyotp.random_base32()
|
||||
|
||||
print("⏳ Genererer PBKDF2-hash...")
|
||||
# Dette vil produsere en streng som starter med $pbkdf2-sha256$...
|
||||
password_hash = pbkdf2_sha256.hash(password)
|
||||
|
||||
print("\n✅ GENERERING VELLYKKET!")
|
||||
print("-" * 50)
|
||||
print("1. KJØR DISSE TO KOMMANDOENE I REKKEFØLGE:")
|
||||
print("-" * 50)
|
||||
|
||||
# Kommando 1: Tøm tabellen for å fjerne gamle, inkompatible hasher
|
||||
print(f'docker exec -it teeoff_db psql -U teeoff_admin -d teeoff -c "TRUNCATE admins;"')
|
||||
|
||||
# Kommando 2: Sett inn den nye brukeren
|
||||
sql = f"INSERT INTO admins (username, email, password_hash, otp_secret) VALUES ('{username}', '{email}', '{password_hash}', '{otp_secret}');"
|
||||
print(f'\ndocker exec -it teeoff_db psql -U teeoff_admin -d teeoff -c "{sql}"')
|
||||
|
||||
print("\n" + "-" * 50)
|
||||
print("2. KONFIGURER 2FA I GOOGLE AUTHENTICATOR:")
|
||||
print(f"Nøkkel: {otp_secret}")
|
||||
print("-" * 50 + "\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_admin()
|
||||
|
|
@ -1,18 +1,42 @@
|
|||
from fastapi import FastAPI, HTTPException
|
||||
"""
|
||||
TEE OFF BACKEND API v3.6.8 - THE RESTORED MASTER VERSION
|
||||
---------------------------------------------------------------------------
|
||||
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
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import asyncpg
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
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
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# --- KONFIGURASJON ---
|
||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||
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"
|
||||
|
||||
# VIKTIG: Vi bruker PBKDF2-SHA256 for å unngå Bcrypt-begrensninger
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
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
|
||||
|
|
@ -25,7 +49,6 @@ def format_row(row):
|
|||
d[key] = d[key].isoformat()
|
||||
|
||||
# 2. Definer alle felter som inneholder JSON-data
|
||||
# Disse må parses manuelt hvis de kommer som strenger fra Postgres
|
||||
json_list_fields = [
|
||||
'course_statuses', 'courses', 'gallery', 'greenfee',
|
||||
'faqs', 'shotzoom', 'social_links', 'holes'
|
||||
|
|
@ -68,6 +91,7 @@ def format_row(row):
|
|||
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,
|
||||
|
|
@ -82,19 +106,96 @@ async def lifespan(app: FastAPI):
|
|||
# Lukk pool ved avslutning
|
||||
await app.state.pool.close()
|
||||
|
||||
app = FastAPI(title="TeeOff API v3.5", lifespan=lifespan)
|
||||
app = FastAPI(title="TeeOff API v3.6.8", lifespan=lifespan)
|
||||
|
||||
# CORS-oppsett slik at Next.js kan snakke med API-et
|
||||
# CORS - Tillater både lokal utvikling og produksjonsdomene
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
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"🔐 Login-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")
|
||||
|
||||
# DEBUG: Printer starten på hashen for å verifisere formatet ($pbkdf2-sha256$...)
|
||||
h = admin['password_hash']
|
||||
print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)")
|
||||
|
||||
try:
|
||||
if not pwd_context.verify(data.get('password'), h):
|
||||
print(" - ❌ Passordet samsvarer ikke med hashen")
|
||||
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
|
||||
except Exception as e:
|
||||
print(f" - 🔥 FEIL VED VERIFISERING: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internt problem med passord-format")
|
||||
|
||||
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"""
|
||||
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT f.*, (
|
||||
|
|
@ -111,7 +212,7 @@ async def get_facilities():
|
|||
|
||||
@app.get("/api/facilities/{slug}")
|
||||
async def get_facility(slug: str):
|
||||
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull"""
|
||||
"""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.*, (
|
||||
|
|
@ -136,7 +237,7 @@ async def get_facility(slug: str):
|
|||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Enkel sjekk for å se at API og DB lever"""
|
||||
"""Enkel sjekk for å se at API og DB lever."""
|
||||
try:
|
||||
async with app.state.pool.acquire() as conn:
|
||||
await conn.execute("SELECT 1")
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
import asyncio
|
||||
import asyncpg
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import json
|
||||
|
||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||
|
||||
def clean(text):
|
||||
if not text: return ""
|
||||
# Fjerner alt som ikke er bokstaver for å matche navn på tvers av systemer
|
||||
return re.sub(r'[^a-z0-9]', '', text.lower().replace("golfklubb", "").replace("gk", "").replace(" og ", "").replace("&", ""))
|
||||
|
||||
async def scrape_golfamore():
|
||||
print("\n******************************************")
|
||||
print("🚀 GOLFAMORE ULTIMATE SYNC v1.2")
|
||||
print("******************************************\n")
|
||||
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
facilities = await conn.fetch("SELECT id, name FROM facilities")
|
||||
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36',
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, headers=headers, follow_redirects=True) as client:
|
||||
print("🕵️ Henter baneliste fra Golfamore (Norge)...")
|
||||
url = "https://www.golfamore.com/no/golfbaner/?country=NO"
|
||||
resp = await client.get(url)
|
||||
|
||||
# 1. Finn alle klubb-lenker i kildekoden (omgår lazyload)
|
||||
# Vi leter etter mønsteret "/no/golfklubb/navn-pa-klubb/"
|
||||
all_slugs = re.findall(r'/no/golfklubb/([^/\"\s]+)/', resp.text)
|
||||
found_links = list(set([f"https://www.golfamore.com/no/golfklubb/{s}/" for s in all_slugs]))
|
||||
|
||||
print(f"📍 Fant {len(found_links)} potensielle norske klubber i kildekoden.")
|
||||
|
||||
if not found_links:
|
||||
print("❌ Klarte ikke å finne noen baner. Sjekk om Golfamore har endret URL-struktur.")
|
||||
await conn.close()
|
||||
return
|
||||
|
||||
# Lag et map for rask matching {vasket_navn: url}
|
||||
link_map = {clean(l.split('/')[-2].replace('-', ' ')): l for l in found_links}
|
||||
|
||||
matches_found = 0
|
||||
for fac in facilities:
|
||||
fac_id = fac['id']
|
||||
fac_name = fac['name']
|
||||
fac_clean = clean(fac_name)
|
||||
|
||||
match_url = link_map.get(fac_clean)
|
||||
|
||||
# Prøv delvis match (f.eks "Arendal" i "Arendal og Omegn")
|
||||
if not match_url:
|
||||
for key, url in link_map.items():
|
||||
if len(fac_clean) > 4 and (fac_clean in key or key in fac_clean):
|
||||
match_url = url
|
||||
break
|
||||
|
||||
if match_url:
|
||||
try:
|
||||
print(f"✅ Match: {fac_name}")
|
||||
f_resp = await client.get(match_url)
|
||||
soup = BeautifulSoup(f_resp.text, 'html.parser')
|
||||
|
||||
# Finn teksten om når kortet gjelder
|
||||
# Vi leter etter div-er som inneholder "Gjelder"
|
||||
validity = "Gjelder én gang pr. sesong."
|
||||
|
||||
# Golfamore bruker ofte en liste (ul/li) eller en spesifikk div for regler
|
||||
rules_container = soup.find('div', class_=re.compile(r'rules|conditions|terms', re.I))
|
||||
if not rules_container:
|
||||
# Fallback: Let etter tekstblokken manuelt
|
||||
for div in soup.find_all('div'):
|
||||
if div.text and "Gjelder" in div.text and len(div.text) < 200:
|
||||
validity = div.text.strip()
|
||||
break
|
||||
else:
|
||||
validity = rules_container.get_text(separator=' ').strip()
|
||||
|
||||
# Vask teksten for linjeskift
|
||||
validity = re.sub(r'\s+', ' ', validity).replace('"', '').strip()
|
||||
|
||||
ga_data = {
|
||||
"url": match_url,
|
||||
"validity": validity
|
||||
}
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET golfamore = true, golfamore_data = $1
|
||||
WHERE id = $2
|
||||
""", json.dumps(ga_data), fac_id)
|
||||
|
||||
matches_found += 1
|
||||
await asyncio.sleep(0.2) # Vær snill
|
||||
except Exception as e:
|
||||
print(f"⚠️ Feil ved parsing av {fac_name}: {e}")
|
||||
await conn.execute("UPDATE facilities SET golfamore = true WHERE id = $1", fac_id)
|
||||
else:
|
||||
# Hvis ikke funnet, sett til false
|
||||
await conn.execute("UPDATE facilities SET golfamore = false, golfamore_data = '{}' WHERE id = $1", fac_id)
|
||||
|
||||
await conn.close()
|
||||
print(f"\n🎉 Ferdig! {matches_found} baner er nå synkronisert med Golfamore.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(scrape_golfamore())
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import asyncio
|
||||
import asyncpg
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import json
|
||||
|
||||
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
|
||||
|
||||
def clean_name(text):
|
||||
if not text: return ""
|
||||
# Vasker navnet for matching (fjerner alt unntatt bokstaver)
|
||||
s = text.lower().replace("golfklubb", "").replace("gk", "").replace(" & ", "").strip()
|
||||
return re.sub(r'[^a-z]', '', s)
|
||||
|
||||
async def get_golfamore_links(client):
|
||||
"""Henter ALLE norske klubblenker fra Golfamore sin sitemap"""
|
||||
print("🕵️ Henter komplett liste fra Golfamore...")
|
||||
try:
|
||||
# Golfamore har egne sitemaps for hvert land
|
||||
resp = await client.get("https://www.golfamore.com/sitemaps/courses-no.xml")
|
||||
if resp.status_code == 200:
|
||||
links = re.findall(r'<loc>(https://www.golfamore.com/no/golfklubb/.*?/)</loc>', resp.text)
|
||||
return list(set(links))
|
||||
except Exception as e:
|
||||
print(f"❌ Kunne ikke hente sitemap: {e}")
|
||||
return []
|
||||
|
||||
async def scrape_golfamore():
|
||||
print("\n******************************************")
|
||||
print("🚀 STARTER GOLFAMORE-SYNKRONISERING v1.0")
|
||||
print("******************************************\n")
|
||||
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
facilities = await conn.fetch("SELECT id, name FROM facilities")
|
||||
|
||||
async with httpx.AsyncClient(timeout=20.0, headers={'User-Agent': 'Mozilla/5.0'}) as client:
|
||||
ga_links = await get_golfamore_links(client)
|
||||
# Map vaskede navn fra URL-en til selve URL-en
|
||||
link_map = {clean_name(l.split('/')[-2].replace('-', ' ')): l for l in ga_links}
|
||||
|
||||
matches_found = 0
|
||||
for fac in facilities:
|
||||
fac_id = fac['id']
|
||||
fac_name = fac['name']
|
||||
fac_clean = clean_name(fac_name)
|
||||
|
||||
match_url = link_map.get(fac_clean)
|
||||
|
||||
# Prøv delvis match hvis ikke eksakt (f.eks "Arendal" i "Arendal og Omegn")
|
||||
if not match_url:
|
||||
for slug, url in link_map.items():
|
||||
if len(fac_clean) > 4 and (fac_clean in slug or slug in fac_clean):
|
||||
match_url = url
|
||||
break
|
||||
|
||||
if match_url:
|
||||
try:
|
||||
# Gå til klubbsiden for å finne vilkårene
|
||||
f_resp = await client.get(match_url)
|
||||
soup = BeautifulSoup(f_resp.text, 'html.parser')
|
||||
|
||||
# Finn teksten om når kortet gjelder.
|
||||
# Golfamore bruker ofte spesifikke klasser for "rules" eller "conditions"
|
||||
rules_section = soup.find('div', {'class': 'course-rules'}) or \
|
||||
soup.find('div', {'class': 'course-info__rules'}) or \
|
||||
soup.find(text=re.compile(r'Golfamore gjelder', re.I))
|
||||
|
||||
validity = "Gjelder alle dager" # Standard
|
||||
if rules_section:
|
||||
# Rydd opp i teksten
|
||||
validity = rules_section.get_text(separator=' ').replace('\n', ' ')
|
||||
validity = re.sub(r'\s+', ' ', validity).strip()
|
||||
|
||||
ga_data = {
|
||||
"validity": validity,
|
||||
"source_url": match_url
|
||||
}
|
||||
|
||||
# Oppdater databasen
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET golfamore = true, golfamore_data = $1
|
||||
WHERE id = $2
|
||||
""", json.dumps(ga_data), fac_id)
|
||||
|
||||
print(f"✅ MATCH: {fac_name} ({validity[:50]}...)")
|
||||
matches_found += 1
|
||||
except:
|
||||
# Hvis vi ikke klarer å lese detaljene, markerer vi den i hvert fall som aktiv
|
||||
await conn.execute("UPDATE facilities SET golfamore = true WHERE id = $1", fac_id)
|
||||
else:
|
||||
# Hvis den ikke finnes på Golfamore, sett til false
|
||||
await conn.execute("UPDATE facilities SET golfamore = false, golfamore_data = '{}' WHERE id = $1", fac_id)
|
||||
|
||||
await conn.close()
|
||||
print(f"\n🎉 Ferdig! {matches_found} baner er nå bekreftet hos Golfamore.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(scrape_golfamore())
|
||||
102
kode_eksport_1/frontend_src_app_admin_login_page_tsx.txt
Normal file
102
kode_eksport_1/frontend_src_app_admin_login_page_tsx.txt
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"use client";
|
||||
/**
|
||||
* TEE OFF ADMIN LOGIN v1.2
|
||||
* ---------------------------------------------------------------------------
|
||||
* PLASSERING: frontend/src/app/admin/login/page.tsx
|
||||
* FUNKSJON: Offentlig tilgjengelig innlogging for administratorer.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { API_URL } from "@/config/constants";
|
||||
|
||||
export default function AdminLogin() {
|
||||
const [step, setStep] = useState(1);
|
||||
const [formData, setFormData] = useState({ username: '', password: '', code: '' });
|
||||
const [tempToken, setTempToken] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: formData.username, password: formData.password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setTempToken(data.temp_token);
|
||||
setStep(2);
|
||||
} else {
|
||||
setError(data.detail || 'Ugyldig pålogging');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Systemfeil: Kunne ikke koble til API-et');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify2FA = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/auth/verify-2fa`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ temp_token: tempToken, code: formData.code })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// VIKTIG: Etter suksess sender vi brukeren til selve dashbordet
|
||||
router.push('/admin');
|
||||
router.refresh();
|
||||
} else {
|
||||
setError('Ugyldig 2FA-kode');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Tilkoblingsfeil ved 2FA-verifisering');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#f1f7ed] p-6 font-sans">
|
||||
<div className="max-w-md w-full bg-white rounded-[3rem] shadow-2xl p-12 border border-white">
|
||||
<div className="flex justify-center mb-10">
|
||||
<img src="/TeeOff-logo-Retina-1.png" className="h-10 w-auto" alt="TeeOff" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-center uppercase tracking-tighter mb-8 text-[#11280f]">
|
||||
{step === 1 ? "Admin Portalen" : "Tofaktor Sjekk"}
|
||||
</h2>
|
||||
<form onSubmit={step === 1 ? handleLogin : handleVerify2FA} className="space-y-4">
|
||||
{step === 1 ? (
|
||||
<>
|
||||
<input type="text" placeholder="Brukernavn eller E-post" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData({...formData, username: e.target.value})} required />
|
||||
<input type="password" placeholder="Passord" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData({...formData, password: e.target.value})} required />
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] text-gray-400 font-black uppercase text-center tracking-widest">Tast inn 6 siffer fra appen din</p>
|
||||
<input type="text" placeholder="000 000" className="w-full p-6 text-center text-4xl tracking-[0.3em] font-black bg-gray-50 rounded-3xl border-none ring-2 ring-[#ff5722]/20 outline-none focus:ring-[#ff5722] transition-all text-[#ff5722]" onChange={e => setFormData({...formData, code: e.target.value})} autoFocus required />
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="bg-red-50 p-4 rounded-xl text-red-600 text-[10px] font-black uppercase tracking-widest text-center border border-red-100">⚠️ {error}</div>}
|
||||
<button type="submit" disabled={isLoading} className={`w-full p-6 rounded-2xl font-black uppercase text-xs tracking-widest text-white transition-all shadow-xl ${step === 1 ? 'bg-[#11280f]' : 'bg-[#ff5722]'}`}>
|
||||
{isLoading ? "Venter..." : (step === 1 ? "Fortsett" : "Logg inn")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
kode_eksport_1/frontend_src_app_admin_page_tsx.txt
Normal file
91
kode_eksport_1/frontend_src_app_admin_page_tsx.txt
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
/**
|
||||
* TEE OFF ADMIN DASHBOARD v1.1
|
||||
* ---------------------------------------------------------------------------
|
||||
* PLASSERING: frontend/src/app/admin/page.tsx
|
||||
* FUNKSJON: Monitorering av banestatus og administrasjon.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API_URL } from "@/config/constants";
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [facilities, setFacilities] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/facilities`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setFacilities(Array.isArray(data) ? data : []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER DASHBORD...</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row min-h-screen bg-[#f1f7ed] font-sans">
|
||||
{/* SIDEBAR (22%) */}
|
||||
<aside className="lg:w-[22%] bg-[#11280f] text-white p-10 flex flex-col">
|
||||
<h1 className="text-2xl font-black uppercase tracking-tighter mb-10">TeeOff Admin</h1>
|
||||
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
|
||||
<div className="text-white border-l-4 border-[#8bc34a] pl-4 py-1">Scraping Monitor</div>
|
||||
<div className="hover:text-white cursor-pointer pl-4 py-1 transition-colors">Medlemskap</div>
|
||||
<div className="hover:text-white cursor-pointer pl-4 py-1 transition-colors">Bildegalleri</div>
|
||||
</nav>
|
||||
<div className="mt-auto pt-10 border-t border-white/10">
|
||||
<button onClick={() => window.location.href='/'} className="text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300">Logg ut</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* HOVEDINNHOLD (78%) */}
|
||||
<main className="lg:w-[78%] p-6 md:p-12">
|
||||
<div className="bg-white rounded-[3rem] shadow-2xl p-10 md:p-16 border border-white">
|
||||
<header className="flex justify-between items-center mb-12">
|
||||
<div>
|
||||
<h2 className="text-4xl font-black tracking-tighter text-[#11280f] mb-2">Scraping Monitor</h2>
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status på {facilities.length} anlegg</p>
|
||||
</div>
|
||||
<button className="bg-[#8bc34a] text-white px-8 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl hover:scale-105 transition-all">Kjør alle skrapere</button>
|
||||
</header>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50">
|
||||
<th className="pb-6">Anlegg</th>
|
||||
<th className="pb-6">Konfigurasjon</th>
|
||||
<th className="pb-6">Siste Sjekk</th>
|
||||
<th className="pb-6 text-right">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm font-bold text-[#11280f]">
|
||||
{facilities.map((f: any) => (
|
||||
<tr key={f.id} className="border-b border-gray-50 group hover:bg-gray-50/50 transition-colors">
|
||||
<td className="py-8">
|
||||
<div className="font-black text-lg">{f.name}</div>
|
||||
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
|
||||
</td>
|
||||
<td className="py-8">
|
||||
<div className="text-[11px] text-blue-600 truncate max-w-[200px] mb-1">{f.scrape_status_url}</div>
|
||||
<div className="text-[10px] font-mono text-gray-300">{f.scrape_status_selector}</div>
|
||||
</td>
|
||||
<td className="py-8 text-gray-400 font-mono text-xs">
|
||||
{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
|
||||
</td>
|
||||
<td className="py-8 text-right">
|
||||
<button className="bg-gray-100 px-5 py-2.5 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-[#11280f] hover:text-white transition-all">Rediger</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ export default function Header() {
|
|||
<Link href="/golfbaner" className="hover:text-[#8bc34a]">Finn Bane</Link>
|
||||
<Link href="/medlemskap" className="hover:text-[#8bc34a]">Medlemskap</Link>
|
||||
<Link href="/om-oss" className="hover:text-[#8bc34a]">Om oss</Link>
|
||||
<Link href="/logg-inn" className="px-5 py-2 bg-[#ff5722] text-white rounded-xl hover:bg-[#e64a19] transition-all">Admin</Link>
|
||||
<Link href="/admin/login" className="px-5 py-2 bg-[#ff5722] text-white rounded-xl hover:bg-black transition-all font-black uppercase text-[10px] tracking-widest">Admin</Link>
|
||||
</nav>
|
||||
|
||||
{/* HAMBURGER (Mobil) */}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,24 @@
|
|||
// Globale innstillinger for TeeOff.no
|
||||
export const API_URL = process.env.API_URL || "http://api:8000/api";
|
||||
/**
|
||||
* TEE OFF CONFIG CONSTANTS v1.3
|
||||
* ---------------------------------------------------------------------------
|
||||
* REGEL 1: ALDRI trunker eller fjern data fra denne filen.
|
||||
* REGEL 2: Håndterer både intern Docker-kommunikasjon og ekstern browser-kommunikasjon.
|
||||
* REGEL 3: Inneholder alle regionale mappinger for Norge.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
// Intern URL for server-to-server (Docker-internt)
|
||||
const INTERNAL_API = process.env.API_URL || "http://api:8000/api";
|
||||
|
||||
// Relativ sti for browseren.
|
||||
// Ved å bruke '/api' sørger vi for at nettleseren bruker samme protokoll (https)
|
||||
// og domene (nye.teeoff.no) som resten av siden.
|
||||
const EXTERNAL_API = "/api";
|
||||
|
||||
export const API_URL = isBrowser ? EXTERNAL_API : INTERNAL_API;
|
||||
|
||||
export const FALLBACK_IMAGE = "/Toppbilde-standard.jpg";
|
||||
export const TEEOFF_LOGO = "/TeeOff-logo-Retina-1.png";
|
||||
|
||||
|
|
@ -20,4 +39,4 @@ export const REGIONS: Record<string, string[]> = {
|
|||
"vestlandet": ["møre og romsdal", "sogn og fjordane", "hordaland", "rogaland", "vestland"],
|
||||
"sørlandet": ["vest-agder", "aust-agder", "agder"],
|
||||
"østlandet": ["telemark", "vestfold", "østfold", "buskerud", "hedmark", "oppland", "oslo", "akershus", "innlandet", "viken"]
|
||||
};
|
||||
};
|
||||
36
kode_eksport_1/frontend_src_middleware_ts.txt
Normal file
36
kode_eksport_1/frontend_src_middleware_ts.txt
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* TEE OFF SECURITY MIDDLEWARE v1.0
|
||||
* ---------------------------------------------------------------------------
|
||||
* REGEL: Beskytter alle ruter under /admin (unntatt /admin/login).
|
||||
* FUNKSJON: Sjekker for admin_session cookie og omdirigerer hvis den mangler.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/request';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const session = request.cookies.get('admin_session');
|
||||
|
||||
// 1. Tillat alltid tilgang til innloggingssiden
|
||||
if (pathname.startsWith('/admin/login')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 2. Beskytt alle andre ruter under /admin
|
||||
if (pathname.startsWith('/admin')) {
|
||||
if (!session) {
|
||||
// Ingen sesjon funnet -> Send til innlogging
|
||||
const loginUrl = new URL('/admin/login', request.url);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Definer hvilke ruter middleware skal kjøre på
|
||||
export const config = {
|
||||
matcher: ['/admin/:path*'],
|
||||
};
|
||||
|
|
@ -110,6 +110,44 @@ SET default_tablespace = '';
|
|||
|
||||
SET default_table_access_method = heap;
|
||||
|
||||
--
|
||||
-- Name: admins; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.admins (
|
||||
id integer NOT NULL,
|
||||
username character varying(50) NOT NULL,
|
||||
password_hash text NOT NULL,
|
||||
otp_secret character varying(32),
|
||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||
email character varying(255)
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.admins OWNER TO teeoff_admin;
|
||||
|
||||
--
|
||||
-- Name: admins_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.admins_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER TABLE public.admins_id_seq OWNER TO teeoff_admin;
|
||||
|
||||
--
|
||||
-- Name: admins_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.admins_id_seq OWNED BY public.admins.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: courses; Type: TABLE; Schema: public; Owner: teeoff_admin
|
||||
--
|
||||
|
|
@ -401,6 +439,13 @@ ALTER TABLE public.tees_id_seq OWNER TO teeoff_admin;
|
|||
ALTER SEQUENCE public.tees_id_seq OWNED BY public.tees.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: admins id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.admins ALTER COLUMN id SET DEFAULT nextval('public.admins_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: courses id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
|
||||
--
|
||||
|
|
@ -443,6 +488,30 @@ ALTER TABLE ONLY public.holes ALTER COLUMN id SET DEFAULT nextval('public.holes_
|
|||
ALTER TABLE ONLY public.tees ALTER COLUMN id SET DEFAULT nextval('public.tees_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: admins admins_email_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.admins
|
||||
ADD CONSTRAINT admins_email_key UNIQUE (email);
|
||||
|
||||
|
||||
--
|
||||
-- Name: admins admins_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.admins
|
||||
ADD CONSTRAINT admins_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: admins admins_username_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.admins
|
||||
ADD CONSTRAINT admins_username_key UNIQUE (username);
|
||||
|
||||
|
||||
--
|
||||
-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
|
||||
--
|
||||
|
|
|
|||
4869
teeoff_backup.sql
4869
teeoff_backup.sql
File diff suppressed because it is too large
Load diff
5747
teeoff_backup_1.sql
5747
teeoff_backup_1.sql
File diff suppressed because it is too large
Load diff
6625
teeoff_backup_2.sql
6625
teeoff_backup_2.sql
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue