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
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- ./src:/app/src
|
- ./src:/app/src
|
||||||
|
- uploads:/data/uploads
|
||||||
command: uvicorn tracker.app:app --host 0.0.0.0 --port 8100 --reload --reload-dir /app/src
|
command: uvicorn tracker.app:app --host 0.0.0.0 --port 8100 --reload --reload-dir /app/src
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
@ -40,3 +41,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
redisdata:
|
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."""
|
"""Messages API — unified messages for chats and task comments."""
|
||||||
|
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@ -15,6 +16,8 @@ from tracker.models import Message, Chat, Attachment
|
|||||||
|
|
||||||
router = APIRouter(tags=["messages"])
|
router = APIRouter(tags=["messages"])
|
||||||
|
|
||||||
|
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/data/uploads")
|
||||||
|
|
||||||
|
|
||||||
# --- Schemas ---
|
# --- Schemas ---
|
||||||
|
|
||||||
@ -40,15 +43,25 @@ class MessageOut(BaseModel):
|
|||||||
created_at: str
|
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):
|
class MessageCreate(BaseModel):
|
||||||
chat_id: str | None = None
|
chat_id: str | None = None
|
||||||
task_id: str | None = None
|
task_id: str | None = None
|
||||||
parent_id: str | None = None
|
parent_id: str | None = None
|
||||||
author_type: str = AuthorType.HUMAN
|
author_type: str | None = None # auto-detected from member
|
||||||
author_id: str
|
author_id: str | None = None # auto-detected from auth
|
||||||
content: str
|
content: str
|
||||||
mentions: list[str] = []
|
mentions: list[str] = []
|
||||||
voice_url: str | None = None
|
voice_url: str | None = None
|
||||||
|
attachments: list[AttachmentInput] = []
|
||||||
|
|
||||||
|
|
||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
@ -106,21 +119,41 @@ async def list_messages(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/messages", response_model=MessageOut)
|
@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:
|
if not req.chat_id and not req.task_id:
|
||||||
raise HTTPException(400, "Either chat_id or task_id must be provided")
|
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(
|
msg = Message(
|
||||||
chat_id=uuid.UUID(req.chat_id) if req.chat_id else None,
|
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,
|
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,
|
parent_id=uuid.UUID(req.parent_id) if req.parent_id else None,
|
||||||
author_type=req.author_type,
|
author_type=author_type,
|
||||||
author_id=uuid.UUID(req.author_id),
|
author_id=author_id,
|
||||||
content=req.content,
|
content=req.content,
|
||||||
mentions=req.mentions,
|
mentions=req.mentions,
|
||||||
voice_url=req.voice_url,
|
voice_url=req.voice_url,
|
||||||
)
|
)
|
||||||
db.add(msg)
|
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()
|
await db.commit()
|
||||||
result2 = await db.execute(
|
result2 = await db.execute(
|
||||||
select(Message).where(Message.id == msg.id).options(
|
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,
|
"author_name": author_name,
|
||||||
"content": msg.content,
|
"content": msg.content,
|
||||||
"mentions": msg.mentions or [],
|
"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(),
|
"created_at": msg.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -152,7 +152,7 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Routers
|
# 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
|
from tracker.ws.handler import router as ws_router # noqa: E402
|
||||||
|
|
||||||
app.include_router(auth.router, prefix="/api/v1")
|
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(tasks.router, prefix="/api/v1")
|
||||||
app.include_router(messages.router, prefix="/api/v1")
|
app.include_router(messages.router, prefix="/api/v1")
|
||||||
app.include_router(steps.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)
|
app.include_router(ws_router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user