feat: file attachments — upload endpoint, attachment creation on messages, download
Some checks failed
Deploy Tracker / deploy (push) Failing after 2s
Some checks failed
Deploy Tracker / deploy (push) Failing after 2s
This commit is contained in:
parent
e8f7d04821
commit
625e75a005
@ -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:
|
||||
|
||||
83
src/tracker/api/attachments.py
Normal file
83
src/tracker/api/attachments.py
Normal 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",
|
||||
)
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user