diff --git a/backend/app/alembic/versions/a1b2c3d4e5f6_add_attachment_model.py b/backend/app/alembic/versions/a1b2c3d4e5f6_add_attachment_model.py new file mode 100644 index 0000000000..52ad50f707 --- /dev/null +++ b/backend/app/alembic/versions/a1b2c3d4e5f6_add_attachment_model.py @@ -0,0 +1,58 @@ +"""Add attachment model + +Revision ID: a1b2c3d4e5f6 +Revises: fe56fa70289e +Create Date: 2026-03-06 10:00:00.000000 + +""" +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a1b2c3d4e5f6" +down_revision = "fe56fa70289e" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "attachment", + sa.Column( + "filename", + sqlmodel.sql.sqltypes.AutoString(length=255), + nullable=False, + ), + sa.Column( + "content_type", + sqlmodel.sql.sqltypes.AutoString(length=100), + nullable=False, + ), + sa.Column("size", sa.Integer(), nullable=False), + sa.Column( + "id", sa.Uuid(), nullable=False + ), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=True + ), + sa.Column( + "storage_path", + sqlmodel.sql.sqltypes.AutoString(length=512), + nullable=False, + ), + sa.Column("item_id", sa.Uuid(), nullable=False), + sa.Column("uploaded_by", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint( + ["item_id"], ["item.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["uploaded_by"], ["user.id"] + ), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade(): + op.drop_table("attachment") diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..9994e3bae5 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import attachments, items, login, private, users, utils from app.core.config import settings api_router = APIRouter() @@ -8,6 +8,7 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(attachments.router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/attachments.py b/backend/app/api/routes/attachments.py new file mode 100644 index 0000000000..54a2888523 --- /dev/null +++ b/backend/app/api/routes/attachments.py @@ -0,0 +1,172 @@ +import os +import re +import uuid +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, UploadFile +from fastapi.responses import FileResponse +from sqlmodel import col, func, select + +from app.api.deps import CurrentUser, SessionDep +from app.core.config import settings +from app.models import ( + Attachment, + AttachmentPublic, + AttachmentsPublic, + Item, + Message, +) + +router = APIRouter(prefix="/items/{item_id}/attachments", tags=["attachments"]) + + +def _sanitize_filename(filename: str) -> str: + """Remove directory separators and other unsafe characters from filename.""" + filename = os.path.basename(filename) + filename = re.sub(r"[^\w\s\-.]", "_", filename) + filename = filename.strip(". ") + if not filename: + filename = "unnamed" + return filename + + +def _get_item_or_404(session: SessionDep, item_id: uuid.UUID) -> Item: + item = session.get(Item, item_id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + return item + + +def _check_item_access(item: Item, current_user: CurrentUser) -> None: + if not current_user.is_superuser and item.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + +@router.post("/", response_model=AttachmentPublic) +def upload_attachment( + *, + session: SessionDep, + current_user: CurrentUser, + item_id: uuid.UUID, + file: UploadFile, +) -> Any: + """Upload a file attachment to an item.""" + item = _get_item_or_404(session, item_id) + _check_item_access(item, current_user) + + if file.content_type not in settings.ALLOWED_UPLOAD_TYPES: + raise HTTPException( + status_code=400, + detail=f"File type '{file.content_type}' is not allowed. " + f"Allowed types: {', '.join(settings.ALLOWED_UPLOAD_TYPES)}", + ) + + content = file.file.read() + if len(content) > settings.MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=400, + detail=f"File size exceeds maximum of {settings.MAX_UPLOAD_SIZE} bytes", + ) + + safe_filename = _sanitize_filename(file.filename or "unnamed") + file_id = uuid.uuid4() + storage_filename = f"{file_id}_{safe_filename}" + upload_dir = Path(settings.UPLOAD_DIR) / str(item_id) + upload_dir.mkdir(parents=True, exist_ok=True) + storage_path = upload_dir / storage_filename + + storage_path.write_bytes(content) + + attachment = Attachment( + filename=safe_filename, + content_type=file.content_type or "application/octet-stream", + size=len(content), + storage_path=str(storage_path), + item_id=item_id, + uploaded_by=current_user.id, + ) + session.add(attachment) + session.commit() + session.refresh(attachment) + return attachment + + +@router.get("/", response_model=AttachmentsPublic) +def list_attachments( + *, + session: SessionDep, + current_user: CurrentUser, + item_id: uuid.UUID, +) -> Any: + """List all attachments for an item.""" + item = _get_item_or_404(session, item_id) + _check_item_access(item, current_user) + + count_statement = ( + select(func.count()) + .select_from(Attachment) + .where(Attachment.item_id == item_id) + ) + count = session.exec(count_statement).one() + + statement = ( + select(Attachment) + .where(Attachment.item_id == item_id) + .order_by(col(Attachment.created_at).desc()) + ) + attachments = session.exec(statement).all() + + return AttachmentsPublic(data=attachments, count=count) + + +@router.get("/{attachment_id}/download") +def download_attachment( + *, + session: SessionDep, + current_user: CurrentUser, + item_id: uuid.UUID, + attachment_id: uuid.UUID, +) -> Any: + """Download an attachment file.""" + item = _get_item_or_404(session, item_id) + _check_item_access(item, current_user) + + attachment = session.get(Attachment, attachment_id) + if not attachment or attachment.item_id != item_id: + raise HTTPException(status_code=404, detail="Attachment not found") + + file_path = Path(attachment.storage_path) + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Attachment file not found on disk") + + return FileResponse( + path=str(file_path), + filename=attachment.filename, + media_type=attachment.content_type, + ) + + +@router.delete("/{attachment_id}") +def delete_attachment( + *, + session: SessionDep, + current_user: CurrentUser, + item_id: uuid.UUID, + attachment_id: uuid.UUID, +) -> Message: + """Delete an attachment.""" + item = _get_item_or_404(session, item_id) + _check_item_access(item, current_user) + + attachment = session.get(Attachment, attachment_id) + if not attachment or attachment.item_id != item_id: + raise HTTPException(status_code=404, detail="Attachment not found") + + file_path = Path(attachment.storage_path) + if file_path.exists(): + file_path.unlink() + + session.delete(attachment) + session.commit() + return Message(message="Attachment deleted successfully") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 650b9f7910..e942102773 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -83,6 +83,16 @@ def _set_default_emails_from(self) -> Self: self.EMAILS_FROM_NAME = self.PROJECT_NAME return self + UPLOAD_DIR: str = "uploads" + MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024 # 10MB + ALLOWED_UPLOAD_TYPES: list[str] = [ + "image/jpeg", + "image/png", + "image/gif", + "application/pdf", + "text/plain", + ] + EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 @computed_field # type: ignore[prop-decorator] diff --git a/backend/app/models.py b/backend/app/models.py index b5132e0e2c..474a1390d6 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -94,6 +94,9 @@ class Item(ItemBase, table=True): foreign_key="user.id", nullable=False, ondelete="CASCADE" ) owner: User | None = Relationship(back_populates="items") + attachments: list["Attachment"] = Relationship( + back_populates="item", cascade_delete=True + ) # Properties to return via API, id is always required @@ -127,3 +130,36 @@ class TokenPayload(SQLModel): class NewPassword(SQLModel): token: str new_password: str = Field(min_length=8, max_length=128) + + +# Attachment models +class AttachmentBase(SQLModel): + filename: str = Field(max_length=255) + content_type: str = Field(max_length=100) + size: int + + +class Attachment(AttachmentBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + storage_path: str = Field(max_length=512) + item_id: uuid.UUID = Field( + foreign_key="item.id", nullable=False, ondelete="CASCADE" + ) + item: Item | None = Relationship(back_populates="attachments") + uploaded_by: uuid.UUID = Field(foreign_key="user.id", nullable=False) + + +class AttachmentPublic(AttachmentBase): + id: uuid.UUID + item_id: uuid.UUID + uploaded_by: uuid.UUID + created_at: datetime | None = None + + +class AttachmentsPublic(SQLModel): + data: list[AttachmentPublic] + count: int diff --git a/backend/tests/api/routes/test_attachments.py b/backend/tests/api/routes/test_attachments.py new file mode 100644 index 0000000000..760fe55ed1 --- /dev/null +++ b/backend/tests/api/routes/test_attachments.py @@ -0,0 +1,166 @@ +import uuid + +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from tests.utils.attachment import upload_test_attachment +from tests.utils.item import create_random_item + + +def test_upload_attachment( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db) + response = upload_test_attachment( + client, item.id, superuser_token_headers + ) + assert response.status_code == 200 + content = response.json() + assert content["filename"] == "test.txt" + assert content["content_type"] == "text/plain" + assert content["size"] == len(b"test file content") + assert content["item_id"] == str(item.id) + assert "id" in content + + +def test_upload_attachment_not_owner( + client: TestClient, + normal_user_token_headers: dict[str, str], + db: Session, +) -> None: + item = create_random_item(db) + response = upload_test_attachment( + client, item.id, normal_user_token_headers + ) + assert response.status_code == 403 + + +def test_upload_attachment_invalid_type( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db) + response = upload_test_attachment( + client, + item.id, + superuser_token_headers, + filename="test.exe", + content=b"binary content", + content_type="application/x-executable", + ) + assert response.status_code == 400 + assert "not allowed" in response.json()["detail"] + + +def test_upload_attachment_too_large( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db) + large_content = b"x" * (settings.MAX_UPLOAD_SIZE + 1) + response = upload_test_attachment( + client, + item.id, + superuser_token_headers, + content=large_content, + ) + assert response.status_code == 400 + assert "exceeds maximum" in response.json()["detail"] + + +def test_upload_attachment_item_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = upload_test_attachment( + client, uuid.uuid4(), superuser_token_headers + ) + assert response.status_code == 404 + + +def test_list_attachments( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db) + upload_test_attachment(client, item.id, superuser_token_headers) + upload_test_attachment( + client, + item.id, + superuser_token_headers, + filename="test2.txt", + content=b"second file", + ) + response = client.get( + f"{settings.API_V1_STR}/items/{item.id}/attachments/", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert content["count"] == 2 + assert len(content["data"]) == 2 + + +def test_download_attachment( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db) + upload_resp = upload_test_attachment( + client, item.id, superuser_token_headers + ) + attachment_id = upload_resp.json()["id"] + response = client.get( + f"{settings.API_V1_STR}/items/{item.id}/attachments/{attachment_id}/download", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + assert response.content == b"test file content" + + +def test_download_attachment_not_found( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db) + response = client.get( + f"{settings.API_V1_STR}/items/{item.id}/attachments/{uuid.uuid4()}/download", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + + +def test_delete_attachment( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db) + upload_resp = upload_test_attachment( + client, item.id, superuser_token_headers + ) + attachment_id = upload_resp.json()["id"] + response = client.delete( + f"{settings.API_V1_STR}/items/{item.id}/attachments/{attachment_id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + assert response.json()["message"] == "Attachment deleted successfully" + + # Verify it's gone + response = client.get( + f"{settings.API_V1_STR}/items/{item.id}/attachments/{attachment_id}/download", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + + +def test_delete_attachment_not_owner( + client: TestClient, + superuser_token_headers: dict[str, str], + normal_user_token_headers: dict[str, str], + db: Session, +) -> None: + item = create_random_item(db) + upload_resp = upload_test_attachment( + client, item.id, superuser_token_headers + ) + attachment_id = upload_resp.json()["id"] + response = client.delete( + f"{settings.API_V1_STR}/items/{item.id}/attachments/{attachment_id}", + headers=normal_user_token_headers, + ) + assert response.status_code == 403 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8ddab7b321..6e6c082eb7 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -7,7 +7,7 @@ from app.core.config import settings from app.core.db import engine, init_db from app.main import app -from app.models import Item, User +from app.models import Attachment, Item, User from tests.utils.user import authentication_token_from_email from tests.utils.utils import get_superuser_token_headers @@ -17,6 +17,8 @@ def db() -> Generator[Session, None, None]: with Session(engine) as session: init_db(session) yield session + statement = delete(Attachment) + session.execute(statement) statement = delete(Item) session.execute(statement) statement = delete(User) diff --git a/backend/tests/utils/attachment.py b/backend/tests/utils/attachment.py new file mode 100644 index 0000000000..568ee5bc90 --- /dev/null +++ b/backend/tests/utils/attachment.py @@ -0,0 +1,23 @@ +import uuid +from io import BytesIO + +from fastapi.testclient import TestClient + +from app.core.config import settings + + +def upload_test_attachment( + client: TestClient, + item_id: uuid.UUID, + headers: dict[str, str], + filename: str = "test.txt", + content: bytes = b"test file content", + content_type: str = "text/plain", +) -> dict: + """Upload a test attachment and return the response JSON.""" + response = client.post( + f"{settings.API_V1_STR}/items/{item_id}/attachments/", + headers=headers, + files={"file": (filename, BytesIO(content), content_type)}, + ) + return response diff --git a/frontend/src/client/attachments.ts b/frontend/src/client/attachments.ts new file mode 100644 index 0000000000..bc7f1b9d94 --- /dev/null +++ b/frontend/src/client/attachments.ts @@ -0,0 +1,75 @@ +import axios from "axios" + +import { OpenAPI } from "./core/OpenAPI" + +export interface AttachmentPublic { + id: string + filename: string + content_type: string + size: number + item_id: string + uploaded_by: string + created_at: string | null +} + +export interface AttachmentsPublic { + data: AttachmentPublic[] + count: number +} + +function getHeaders(): Record { + const token = localStorage.getItem("access_token") + return token ? { Authorization: `Bearer ${token}` } : {} +} + +function getBaseUrl(): string { + return OpenAPI.BASE || "" +} + +export const AttachmentsService = { + async uploadAttachment( + itemId: string, + file: File, + ): Promise { + const formData = new FormData() + formData.append("file", file) + const response = await axios.post( + `${getBaseUrl()}/api/v1/items/${itemId}/attachments/`, + formData, + { + headers: { + ...getHeaders(), + "Content-Type": "multipart/form-data", + }, + }, + ) + return response.data + }, + + async listAttachments(itemId: string): Promise { + const response = await axios.get( + `${getBaseUrl()}/api/v1/items/${itemId}/attachments/`, + { headers: getHeaders() }, + ) + return response.data + }, + + async downloadAttachment(itemId: string, attachmentId: string): Promise { + const response = await axios.get( + `${getBaseUrl()}/api/v1/items/${itemId}/attachments/${attachmentId}/download`, + { headers: getHeaders(), responseType: "blob" }, + ) + return response.data + }, + + async deleteAttachment( + itemId: string, + attachmentId: string, + ): Promise<{ message: string }> { + const response = await axios.delete( + `${getBaseUrl()}/api/v1/items/${itemId}/attachments/${attachmentId}`, + { headers: getHeaders() }, + ) + return response.data + }, +} diff --git a/frontend/src/components/Attachments/AttachmentsList.tsx b/frontend/src/components/Attachments/AttachmentsList.tsx new file mode 100644 index 0000000000..db549cca2b --- /dev/null +++ b/frontend/src/components/Attachments/AttachmentsList.tsx @@ -0,0 +1,203 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Download, FileText, Trash2, Upload } from "lucide-react" +import { useRef, useState } from "react" + +import { + type AttachmentPublic, + AttachmentsService, +} from "@/client/attachments" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { LoadingButton } from "@/components/ui/loading-button" +import useCustomToast from "@/hooks/useCustomToast" + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +interface AttachmentsListProps { + itemId: string + onSuccess?: () => void +} + +const AttachmentsList = ({ itemId, onSuccess }: AttachmentsListProps) => { + const [isOpen, setIsOpen] = useState(false) + const fileInputRef = useRef(null) + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + + const { data: attachments, isLoading } = useQuery({ + queryKey: ["attachments", itemId], + queryFn: () => AttachmentsService.listAttachments(itemId), + enabled: isOpen, + }) + + const uploadMutation = useMutation({ + mutationFn: (file: File) => + AttachmentsService.uploadAttachment(itemId, file), + onSuccess: () => { + showSuccessToast("File uploaded successfully") + queryClient.invalidateQueries({ queryKey: ["attachments", itemId] }) + }, + onError: (err: Error) => { + showErrorToast(err.message || "Failed to upload file") + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (attachmentId: string) => + AttachmentsService.deleteAttachment(itemId, attachmentId), + onSuccess: () => { + showSuccessToast("Attachment deleted") + queryClient.invalidateQueries({ queryKey: ["attachments", itemId] }) + }, + onError: (err: Error) => { + showErrorToast(err.message || "Failed to delete attachment") + }, + }) + + const handleDownload = async (attachment: AttachmentPublic) => { + try { + const blob = await AttachmentsService.downloadAttachment( + itemId, + attachment.id, + ) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = attachment.filename + a.click() + URL.revokeObjectURL(url) + } catch { + showErrorToast("Failed to download file") + } + } + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + uploadMutation.mutate(file) + e.target.value = "" + } + } + + return ( + + e.preventDefault()} + onClick={() => setIsOpen(true)} + > + + Attachments + + + + Attachments + + Manage file attachments for this item. + + + +
+ + fileInputRef.current?.click()} + > + + Upload File + + + {isLoading && ( +

+ Loading... +

+ )} + + {attachments && attachments.data.length === 0 && ( +

+ No attachments yet +

+ )} + + {attachments?.data.map((attachment) => ( +
+
+ +
+

+ {attachment.filename} +

+
+ + {attachment.content_type} + + + {formatFileSize(attachment.size)} + +
+
+
+
+ + +
+
+ ))} +
+ + + + + + +
+
+ ) +} + +export default AttachmentsList diff --git a/frontend/src/components/Items/ItemActionsMenu.tsx b/frontend/src/components/Items/ItemActionsMenu.tsx index 1efe7bf719..6b33408f90 100644 --- a/frontend/src/components/Items/ItemActionsMenu.tsx +++ b/frontend/src/components/Items/ItemActionsMenu.tsx @@ -8,6 +8,7 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" +import AttachmentsList from "../Attachments/AttachmentsList" import DeleteItem from "../Items/DeleteItem" import EditItem from "../Items/EditItem" @@ -27,6 +28,7 @@ export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => { setOpen(false)} /> + setOpen(false)} /> setOpen(false)} />