feat: project files API — upload, list, search, download, update description, delete
Some checks failed
Deploy Tracker / deploy (push) Failing after 4s
Some checks failed
Deploy Tracker / deploy (push) Failing after 4s
This commit is contained in:
parent
ce98d45712
commit
56aac3490d
@ -11,6 +11,8 @@ services:
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./data/uploads:/data/uploads
|
||||
- ./data/projects:/data/projects
|
||||
- ./data/projects:/data/projects
|
||||
command: uvicorn tracker.app:app --host 0.0.0.0 --port 8100 --reload --reload-dir /app/src
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
292
src/tracker/api/project_files.py
Normal file
292
src/tracker/api/project_files.py
Normal file
@ -0,0 +1,292 @@
|
||||
"""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 {"message": "File deleted successfully"}
|
||||
@ -161,7 +161,7 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
# Routers
|
||||
from tracker.api import auth, members, projects, tasks, messages, steps, attachments # noqa: E402
|
||||
from tracker.api import auth, members, projects, tasks, messages, steps, attachments, project_files # noqa: E402
|
||||
from tracker.ws.handler import router as ws_router # noqa: E402
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
@ -171,6 +171,7 @@ app.include_router(tasks.router, prefix="/api/v1")
|
||||
app.include_router(messages.router, prefix="/api/v1")
|
||||
app.include_router(steps.router, prefix="/api/v1")
|
||||
app.include_router(attachments.router, prefix="/api/v1")
|
||||
app.include_router(project_files.router, prefix="/api/v1")
|
||||
app.include_router(ws_router)
|
||||
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ from tracker.models.member import AgentConfig, Member
|
||||
from tracker.models.project import Project, ProjectMember
|
||||
from tracker.models.task import Step, Task, TaskAction
|
||||
from tracker.models.chat import Attachment, Chat, Message
|
||||
from tracker.models.project_file import ProjectFile
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@ -18,4 +19,5 @@ __all__ = [
|
||||
"Chat",
|
||||
"Message",
|
||||
"Attachment",
|
||||
"ProjectFile",
|
||||
]
|
||||
|
||||
34
src/tracker/models/project_file.py
Normal file
34
src/tracker/models/project_file.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""Project file model."""
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from tracker.models.base import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tracker.models.project import Project
|
||||
from tracker.models.member import Member
|
||||
|
||||
|
||||
class ProjectFile(Base):
|
||||
__tablename__ = "project_files"
|
||||
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
filename: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
mime_type: Mapped[str | None] = mapped_column(String(100))
|
||||
size: Mapped[int] = mapped_column(Integer, nullable=False) # bytes
|
||||
uploaded_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
storage_path: Mapped[str] = mapped_column(String(1000), nullable=False) # relative path inside project dir
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship()
|
||||
uploader: Mapped["Member"] = relationship()
|
||||
Loading…
Reference in New Issue
Block a user