v1
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
import httpx
|
||||
import user_agents
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth.deps import get_current_user
|
||||
from app.middleware.rate_limit import limiter
|
||||
from app.schemas.analytics import AnalyticsSummary, BookingOperationsSummary, EventCreate
|
||||
from app.services.analytics import get_booking_operations_summary, get_summary, record_event
|
||||
|
||||
router = APIRouter(tags=["Analytics"])
|
||||
ANON_COOKIE_NAME = "__gw_anon"
|
||||
ANON_COOKIE_MAX_AGE = 60 * 60 * 24 * 365
|
||||
CLIENT_METADATA_KEYS = {
|
||||
"area",
|
||||
"channel",
|
||||
"destination",
|
||||
"menu",
|
||||
"plan",
|
||||
"popular",
|
||||
"price",
|
||||
"unit",
|
||||
"variant",
|
||||
}
|
||||
|
||||
_PRIVATE_PREFIXES = ("127.", "10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
|
||||
"172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.",
|
||||
"192.168.", "::1", "localhost")
|
||||
|
||||
|
||||
def _mask_ip(ip: str) -> str:
|
||||
"""Return a privacy-safe partial IP: last octet replaced with 'x'."""
|
||||
if ":" in ip: # IPv6 — keep first 4 groups
|
||||
parts = ip.split(":")
|
||||
return ":".join(parts[:4]) + ":x"
|
||||
parts = ip.split(".")
|
||||
if len(parts) == 4:
|
||||
return f"{parts[0]}.{parts[1]}.{parts[2]}.x"
|
||||
return ip
|
||||
|
||||
|
||||
def _get_client_ip(request: Request) -> str | None:
|
||||
"""Resolve the best-effort client IP, preferring forwarded headers."""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
first = forwarded.split(",")[0].strip()
|
||||
if first:
|
||||
return first
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def _should_secure_cookie(request: Request) -> bool:
|
||||
"""Use Secure cookies in HTTPS contexts, but allow localhost HTTP development."""
|
||||
return request.url.scheme == "https"
|
||||
|
||||
|
||||
def _sanitize_client_metadata(metadata: dict | None) -> dict | None:
|
||||
"""Keep only flat, non-identifying telemetry labels from the browser."""
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
clean: dict[str, str | int | float | bool] = {}
|
||||
|
||||
for key, value in metadata.items():
|
||||
if not isinstance(key, str) or key not in CLIENT_METADATA_KEYS:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
clean[key] = value[:120]
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
clean[key] = value
|
||||
continue
|
||||
if isinstance(value, (int, float)):
|
||||
clean[key] = value
|
||||
|
||||
return clean or None
|
||||
|
||||
|
||||
def _get_or_create_session_id(request: Request, response: Response, payload_session_id: str | None) -> str:
|
||||
"""Use a server-owned anonymous session id, falling back to legacy payload support."""
|
||||
cookie_session_id = request.cookies.get(ANON_COOKIE_NAME)
|
||||
session_id = cookie_session_id or payload_session_id or secrets.token_urlsafe(24)
|
||||
|
||||
if cookie_session_id != session_id:
|
||||
response.set_cookie(
|
||||
key=ANON_COOKIE_NAME,
|
||||
value=session_id,
|
||||
max_age=ANON_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=_should_secure_cookie(request),
|
||||
path="/",
|
||||
)
|
||||
|
||||
return session_id
|
||||
|
||||
|
||||
def _parse_ua(ua_string: str) -> tuple[str | None, str | None]:
|
||||
"""Parse a User-Agent string into (browser, os_name)."""
|
||||
if not ua_string:
|
||||
return None, None
|
||||
ua = user_agents.parse(ua_string)
|
||||
browser = ua.browser.family
|
||||
if browser and browser != "Other" and ua.browser.version_string:
|
||||
major = ua.browser.version_string.split(".")[0]
|
||||
browser = f"{browser} {major}"
|
||||
os_name = ua.os.family
|
||||
if os_name and os_name != "Other" and ua.os.version_string:
|
||||
os_name = f"{os_name} {ua.os.version_string}"
|
||||
return (
|
||||
None if not browser or browser == "Other" else browser[:100],
|
||||
None if not os_name or os_name == "Other" else os_name[:100],
|
||||
)
|
||||
|
||||
|
||||
async def _geo_lookup(ip: str) -> tuple[str | None, str | None]:
|
||||
"""Resolve IP to (country, city) via ip-api.com. Returns (None, None) on failure."""
|
||||
if not ip or any(ip.startswith(p) for p in _PRIVATE_PREFIXES):
|
||||
return None, None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
r = await client.get(
|
||||
f"http://ip-api.com/json/{ip}",
|
||||
params={"fields": "status,country,city"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
d = r.json()
|
||||
if d.get("status") == "success":
|
||||
return d.get("country"), d.get("city")
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
@router.post("/api/web/event", status_code=201)
|
||||
@router.post("/api/analytics/event", status_code=201)
|
||||
@limiter.limit("60/minute")
|
||||
async def ingest_event(
|
||||
request: Request,
|
||||
response: Response,
|
||||
data: EventCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Record a telemetry event. Public — no auth required."""
|
||||
raw_ip = _get_client_ip(request)
|
||||
|
||||
ip_hash = hashlib.sha256(raw_ip.encode()).hexdigest()[:16] if raw_ip else None
|
||||
ip_partial = _mask_ip(raw_ip) if raw_ip else None
|
||||
|
||||
ua_string = request.headers.get("User-Agent", "")
|
||||
browser, os_name = _parse_ua(ua_string)
|
||||
|
||||
country, city = await _geo_lookup(raw_ip or "")
|
||||
session_id = _get_or_create_session_id(request, response, data.session_id)
|
||||
|
||||
metadata = _sanitize_client_metadata(data.metadata) or {}
|
||||
referer = request.headers.get("referer")
|
||||
if referer:
|
||||
metadata["referrer"] = referer[:255]
|
||||
|
||||
normalized = data.model_copy(update={
|
||||
"session_id": session_id,
|
||||
"metadata": metadata or None,
|
||||
})
|
||||
|
||||
await record_event(
|
||||
db, normalized,
|
||||
ip_hash=ip_hash,
|
||||
ip_partial=ip_partial,
|
||||
user_agent=ua_string[:512] if ua_string else None,
|
||||
browser=browser,
|
||||
os_name=os_name,
|
||||
country=country,
|
||||
city=city,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/api/v1/analytics/summary", response_model=AnalyticsSummary)
|
||||
async def analytics_summary(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
"""Return analytics summary. Auth required."""
|
||||
return await get_summary(db)
|
||||
|
||||
|
||||
@router.get("/api/v1/analytics/bookings-summary", response_model=BookingOperationsSummary)
|
||||
async def booking_operations_summary(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
"""Return booking operations reporting. Auth required."""
|
||||
return await get_booking_operations_summary(db)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Audit router.
|
||||
|
||||
Admin:
|
||||
GET /admin/audit — paginated, filtered audit log (admin-authenticated)
|
||||
|
||||
Member:
|
||||
POST /members/audit/page-visit — record a page navigation (member-authenticated)
|
||||
"""
|
||||
import math
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.auth.member_deps import get_authenticated_member
|
||||
from app.database import get_db
|
||||
from app.middleware.rate_limit import limiter
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.member import Member
|
||||
from app.models.user import User
|
||||
from app.schemas.audit import AuditLogPage, AuditLogResponse, PageVisitSchema
|
||||
from app.services.audit import log_audit
|
||||
from app.services.settings import get_feature_settings_snapshot
|
||||
|
||||
router = APIRouter(tags=["Audit"])
|
||||
|
||||
|
||||
async def _require_audit_history_enabled(db: AsyncSession) -> None:
|
||||
feature_settings = await get_feature_settings_snapshot(db)
|
||||
if not feature_settings.audit_history_enabled:
|
||||
raise HTTPException(status_code=404, detail="Audit history is currently disabled.")
|
||||
|
||||
|
||||
# ── Admin: query audit log ─────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/admin/audit", response_model=AuditLogPage)
|
||||
async def admin_list_audit(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
member_id: Optional[uuid.UUID] = Query(None),
|
||||
action_type: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
area: Optional[str] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
sort_by: str = Query("timestamp"),
|
||||
sort_dir: str = Query("desc"),
|
||||
_admin: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _require_audit_history_enabled(db)
|
||||
|
||||
allowed_sort = {"timestamp", "action_type", "status", "area", "member_email"}
|
||||
if sort_by not in allowed_sort:
|
||||
sort_by = "timestamp"
|
||||
|
||||
col = getattr(AuditLog, sort_by)
|
||||
order = col.desc() if sort_dir == "desc" else col.asc()
|
||||
|
||||
conditions = []
|
||||
if member_id is not None:
|
||||
conditions.append(AuditLog.member_id == member_id)
|
||||
if action_type:
|
||||
conditions.append(AuditLog.action_type == action_type)
|
||||
if status:
|
||||
conditions.append(AuditLog.status == status)
|
||||
if area:
|
||||
conditions.append(AuditLog.area.ilike(f"%{area}%"))
|
||||
if date_from:
|
||||
conditions.append(AuditLog.timestamp >= date_from)
|
||||
if date_to:
|
||||
conditions.append(AuditLog.timestamp <= date_to)
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
AuditLog.member_email.ilike(term),
|
||||
AuditLog.description.ilike(term),
|
||||
AuditLog.area.ilike(term),
|
||||
AuditLog.action_type.ilike(term),
|
||||
AuditLog.error_message.ilike(term),
|
||||
)
|
||||
)
|
||||
|
||||
base_q = select(AuditLog)
|
||||
if conditions:
|
||||
from sqlalchemy import and_
|
||||
base_q = base_q.where(and_(*conditions))
|
||||
|
||||
count_result = await db.execute(select(func.count()).select_from(base_q.subquery()))
|
||||
total = count_result.scalar_one()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
items_result = await db.execute(base_q.order_by(order).offset(offset).limit(page_size))
|
||||
items = items_result.scalars().all()
|
||||
|
||||
return AuditLogPage(
|
||||
items=[AuditLogResponse.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=max(1, math.ceil(total / page_size)),
|
||||
)
|
||||
|
||||
|
||||
# ── Member: page visit ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/members/audit/page-visit", status_code=204)
|
||||
@limiter.limit("120/minute")
|
||||
async def member_log_page_visit(
|
||||
request: Request,
|
||||
response: Response,
|
||||
data: PageVisitSchema,
|
||||
member: Member = Depends(get_authenticated_member),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
feature_settings = await get_feature_settings_snapshot(db)
|
||||
if not feature_settings.audit_history_enabled:
|
||||
return
|
||||
|
||||
path = data.path[:255] if data.path else "unknown"
|
||||
title = data.title or path
|
||||
|
||||
await log_audit(
|
||||
db,
|
||||
member_id=member.id,
|
||||
member_email=member.email,
|
||||
action_type="page_visit",
|
||||
area=path,
|
||||
description=f"Visited: {title}",
|
||||
status="success",
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.jwt import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
hash_refresh_token,
|
||||
get_token_expiry,
|
||||
)
|
||||
from app.auth.password import verify_password
|
||||
from app.database import get_db
|
||||
from app.middleware.rate_limit import limiter
|
||||
from app.models.user import User, RefreshToken
|
||||
from app.schemas.auth import LoginRequest, TokenResponse, RefreshRequest
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
@limiter.limit("5/minute")
|
||||
async def login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
data: LoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Authenticate with email and password.
|
||||
Returns access token (15 min) and refresh token (7 days).
|
||||
"""
|
||||
result = await db.execute(select(User).where(User.email == data.email))
|
||||
user = result.scalars().first()
|
||||
|
||||
if user is None or not verify_password(data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Account is inactive",
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
plaintext_refresh, refresh_hash = create_refresh_token()
|
||||
|
||||
refresh_token_row = RefreshToken(
|
||||
user_id=user.id,
|
||||
token_hash=refresh_hash,
|
||||
expires_at=get_token_expiry(),
|
||||
revoked=False,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(refresh_token_row)
|
||||
await db.flush()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=plaintext_refresh,
|
||||
token_type="bearer",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
@limiter.limit("5/minute")
|
||||
async def refresh_tokens(
|
||||
request: Request,
|
||||
response: Response,
|
||||
data: RefreshRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Exchange a valid refresh token for a new token pair.
|
||||
The old refresh token is revoked atomically.
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token_hash = hash_refresh_token(data.refresh_token)
|
||||
result = await db.execute(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.token_hash == token_hash,
|
||||
RefreshToken.revoked == False,
|
||||
RefreshToken.expires_at > now,
|
||||
)
|
||||
)
|
||||
matched_row = result.scalars().first()
|
||||
|
||||
if matched_row is None:
|
||||
raise credentials_exception
|
||||
|
||||
# Revoke old token
|
||||
matched_row.revoked = True
|
||||
|
||||
# Load user
|
||||
result = await db.execute(select(User).where(User.id == matched_row.user_id))
|
||||
user = result.scalars().first()
|
||||
|
||||
if user is None or not user.is_active:
|
||||
raise credentials_exception
|
||||
|
||||
# Issue new tokens
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
plaintext_refresh, refresh_hash = create_refresh_token()
|
||||
|
||||
new_refresh_row = RefreshToken(
|
||||
user_id=user.id,
|
||||
token_hash=refresh_hash,
|
||||
expires_at=get_token_expiry(),
|
||||
revoked=False,
|
||||
created_at=now,
|
||||
)
|
||||
db.add(new_refresh_row)
|
||||
await db.flush()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=plaintext_refresh,
|
||||
token_type="bearer",
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.middleware.rate_limit import limiter
|
||||
from app.models.contact_lead import ContactLead
|
||||
from app.models.member import Member, MagicLinkToken
|
||||
from app.models.user import User
|
||||
from app.schemas.contact import (
|
||||
ContactLeadCreate,
|
||||
ContactLeadInviteRequest,
|
||||
ContactLeadInviteResponse,
|
||||
ContactLeadResponse,
|
||||
ContactLeadUpdate,
|
||||
)
|
||||
from app.services.email import send_onboarding_invite
|
||||
|
||||
router = APIRouter(tags=["Contact Leads"])
|
||||
|
||||
|
||||
def _split_name(full_name: str) -> tuple[str, str]:
|
||||
parts = [part for part in full_name.strip().split() if part]
|
||||
if not parts:
|
||||
return "Goodwalk", "Client"
|
||||
if len(parts) == 1:
|
||||
return parts[0], "Client"
|
||||
return parts[0], " ".join(parts[1:])
|
||||
|
||||
|
||||
def _normalise_services(payload: ContactLeadCreate) -> str | None:
|
||||
if payload.services:
|
||||
return ", ".join(payload.services)
|
||||
if payload.service:
|
||||
return payload.service.strip() or None
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/api/contact", response_model=ContactLeadResponse, status_code=201)
|
||||
@limiter.limit("10/minute")
|
||||
async def submit_contact_lead(
|
||||
request: Request,
|
||||
response: Response,
|
||||
data: ContactLeadCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
lead = ContactLead(
|
||||
full_name=data.name.strip(),
|
||||
email=data.email.strip().lower(),
|
||||
phone=(data.phone or "").strip() or None,
|
||||
requested_services=_normalise_services(data),
|
||||
pet_name=(data.petName or "").strip() or None,
|
||||
pet_breed=(data.petBreed or "").strip() or None,
|
||||
suburb=(data.location or "").strip() or None,
|
||||
service_area_status=(data.serviceAreaStatus or "").strip() or None,
|
||||
message=(data.message or "").strip() or None,
|
||||
source=data.source,
|
||||
status="invite",
|
||||
metadata_json={
|
||||
"services": data.services,
|
||||
"service": data.service,
|
||||
},
|
||||
)
|
||||
db.add(lead)
|
||||
await db.flush()
|
||||
await db.refresh(lead)
|
||||
return lead
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/leads", response_model=list[ContactLeadResponse])
|
||||
async def admin_list_leads(
|
||||
_admin: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(ContactLead).order_by(ContactLead.created_at.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.put("/api/v1/admin/leads/{lead_id}", response_model=ContactLeadResponse)
|
||||
async def admin_update_lead(
|
||||
lead_id: uuid.UUID,
|
||||
data: ContactLeadUpdate,
|
||||
_admin: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(ContactLead).where(ContactLead.id == lead_id))
|
||||
lead = result.scalars().first()
|
||||
if lead is None:
|
||||
raise HTTPException(status_code=404, detail="Lead not found.")
|
||||
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(lead, field, value)
|
||||
await db.flush()
|
||||
await db.refresh(lead)
|
||||
return lead
|
||||
|
||||
|
||||
@router.post("/api/v1/admin/leads/{lead_id}/invite", response_model=ContactLeadInviteResponse)
|
||||
async def admin_invite_lead(
|
||||
lead_id: uuid.UUID,
|
||||
data: ContactLeadInviteRequest,
|
||||
_admin: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(ContactLead).where(ContactLead.id == lead_id))
|
||||
lead = result.scalars().first()
|
||||
if lead is None:
|
||||
raise HTTPException(status_code=404, detail="Lead not found.")
|
||||
|
||||
existing_member_result = await db.execute(select(Member).where(Member.email == lead.email))
|
||||
member = existing_member_result.scalars().first()
|
||||
|
||||
if member is None:
|
||||
first_name, last_name = _split_name(lead.full_name)
|
||||
member = Member(
|
||||
email=lead.email,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
phone=lead.phone,
|
||||
address=lead.suburb,
|
||||
onboarding_data={
|
||||
"dog_name": lead.pet_name,
|
||||
"dog_breed": lead.pet_breed,
|
||||
"preferred_service": lead.requested_services,
|
||||
"lead_message": lead.message,
|
||||
"service_area_status": lead.service_area_status,
|
||||
"source": lead.source,
|
||||
},
|
||||
is_claimed=False,
|
||||
is_active=True,
|
||||
member_status="invited",
|
||||
)
|
||||
db.add(member)
|
||||
await db.flush()
|
||||
|
||||
lead.invited_member_id = member.id
|
||||
lead.invited_at = datetime.now(timezone.utc)
|
||||
lead.status = "invited"
|
||||
await db.flush()
|
||||
await db.refresh(lead)
|
||||
await db.refresh(member)
|
||||
|
||||
if data.send_email:
|
||||
plaintext_token = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(plaintext_token.encode()).hexdigest()
|
||||
magic_token = MagicLinkToken(
|
||||
member_id=member.id,
|
||||
token_hash=token_hash,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
)
|
||||
db.add(magic_token)
|
||||
await db.flush()
|
||||
magic_url = f"{settings.MEMBERS_URL.rstrip('/')}/join?token={plaintext_token}"
|
||||
await send_onboarding_invite(lead.email, member.first_name, magic_url)
|
||||
|
||||
return ContactLeadInviteResponse(
|
||||
lead=ContactLeadResponse.model_validate(lead),
|
||||
member_id=member.id,
|
||||
member_status=member.member_status,
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.database import get_db
|
||||
from app.middleware.rate_limit import limiter
|
||||
from app.schemas.experiments import (
|
||||
ExperimentConversionCreate,
|
||||
ExperimentDefinitionResponse,
|
||||
ExperimentDefinitionUpdate,
|
||||
ExperimentEventCreate,
|
||||
ExperimentImpressionCreate,
|
||||
ExperimentIngestResponse,
|
||||
ExperimentResult,
|
||||
)
|
||||
from app.services.experiments import (
|
||||
experiment_exists,
|
||||
get_experiment_definition,
|
||||
get_experiment_results,
|
||||
list_experiment_definitions,
|
||||
record_experiment_event,
|
||||
upsert_experiment_definition,
|
||||
)
|
||||
from app.services.settings import get_feature_settings_snapshot
|
||||
|
||||
router = APIRouter(tags=["Experiments"])
|
||||
BOT_UA_PATTERN = re.compile(r"(bot|crawler|spider|slurp|preview|headless)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_bot_request(request: Request) -> bool:
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
return bool(BOT_UA_PATTERN.search(user_agent))
|
||||
|
||||
|
||||
def _validate_experiment_assignment(experiment_key: str, variant_key: str) -> None:
|
||||
if not experiment_exists(experiment_key, variant_key):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="unknown experiment or variant",
|
||||
)
|
||||
|
||||
|
||||
async def _experiments_enabled(db: AsyncSession) -> bool:
|
||||
feature_settings = await get_feature_settings_snapshot(db)
|
||||
return feature_settings.experiments_enabled
|
||||
|
||||
|
||||
async def _require_experiments_enabled(db: AsyncSession) -> None:
|
||||
if not await _experiments_enabled(db):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Experiments are currently disabled.")
|
||||
|
||||
|
||||
@router.get("/api/experiments", response_model=list[ExperimentDefinitionResponse])
|
||||
async def get_experiments(db: AsyncSession = Depends(get_db)):
|
||||
if not await _experiments_enabled(db):
|
||||
return []
|
||||
return await list_experiment_definitions(db)
|
||||
|
||||
|
||||
@router.post("/api/experiments/impression", response_model=ExperimentIngestResponse, status_code=202)
|
||||
@limiter.limit("30/minute")
|
||||
async def ingest_experiment_impression(
|
||||
request: Request,
|
||||
response: Response,
|
||||
payload: ExperimentImpressionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not await _experiments_enabled(db):
|
||||
return ExperimentIngestResponse(ok=True, accepted=False)
|
||||
|
||||
_validate_experiment_assignment(payload.experiment_key, payload.variant_key)
|
||||
|
||||
if _is_bot_request(request):
|
||||
return ExperimentIngestResponse(ok=True, accepted=False)
|
||||
|
||||
await record_experiment_event(db, payload)
|
||||
return ExperimentIngestResponse(ok=True, accepted=True)
|
||||
|
||||
|
||||
@router.post("/api/experiments/event", response_model=ExperimentIngestResponse, status_code=202)
|
||||
@limiter.limit("30/minute")
|
||||
async def ingest_experiment_event(
|
||||
request: Request,
|
||||
response: Response,
|
||||
payload: ExperimentEventCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not await _experiments_enabled(db):
|
||||
return ExperimentIngestResponse(ok=True, accepted=False)
|
||||
|
||||
_validate_experiment_assignment(payload.experiment_key, payload.variant_key)
|
||||
|
||||
if _is_bot_request(request):
|
||||
return ExperimentIngestResponse(ok=True, accepted=False)
|
||||
|
||||
await record_experiment_event(db, payload)
|
||||
return ExperimentIngestResponse(ok=True, accepted=True)
|
||||
|
||||
|
||||
@router.post("/api/experiments/conversion", response_model=ExperimentIngestResponse, status_code=202)
|
||||
@limiter.limit("30/minute")
|
||||
async def ingest_experiment_conversion(
|
||||
request: Request,
|
||||
response: Response,
|
||||
payload: ExperimentConversionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not await _experiments_enabled(db):
|
||||
return ExperimentIngestResponse(ok=True, accepted=False)
|
||||
|
||||
_validate_experiment_assignment(payload.experiment_key, payload.variant_key)
|
||||
|
||||
if _is_bot_request(request):
|
||||
return ExperimentIngestResponse(ok=True, accepted=False)
|
||||
|
||||
await record_experiment_event(db, payload)
|
||||
return ExperimentIngestResponse(ok=True, accepted=True)
|
||||
|
||||
|
||||
@router.get("/api/v1/experiments/results", response_model=list[ExperimentResult])
|
||||
async def experiment_results(
|
||||
experiment_key: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
await _require_experiments_enabled(db)
|
||||
return await get_experiment_results(db, experiment_key)
|
||||
|
||||
|
||||
@router.get("/api/admin/experiments", response_model=list[ExperimentDefinitionResponse])
|
||||
async def admin_list_experiments(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
await _require_experiments_enabled(db)
|
||||
return await list_experiment_definitions(db)
|
||||
|
||||
|
||||
@router.get("/api/admin/experiments/{experiment_key}", response_model=ExperimentDefinitionResponse)
|
||||
async def admin_get_experiment(
|
||||
experiment_key: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
await _require_experiments_enabled(db)
|
||||
experiment = await get_experiment_definition(db, experiment_key)
|
||||
if experiment is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Experiment not found")
|
||||
|
||||
definitions = await list_experiment_definitions(db)
|
||||
match = next((item for item in definitions if item.experiment_key == experiment_key), None)
|
||||
assert match is not None
|
||||
return match
|
||||
|
||||
|
||||
@router.put("/api/admin/experiments/{experiment_key}", response_model=ExperimentDefinitionResponse)
|
||||
async def admin_update_experiment(
|
||||
experiment_key: str,
|
||||
payload: ExperimentDefinitionUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
await _require_experiments_enabled(db)
|
||||
try:
|
||||
experiment = await upsert_experiment_definition(db, experiment_key, payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
return ExperimentDefinitionResponse(
|
||||
experiment_key=experiment.experiment_key,
|
||||
cookie_name=experiment.cookie_name,
|
||||
name=experiment.name,
|
||||
description=experiment.description,
|
||||
enabled=experiment.enabled,
|
||||
eligible_routes=experiment.eligible_routes,
|
||||
variants=[
|
||||
{
|
||||
"variant_key": variant.variant_key,
|
||||
"label": variant.label,
|
||||
"allocation": variant.allocation,
|
||||
"is_control": variant.is_control,
|
||||
}
|
||||
for variant in experiment.variants
|
||||
],
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.page import PageCreate, PageUpdate, PageResponse
|
||||
from app.services import pages as page_service
|
||||
from typing import List
|
||||
|
||||
router = APIRouter(prefix="/pages", tags=["Pages"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[PageResponse])
|
||||
async def list_pages(db: AsyncSession = Depends(get_db)):
|
||||
"""List all published pages."""
|
||||
pages = await page_service.get_published_pages(db)
|
||||
return [PageResponse.model_validate(p) for p in pages]
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=PageResponse)
|
||||
async def get_page(slug: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a single published page by slug."""
|
||||
page = await page_service.get_page_by_slug(db, slug, published_only=True)
|
||||
if page is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Page '{slug}' not found")
|
||||
return PageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.post("", response_model=PageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_page(
|
||||
data: PageCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new page (auth required)."""
|
||||
page = await page_service.create_page(db, data)
|
||||
return PageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.put("/{slug}", response_model=PageResponse)
|
||||
async def update_page(
|
||||
slug: str,
|
||||
data: PageUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update a page by slug (auth required)."""
|
||||
page = await page_service.update_page(db, slug, data)
|
||||
if page is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Page '{slug}' not found")
|
||||
return PageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.delete("/{slug}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_page(
|
||||
slug: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a page by slug (auth required)."""
|
||||
deleted = await page_service.delete_page(db, slug)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Page '{slug}' not found")
|
||||
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.post import PostCreate, PostUpdate, PostResponse, PaginatedPostsResponse
|
||||
from app.services import posts as post_service
|
||||
|
||||
router = APIRouter(prefix="/posts", tags=["Posts"])
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedPostsResponse)
|
||||
async def list_posts(
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=10, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List published posts with pagination."""
|
||||
return await post_service.get_published_posts(db, page=page, per_page=per_page)
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=PostResponse)
|
||||
async def get_post(slug: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a single published post by slug."""
|
||||
post = await post_service.get_post_by_slug(db, slug, published_only=True)
|
||||
if post is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Post '{slug}' not found")
|
||||
return PostResponse.model_validate(post)
|
||||
|
||||
|
||||
@router.post("", response_model=PostResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_post(
|
||||
data: PostCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new blog post (auth required)."""
|
||||
post = await post_service.create_post(db, data)
|
||||
return PostResponse.model_validate(post)
|
||||
|
||||
|
||||
@router.put("/{slug}", response_model=PostResponse)
|
||||
async def update_post(
|
||||
slug: str,
|
||||
data: PostUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update a post by slug (auth required)."""
|
||||
post = await post_service.update_post(db, slug, data)
|
||||
if post is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Post '{slug}' not found")
|
||||
return PostResponse.model_validate(post)
|
||||
|
||||
|
||||
@router.delete("/{slug}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_post(
|
||||
slug: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a post by slug (auth required)."""
|
||||
deleted = await post_service.delete_post(db, slug)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Post '{slug}' not found")
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Legacy-compatible content section endpoints.
|
||||
Matches the URL shapes the SvelteKit frontend already calls,
|
||||
so no frontend changes are needed.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.sections import get_section, upsert_section, list_sections
|
||||
|
||||
router = APIRouter(tags=["Sections"])
|
||||
|
||||
# Slug → content_sections key
|
||||
PAGE_SLUG_MAP = {
|
||||
"home": "pages.home",
|
||||
"pack-walks": "pages.packWalks",
|
||||
"1-1-walks": "pages.oneOnOneWalks",
|
||||
"puppy-visits": "pages.puppyVisits",
|
||||
"pricing": "pages.pricing",
|
||||
"about": "pages.about",
|
||||
"contact": "pages.contact",
|
||||
}
|
||||
|
||||
|
||||
# ── Public read endpoints ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/site-settings")
|
||||
async def site_settings(db: AsyncSession = Depends(get_db)):
|
||||
data = await get_section(db, "siteSettings")
|
||||
return data or {}
|
||||
|
||||
|
||||
@router.get("/api/navigation")
|
||||
async def navigation(db: AsyncSession = Depends(get_db)):
|
||||
data = await get_section(db, "navigation")
|
||||
return data or {"items": []}
|
||||
|
||||
|
||||
@router.get("/api/footer")
|
||||
async def footer(db: AsyncSession = Depends(get_db)):
|
||||
data = await get_section(db, "footer")
|
||||
return data or {}
|
||||
|
||||
|
||||
@router.get("/api/testimonials")
|
||||
async def testimonials(db: AsyncSession = Depends(get_db)):
|
||||
data = await get_section(db, "testimonials")
|
||||
return data if data is not None else []
|
||||
|
||||
|
||||
@router.get("/api/onboarding")
|
||||
async def onboarding(db: AsyncSession = Depends(get_db)):
|
||||
data = await get_section(db, "onboarding")
|
||||
return data or {}
|
||||
|
||||
|
||||
@router.get("/api/pages/{slug}")
|
||||
async def page_by_slug(slug: str, db: AsyncSession = Depends(get_db)):
|
||||
key = PAGE_SLUG_MAP.get(slug)
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail=f"Page '{slug}' not found")
|
||||
data = await get_section(db, key)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail=f"Page '{slug}' not found")
|
||||
return data
|
||||
|
||||
|
||||
# ── Protected admin endpoints ────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/admin/sections")
|
||||
async def admin_list_sections(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
):
|
||||
return await list_sections(db)
|
||||
|
||||
|
||||
@router.get("/api/admin/sections/{key:path}")
|
||||
async def admin_get_section(
|
||||
key: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
):
|
||||
data = await get_section(db, key)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="Section not found")
|
||||
return {"key": key, "data": data}
|
||||
|
||||
|
||||
@router.put("/api/admin/sections/{key:path}")
|
||||
async def admin_update_section(
|
||||
key: str,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
):
|
||||
row = await upsert_section(db, key, body)
|
||||
return {"success": True, "key": row.key}
|
||||
@@ -0,0 +1,202 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.settings import (
|
||||
FeatureSettingsResponse,
|
||||
FeatureSettingsUpdate,
|
||||
PlannerWeatherResponse,
|
||||
ServicePricingSettingsResponse,
|
||||
ServicePricingSettingsUpdate,
|
||||
SiteSettingsResponse,
|
||||
SiteSettingsUpdate,
|
||||
)
|
||||
from app.services import settings as settings_service
|
||||
from app.services.settings import FeatureSettingsSchemaOutdatedError, ServicePricingSchemaOutdatedError
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["Settings"])
|
||||
PLANNER_WEATHER_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
PLANNER_WEATHER_TTL = timedelta(hours=1)
|
||||
PLANNER_WEATHER_CACHE = {
|
||||
"fetched_at": datetime.min.replace(tzinfo=UTC),
|
||||
"weather": {},
|
||||
}
|
||||
|
||||
|
||||
async def _load_planner_weather_snapshot() -> tuple[datetime, dict[str, dict[str, int]]]:
|
||||
fetched_at = PLANNER_WEATHER_CACHE["fetched_at"]
|
||||
cached_weather = PLANNER_WEATHER_CACHE["weather"]
|
||||
now = datetime.now(UTC)
|
||||
|
||||
if cached_weather and now - fetched_at < PLANNER_WEATHER_TTL:
|
||||
return fetched_at, cached_weather
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(
|
||||
PLANNER_WEATHER_URL,
|
||||
params={
|
||||
"latitude": -36.85,
|
||||
"longitude": 174.77,
|
||||
"daily": "weathercode,temperature_2m_max,temperature_2m_min",
|
||||
"timezone": "Pacific/Auckland",
|
||||
"forecast_days": 16,
|
||||
"past_days": 14,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except httpx.HTTPError:
|
||||
if cached_weather:
|
||||
return fetched_at, cached_weather
|
||||
raise
|
||||
|
||||
next_weather: dict[str, dict[str, int]] = {}
|
||||
daily = payload.get("daily") or {}
|
||||
dates = daily.get("time") or []
|
||||
codes = daily.get("weathercode") or []
|
||||
highs = daily.get("temperature_2m_max") or []
|
||||
lows = daily.get("temperature_2m_min") or []
|
||||
|
||||
for index, date_key in enumerate(dates):
|
||||
if index >= len(codes) or index >= len(highs) or index >= len(lows):
|
||||
continue
|
||||
next_weather[date_key] = {
|
||||
"code": int(codes[index]),
|
||||
"max": round(highs[index]),
|
||||
"min": round(lows[index]),
|
||||
}
|
||||
|
||||
fetched_at = now
|
||||
PLANNER_WEATHER_CACHE["fetched_at"] = fetched_at
|
||||
PLANNER_WEATHER_CACHE["weather"] = next_weather
|
||||
return fetched_at, next_weather
|
||||
|
||||
|
||||
def _filter_planner_weather(
|
||||
weather: dict[str, dict[str, int]],
|
||||
start_date: str | None,
|
||||
end_date: str | None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
if not start_date and not end_date:
|
||||
return weather
|
||||
|
||||
filtered: dict[str, dict[str, int]] = {}
|
||||
for key, value in weather.items():
|
||||
if start_date and key < start_date:
|
||||
continue
|
||||
if end_date and key > end_date:
|
||||
continue
|
||||
filtered[key] = value
|
||||
return filtered
|
||||
|
||||
|
||||
@router.get("", response_model=SiteSettingsResponse)
|
||||
async def get_settings(db: AsyncSession = Depends(get_db)):
|
||||
"""Get site settings singleton."""
|
||||
row = await settings_service.get_settings(db)
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Site settings have not been configured yet. Run seed.py to initialise.",
|
||||
)
|
||||
return SiteSettingsResponse.model_validate(row)
|
||||
|
||||
|
||||
@router.put("", response_model=SiteSettingsResponse)
|
||||
async def update_settings(
|
||||
data: SiteSettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create or update site settings singleton (auth required)."""
|
||||
row = await settings_service.upsert_settings(db, data)
|
||||
return SiteSettingsResponse.model_validate(row)
|
||||
|
||||
|
||||
@router.get("/features", response_model=FeatureSettingsResponse)
|
||||
async def get_feature_settings(db: AsyncSession = Depends(get_db)):
|
||||
snapshot = await settings_service.get_feature_settings_snapshot(db)
|
||||
return FeatureSettingsResponse(
|
||||
bookings_enabled=snapshot.bookings_enabled,
|
||||
walks_enabled=snapshot.walks_enabled,
|
||||
messages_enabled=snapshot.messages_enabled,
|
||||
two_factor_enabled=snapshot.two_factor_enabled,
|
||||
audit_history_enabled=snapshot.audit_history_enabled,
|
||||
experiments_enabled=snapshot.experiments_enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/features", response_model=FeatureSettingsResponse)
|
||||
async def update_feature_settings(
|
||||
data: FeatureSettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
del current_user
|
||||
try:
|
||||
snapshot = await settings_service.update_feature_settings_snapshot(db, data)
|
||||
except FeatureSettingsSchemaOutdatedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
return FeatureSettingsResponse(
|
||||
bookings_enabled=snapshot.bookings_enabled,
|
||||
walks_enabled=snapshot.walks_enabled,
|
||||
messages_enabled=snapshot.messages_enabled,
|
||||
two_factor_enabled=snapshot.two_factor_enabled,
|
||||
audit_history_enabled=snapshot.audit_history_enabled,
|
||||
experiments_enabled=snapshot.experiments_enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pricing", response_model=ServicePricingSettingsResponse)
|
||||
async def get_service_pricing(db: AsyncSession = Depends(get_db)):
|
||||
snapshot = await settings_service.get_service_pricing_snapshot(db)
|
||||
return ServicePricingSettingsResponse(service_pricing=snapshot)
|
||||
|
||||
|
||||
@router.put("/pricing", response_model=ServicePricingSettingsResponse)
|
||||
async def update_service_pricing(
|
||||
data: ServicePricingSettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
del current_user
|
||||
try:
|
||||
snapshot = await settings_service.update_service_pricing_snapshot(
|
||||
db,
|
||||
service_pricing=data.service_pricing,
|
||||
)
|
||||
except ServicePricingSchemaOutdatedError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
return ServicePricingSettingsResponse(service_pricing=snapshot)
|
||||
|
||||
|
||||
@router.get("/planner-weather", response_model=PlannerWeatherResponse)
|
||||
async def get_planner_weather(
|
||||
start_date: str | None = Query(default=None),
|
||||
end_date: str | None = Query(default=None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
del current_user
|
||||
|
||||
for value, label in ((start_date, "start_date"), (end_date, "end_date")):
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
datetime.strptime(value, "%Y-%m-%d")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{label} must use YYYY-MM-DD format.",
|
||||
) from exc
|
||||
|
||||
fetched_at, weather = await _load_planner_weather_snapshot()
|
||||
return PlannerWeatherResponse(
|
||||
fetched_at=fetched_at,
|
||||
weather=_filter_planner_weather(weather, start_date, end_date),
|
||||
)
|
||||
Reference in New Issue
Block a user