From 56aac3490d770c90adcff230920be723faafb629 Mon Sep 17 00:00:00 2001 From: markov Date: Wed, 25 Feb 2026 09:18:52 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20project=20files=20API=20=E2=80=94=20upl?= =?UTF-8?q?oad,=20list,=20search,=20download,=20update=20description,=20de?= =?UTF-8?q?lete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 + src/tracker/api/project_files.py | 292 +++++++++++++++++++++++++++++ src/tracker/app.py | 3 +- src/tracker/models/__init__.py | 2 + src/tracker/models/project_file.py | 34 ++++ 5 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 src/tracker/api/project_files.py create mode 100644 src/tracker/models/project_file.py diff --git a/docker-compose.yml b/docker-compose.yml index 041b585..b49c722 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/src/tracker/api/project_files.py b/src/tracker/api/project_files.py new file mode 100644 index 0000000..da861f6 --- /dev/null +++ b/src/tracker/api/project_files.py @@ -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"} \ No newline at end of file diff --git a/src/tracker/app.py b/src/tracker/app.py index e8d8c52..0f09feb 100644 --- a/src/tracker/app.py +++ b/src/tracker/app.py @@ -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) diff --git a/src/tracker/models/__init__.py b/src/tracker/models/__init__.py index b423cbe..5e4ea39 100644 --- a/src/tracker/models/__init__.py +++ b/src/tracker/models/__init__.py @@ -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", ] diff --git a/src/tracker/models/project_file.py b/src/tracker/models/project_file.py new file mode 100644 index 0000000..8f8fa3e --- /dev/null +++ b/src/tracker/models/project_file.py @@ -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() \ No newline at end of file