feat: project files API — upload, list, search, download, update description, delete
Some checks failed
Deploy Tracker / deploy (push) Failing after 4s
Some checks failed
Deploy Tracker / deploy (push) Failing after 4s
This commit is contained in:
parent
ce98d45712
commit
56aac3490d
@ -11,6 +11,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./src:/app/src
|
- ./src:/app/src
|
||||||
- ./data/uploads:/data/uploads
|
- ./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
|
command: uvicorn tracker.app:app --host 0.0.0.0 --port 8100 --reload --reload-dir /app/src
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|||||||
292
src/tracker/api/project_files.py
Normal file
292
src/tracker/api/project_files.py
Normal file
@ -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"}
|
||||||
@ -161,7 +161,7 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Routers
|
# 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
|
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")
|
||||||
@ -171,6 +171,7 @@ 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(attachments.router, prefix="/api/v1")
|
||||||
|
app.include_router(project_files.router, prefix="/api/v1")
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from tracker.models.member import AgentConfig, Member
|
|||||||
from tracker.models.project import Project, ProjectMember
|
from tracker.models.project import Project, ProjectMember
|
||||||
from tracker.models.task import Step, Task, TaskAction
|
from tracker.models.task import Step, Task, TaskAction
|
||||||
from tracker.models.chat import Attachment, Chat, Message
|
from tracker.models.chat import Attachment, Chat, Message
|
||||||
|
from tracker.models.project_file import ProjectFile
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@ -18,4 +19,5 @@ __all__ = [
|
|||||||
"Chat",
|
"Chat",
|
||||||
"Message",
|
"Message",
|
||||||
"Attachment",
|
"Attachment",
|
||||||
|
"ProjectFile",
|
||||||
]
|
]
|
||||||
|
|||||||
34
src/tracker/models/project_file.py
Normal file
34
src/tracker/models/project_file.py
Normal file
@ -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()
|
||||||
Loading…
Reference in New Issue
Block a user