kitchen/backend/api/public.py
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

118 lines
3.9 KiB
Python

"""
Public API endpoints - NO AUTHENTICATION REQUIRED.
These endpoints are designed for sharing with external parties (e.g., suppliers)
via hash-based URLs that don't require login.
"""
import os
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from fastapi import Depends
from database import get_db
from models.dispute import DisputeAttachment
from models.settings import KitchenSettings
from services.nextcloud_service import NextcloudService
router = APIRouter()
@router.get("/attachments/{public_hash}")
async def get_public_attachment(
public_hash: str,
db: AsyncSession = Depends(get_db)
):
"""
View a dispute attachment publicly via its hash.
This endpoint does NOT require authentication, allowing suppliers
to view attached images/documents via shareable links in emails.
"""
# Find attachment by public hash
result = await db.execute(
select(DisputeAttachment).where(DisputeAttachment.public_hash == public_hash)
)
attachment = result.scalar_one_or_none()
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
# Get file content
content = None
# Try local file first
if attachment.file_storage_location == "local" and attachment.file_path:
if os.path.exists(attachment.file_path):
with open(attachment.file_path, 'rb') as f:
content = f.read()
# Try Nextcloud if local not found
if content is None and attachment.file_storage_location == "nextcloud" and attachment.nextcloud_path:
# Get kitchen settings for Nextcloud credentials
settings_result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == attachment.kitchen_id)
)
settings = settings_result.scalar_one_or_none()
if settings and settings.nextcloud_enabled:
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
""
)
success, nc_content = await nc.download_file(attachment.nextcloud_path)
await nc.close()
if success:
content = nc_content
if content is None:
raise HTTPException(status_code=404, detail="File not found")
# Determine if browser should display inline or download
# Images and PDFs display inline, others download
inline_types = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf'
]
disposition = "inline" if attachment.file_type in inline_types else "attachment"
return Response(
content=content,
media_type=attachment.file_type,
headers={
"Content-Disposition": f'{disposition}; filename="{attachment.file_name}"',
"Cache-Control": "private, max-age=3600" # Cache for 1 hour
}
)
@router.get("/attachments/{public_hash}/info")
async def get_public_attachment_info(
public_hash: str,
db: AsyncSession = Depends(get_db)
):
"""
Get attachment metadata without downloading the file.
Useful for email previews or link unfurling.
"""
result = await db.execute(
select(DisputeAttachment).where(DisputeAttachment.public_hash == public_hash)
)
attachment = result.scalar_one_or_none()
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
return {
"file_name": attachment.file_name,
"file_type": attachment.file_type,
"file_size_bytes": attachment.file_size_bytes,
"attachment_type": attachment.attachment_type,
"description": attachment.description,
"uploaded_at": attachment.uploaded_at.isoformat() if attachment.uploaded_at else None
}