feat: file attachments — upload endpoint, attachment creation on messages, download
Some checks failed
Deploy Tracker / deploy (push) Failing after 2s

This commit is contained in:
markov 2026-02-25 06:17:20 +01:00
parent e8f7d04821
commit 625e75a005
4 changed files with 130 additions and 7 deletions

View File

@ -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:

View File

@ -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",
)

View File

@ -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(),
}

View File

@ -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)