From 625e75a005896de9d2b8aa0a4d2e6862f407f924 Mon Sep 17 00:00:00 2001 From: markov Date: Wed, 25 Feb 2026 06:17:20 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20file=20attachments=20=E2=80=94=20upload?= =?UTF-8?q?=20endpoint,=20attachment=20creation=20on=20messages,=20downloa?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 + src/tracker/api/attachments.py | 83 ++++++++++++++++++++++++++++++++++ src/tracker/api/messages.py | 49 +++++++++++++++++--- src/tracker/app.py | 3 +- 4 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 src/tracker/api/attachments.py diff --git a/docker-compose.yml b/docker-compose.yml index 855048a..9e2c2ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: condition: service_started volumes: - ./src:/app/src + - uploads:/data/uploads command: uvicorn tracker.app:app --host 0.0.0.0 --port 8100 --reload --reload-dir /app/src postgres: @@ -40,3 +41,4 @@ services: volumes: pgdata: redisdata: + uploads: diff --git a/src/tracker/api/attachments.py b/src/tracker/api/attachments.py new file mode 100644 index 0000000..f66e03b --- /dev/null +++ b/src/tracker/api/attachments.py @@ -0,0 +1,83 @@ +"""Attachments API — upload/download files.""" + +import os +import uuid +import mimetypes + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request +from fastapi.responses import FileResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from tracker.database import get_db +from tracker.models import Attachment, Message, Member + +router = APIRouter(tags=["attachments"]) + +UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/data/uploads") +MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB + + +def _get_member(request: Request) -> Member: + member = getattr(request.state, "member", None) + if not member: + raise HTTPException(401, "Not authenticated") + return member + + +@router.post("/upload") +async def upload_file( + request: Request, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), +): + """Upload a file and return attachment metadata (not yet linked to a message).""" + member = _get_member(request) + + # 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)") + + file_id = uuid.uuid4() + ext = os.path.splitext(file.filename or "")[1] + storage_name = f"{file_id}{ext}" + storage_path = os.path.join(UPLOAD_DIR, storage_name) + + os.makedirs(UPLOAD_DIR, exist_ok=True) + with open(storage_path, "wb") as f: + f.write(content) + + mime = file.content_type or mimetypes.guess_type(file.filename or "")[0] + + return { + "file_id": str(file_id), + "filename": file.filename, + "mime_type": mime, + "size": len(content), + "storage_name": storage_name, + } + + +@router.get("/attachments/{attachment_id}/download") +async def download_attachment( + attachment_id: str, + db: AsyncSession = Depends(get_db), +): + """Download an attachment by ID.""" + result = await db.execute( + select(Attachment).where(Attachment.id == uuid.UUID(attachment_id)) + ) + att = result.scalar_one_or_none() + if not att: + raise HTTPException(404, "Attachment not found") + + file_path = os.path.join(UPLOAD_DIR, att.storage_path) + if not os.path.exists(file_path): + raise HTTPException(404, "File not found on disk") + + return FileResponse( + file_path, + filename=att.filename, + media_type=att.mime_type or "application/octet-stream", + ) diff --git a/src/tracker/api/messages.py b/src/tracker/api/messages.py index abd2bc3..da9c263 100644 --- a/src/tracker/api/messages.py +++ b/src/tracker/api/messages.py @@ -1,9 +1,10 @@ """Messages API — unified messages for chats and task comments.""" +import os import uuid from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,6 +16,8 @@ from tracker.models import Message, Chat, Attachment router = APIRouter(tags=["messages"]) +UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/data/uploads") + # --- Schemas --- @@ -40,15 +43,25 @@ class MessageOut(BaseModel): created_at: str +class AttachmentInput(BaseModel): + """Uploaded file info from /upload endpoint.""" + file_id: str # UUID from upload + filename: str + mime_type: str | None = None + size: int = 0 + storage_name: str # filename on disk + + class MessageCreate(BaseModel): chat_id: str | None = None task_id: str | None = None parent_id: str | None = None - author_type: str = AuthorType.HUMAN - author_id: str + author_type: str | None = None # auto-detected from member + author_id: str | None = None # auto-detected from auth content: str mentions: list[str] = [] voice_url: str | None = None + attachments: list[AttachmentInput] = [] # --- Helpers --- @@ -106,21 +119,41 @@ async def list_messages( @router.post("/messages", response_model=MessageOut) -async def create_message(req: MessageCreate, db: AsyncSession = Depends(get_db)): +async def create_message(req: MessageCreate, request: Request, db: AsyncSession = Depends(get_db)): if not req.chat_id and not req.task_id: raise HTTPException(400, "Either chat_id or task_id must be provided") + # Resolve author from auth + member = getattr(request.state, "member", None) + author_id = uuid.UUID(req.author_id) if req.author_id else (member.id if member else None) + author_type = req.author_type or (member.type if member else AuthorType.HUMAN) + if not author_id: + raise HTTPException(401, "Not authenticated") + msg = Message( chat_id=uuid.UUID(req.chat_id) if req.chat_id else None, task_id=uuid.UUID(req.task_id) if req.task_id else None, parent_id=uuid.UUID(req.parent_id) if req.parent_id else None, - author_type=req.author_type, - author_id=uuid.UUID(req.author_id), + author_type=author_type, + author_id=author_id, content=req.content, mentions=req.mentions, voice_url=req.voice_url, ) db.add(msg) + await db.flush() # get msg.id + + # Create attachment records + for att_in in req.attachments: + att = Attachment( + message_id=msg.id, + filename=att_in.filename, + mime_type=att_in.mime_type, + size=att_in.size, + storage_path=att_in.storage_name, + ) + db.add(att) + await db.commit() result2 = await db.execute( select(Message).where(Message.id == msg.id).options( @@ -146,6 +179,10 @@ async def create_message(req: MessageCreate, db: AsyncSession = Depends(get_db)) "author_name": author_name, "content": msg.content, "mentions": msg.mentions or [], + "attachments": [ + {"id": str(a.id), "filename": a.filename, "mime_type": a.mime_type, "size": a.size} + for a in (msg.attachments or []) + ], "created_at": msg.created_at.isoformat(), } diff --git a/src/tracker/app.py b/src/tracker/app.py index 9a956d6..9dfceb5 100644 --- a/src/tracker/app.py +++ b/src/tracker/app.py @@ -152,7 +152,7 @@ app.add_middleware( ) # Routers -from tracker.api import auth, members, projects, tasks, messages, steps # noqa: E402 +from tracker.api import auth, members, projects, tasks, messages, steps, attachments # noqa: E402 from tracker.ws.handler import router as ws_router # noqa: E402 app.include_router(auth.router, prefix="/api/v1") @@ -161,6 +161,7 @@ app.include_router(projects.router, prefix="/api/v1") 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(ws_router)