This commit is contained in:
ponzischeme89
2026-04-18 07:23:55 +12:00
parent f210020772
commit 6d44e05de4
396 changed files with 75296 additions and 0 deletions
View File
+202
View File
@@ -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)
+140
View File
@@ -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"),
)
+129
View File
@@ -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",
)
+167
View File
@@ -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,
)
+187
View File
@@ -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
+64
View File
@@ -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")
+66
View File
@@ -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")
+101
View File
@@ -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}
+202
View File
@@ -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),
)