292 lines
8.5 KiB
Python
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} |