tracker/src/tracker/api/project_files.py
markov 39dde0e2d8
Some checks failed
Deploy Tracker / deploy (push) Failing after 4s
fix: consistent delete response format
2026-02-25 10:37:47 +01:00

292 lines
8.5 KiB
Python

"""Project Files API — upload/download project files."""
import os
import uuid
import mimetypes
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, Query, Form
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from tracker.database import get_db
from tracker.models import ProjectFile, Project, Member
router = APIRouter(tags=["project-files"])
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
PROJECTS_DIR = os.environ.get("PROJECTS_DIR", "/data/projects")
# --- Schemas ---
class ProjectFileOut(BaseModel):
id: str
filename: str
description: str | None = None
mime_type: str | None = None
size: int
uploaded_by: str
uploader: dict | None = None # Member object for display
created_at: str
updated_at: str
class ProjectFileUpdate(BaseModel):
description: str | None = None
# --- Helpers ---
def _get_member(request: Request) -> Member:
member = getattr(request.state, "member", None)
if not member:
raise HTTPException(401, "Not authenticated")
return member
async def _get_project(slug: str, db: AsyncSession) -> Project:
"""Get project by slug."""
result = await db.execute(select(Project).where(Project.slug == slug))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
return project
def _file_out(f: ProjectFile) -> dict:
"""Convert ProjectFile to dict for API response."""
return {
"id": str(f.id),
"filename": f.filename,
"description": f.description,
"mime_type": f.mime_type,
"size": f.size,
"uploaded_by": str(f.uploaded_by),
"uploader": {"id": str(f.uploader.id), "slug": f.uploader.slug, "name": f.uploader.name} if f.uploader else None,
"created_at": f.created_at.isoformat() if f.created_at else "",
"updated_at": f.updated_at.isoformat() if f.updated_at else "",
}
# --- Endpoints ---
@router.get("/projects/{slug}/files", response_model=list[ProjectFileOut])
async def list_project_files(
slug: str,
search: Optional[str] = Query(None, description="Search by filename"),
db: AsyncSession = Depends(get_db),
):
"""Get list of project files with optional search by filename."""
project = await _get_project(slug, db)
q = select(ProjectFile).where(ProjectFile.project_id == project.id).options(selectinload(ProjectFile.uploader))
if search:
# Search by filename (case insensitive)
q = q.where(ProjectFile.filename.ilike(f"%{search}%"))
q = q.order_by(ProjectFile.created_at.desc())
result = await db.execute(q)
files = result.scalars().all()
return [_file_out(f) for f in files]
@router.post("/projects/{slug}/files", response_model=ProjectFileOut)
async def upload_project_file(
request: Request,
slug: str,
file: UploadFile = File(...),
description: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Upload a file to project. If file with same name exists, update it."""
member = _get_member(request)
project = await _get_project(slug, db)
# Read file
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(413, f"File too large (max {MAX_FILE_SIZE // 1024 // 1024}MB)")
if not file.filename:
raise HTTPException(400, "Filename is required")
# Create project directory if not exists
project_dir = os.path.join(PROJECTS_DIR, slug)
os.makedirs(project_dir, exist_ok=True)
# Check if file with same name already exists
result = await db.execute(
select(ProjectFile).where(
ProjectFile.project_id == project.id,
ProjectFile.filename == file.filename
)
)
existing_file = result.scalar_one_or_none()
# Save file to disk
storage_path = file.filename # relative path inside project dir
full_path = os.path.join(project_dir, file.filename)
with open(full_path, "wb") as f:
f.write(content)
mime_type = file.content_type or mimetypes.guess_type(file.filename)[0]
if existing_file:
# Update existing file
existing_file.description = description
existing_file.mime_type = mime_type
existing_file.size = len(content)
existing_file.uploaded_by = member.id
existing_file.storage_path = storage_path
await db.commit()
await db.refresh(existing_file, ["uploader"])
return _file_out(existing_file)
else:
# Create new file record
project_file = ProjectFile(
project_id=project.id,
filename=file.filename,
description=description,
mime_type=mime_type,
size=len(content),
uploaded_by=member.id,
storage_path=storage_path,
)
db.add(project_file)
await db.commit()
await db.refresh(project_file, ["uploader"])
return _file_out(project_file)
@router.get("/projects/{slug}/files/{file_id}")
async def get_project_file(
slug: str,
file_id: str,
db: AsyncSession = Depends(get_db),
):
"""Get project file metadata."""
project = await _get_project(slug, db)
result = await db.execute(
select(ProjectFile).where(
ProjectFile.id == uuid.UUID(file_id),
ProjectFile.project_id == project.id
).options(selectinload(ProjectFile.uploader))
)
project_file = result.scalar_one_or_none()
if not project_file:
raise HTTPException(404, "File not found")
return _file_out(project_file)
@router.get("/projects/{slug}/files/{file_id}/download")
async def download_project_file(
slug: str,
file_id: str,
db: AsyncSession = Depends(get_db),
):
"""Download project file."""
project = await _get_project(slug, db)
result = await db.execute(
select(ProjectFile).where(
ProjectFile.id == uuid.UUID(file_id),
ProjectFile.project_id == project.id
)
)
project_file = result.scalar_one_or_none()
if not project_file:
raise HTTPException(404, "File not found")
# Full path to file
full_path = os.path.join(PROJECTS_DIR, slug, project_file.storage_path)
if not os.path.exists(full_path):
raise HTTPException(404, "File not found on disk")
return FileResponse(
full_path,
filename=project_file.filename,
media_type=project_file.mime_type or "application/octet-stream",
)
@router.patch("/projects/{slug}/files/{file_id}", response_model=ProjectFileOut)
async def update_project_file(
request: Request,
slug: str,
file_id: str,
data: ProjectFileUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update project file description."""
member = _get_member(request)
project = await _get_project(slug, db)
result = await db.execute(
select(ProjectFile).where(
ProjectFile.id == uuid.UUID(file_id),
ProjectFile.project_id == project.id
).options(selectinload(ProjectFile.uploader))
)
project_file = result.scalar_one_or_none()
if not project_file:
raise HTTPException(404, "File not found")
# Update description
if data.description is not None:
project_file.description = data.description
await db.commit()
await db.refresh(project_file)
return _file_out(project_file)
@router.delete("/projects/{slug}/files/{file_id}")
async def delete_project_file(
request: Request,
slug: str,
file_id: str,
db: AsyncSession = Depends(get_db),
):
"""Delete project file from database and disk."""
member = _get_member(request)
project = await _get_project(slug, db)
result = await db.execute(
select(ProjectFile).where(
ProjectFile.id == uuid.UUID(file_id),
ProjectFile.project_id == project.id
)
)
project_file = result.scalar_one_or_none()
if not project_file:
raise HTTPException(404, "File not found")
# Delete file from disk
full_path = os.path.join(PROJECTS_DIR, slug, project_file.storage_path)
if os.path.exists(full_path):
os.remove(full_path)
# Delete from database
await db.delete(project_file)
await db.commit()
return {"ok": True}