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>
118 lines
3.9 KiB
Python
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
|
|
}
|