Add attachment management CRUD to the /uploads API
Complete the read and update faces of the signed-in user's attachment
management over the existing attachments table:
- GET /uploads: paginated list of the user's own attachments, newest
first, with an optional linked/orphaned filter
- GET /uploads/{uid}: fetch one attachment (owner or admin)
- PATCH /uploads/{uid}: rename the display filename, always preserving
the original extension (owner or admin, audited as attachment.rename)
Adds get_user_attachments/get_user_attachment data helpers, the
rename_attachment operation, AttachmentRenameForm, the UploadItemOut and
UploadsListOut schemas, the Devii tools list_attachments/get_attachment/
rename_attachment, expanded API reference documentation for the full
lifecycle including delete, and api-tier tests.
This commit is contained in:
@@ -30,7 +30,7 @@ Prefixes are wired in `main.py`:
|
||||
| `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed), `attachments.py` (file attachments on open issues + comments, mirrored to Gitea native assets; add/list/delete with owner-or-admin + open-state guards) |
|
||||
| `/gists` | gists.py |
|
||||
| `/news` | news.py |
|
||||
| `/uploads` | uploads.py |
|
||||
| `/uploads` | uploads.py - attachment management CRUD for the signed-in user over the ONE `attachments` table (the same rows that appear on posts/comments/projects/gists/issues). Create: `POST /upload` (multipart), `POST /upload-url` (server-side fetch). Read: `GET ""` (own attachments, paginated 24/page newest-first, `?page=`, `?linked=true|false` via `database.get_user_attachments`), `GET /{attachment_uid}` (one, via `database.get_user_attachment`). Update: `PATCH /{attachment_uid}` (rename display filename via `attachments.rename_attachment`; the original file extension is ALWAYS preserved - renaming can never change the file type, the upload-time security control - audit `attachment.rename`). Delete: `DELETE /delete/{attachment_uid}` (soft delete). All `require_user_api` (401 for guests); read/rename/delete of another user's row is owner-or-admin. JSON-only router (no HTML/`respond`); list uses `UploadsListOut`, single/rename return `UploadItemOut`. Devii tools mirror every face: `upload_file`/`attach_url`/`list_attachments`/`get_attachment`/`rename_attachment`/`delete_attachment` |
|
||||
| `/media` | media.py - profile media gallery item soft delete/restore: `POST /media/{uid}/delete` and `POST /media/{uid}/restore` (owner or admin) |
|
||||
| `/openai` | openai_gateway.py |
|
||||
| `/devii` | devii.py - WebSocket terminal (`/devii/ws`), page, `/devii/usage`, `/devii/session` |
|
||||
|
||||
@@ -5,16 +5,23 @@ from pathlib import Path
|
||||
from typing import Annotated
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from devplacepy.database import get_table, get_int_setting
|
||||
from devplacepy.models import UploadUrlForm
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
get_int_setting,
|
||||
get_user_attachments,
|
||||
get_user_attachment,
|
||||
)
|
||||
from devplacepy.models import UploadUrlForm, AttachmentRenameForm
|
||||
from devplacepy.utils import require_user_api, is_admin, track_action
|
||||
from devplacepy.attachments import (
|
||||
store_attachment,
|
||||
store_attachment_from_url,
|
||||
soft_delete_attachment,
|
||||
rename_attachment,
|
||||
is_extension_allowed,
|
||||
RemoteFetchError,
|
||||
)
|
||||
from devplacepy.schemas import UploadItemOut, UploadsListOut
|
||||
from urllib.parse import urlparse
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.dependencies import json_or_form
|
||||
@@ -91,6 +98,74 @@ async def upload_from_url(request: Request, data: Annotated[UploadUrlForm, Depen
|
||||
)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
def _linked_filter(linked):
|
||||
if linked is None:
|
||||
return None
|
||||
return linked.strip().lower() in ("1", "true", "yes", "linked")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_attachments(
|
||||
request: Request, page: int = 1, linked: str | None = None
|
||||
):
|
||||
user = require_user_api(request)
|
||||
items, pagination = get_user_attachments(
|
||||
user["uid"], page=page, linked=_linked_filter(linked)
|
||||
)
|
||||
payload = {
|
||||
"attachments": items,
|
||||
"pagination": pagination,
|
||||
"total": pagination.get("total", len(items)),
|
||||
}
|
||||
return JSONResponse(UploadsListOut.model_validate(payload).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/{attachment_uid}")
|
||||
async def get_attachment_route(request: Request, attachment_uid: str):
|
||||
user = require_user_api(request)
|
||||
item = get_user_attachment(attachment_uid)
|
||||
if not item:
|
||||
return JSONResponse({"error": "Attachment not found"}, status_code=404)
|
||||
if item.get("user_uid") and item["user_uid"] != user["uid"] and not is_admin(user):
|
||||
return JSONResponse({"error": "Not authorized"}, status_code=403)
|
||||
return JSONResponse(UploadItemOut.model_validate(item).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.patch("/{attachment_uid}")
|
||||
async def rename_attachment_route(
|
||||
request: Request,
|
||||
attachment_uid: str,
|
||||
data: Annotated[
|
||||
AttachmentRenameForm, Depends(json_or_form(AttachmentRenameForm))
|
||||
],
|
||||
):
|
||||
user = require_user_api(request)
|
||||
att = get_table("attachments").find_one(uid=attachment_uid, deleted_at=None)
|
||||
if not att:
|
||||
return JSONResponse({"error": "Attachment not found"}, status_code=404)
|
||||
if att.get("user_uid") and att["user_uid"] != user["uid"] and not is_admin(user):
|
||||
return JSONResponse({"error": "Not authorized"}, status_code=403)
|
||||
old_name = att.get("original_filename")
|
||||
new_name = rename_attachment(attachment_uid, data.filename)
|
||||
if new_name is None:
|
||||
return JSONResponse({"error": "Invalid filename"}, status_code=400)
|
||||
logger.info(f"Attachment {attachment_uid} renamed to {new_name} by {user['username']}")
|
||||
audit.record(
|
||||
request,
|
||||
"attachment.rename",
|
||||
user=user,
|
||||
target_type="attachment",
|
||||
target_uid=attachment_uid,
|
||||
target_label=new_name,
|
||||
old_value=old_name,
|
||||
new_value=new_name,
|
||||
summary=f"{user['username']} renamed attachment to {new_name}",
|
||||
links=[audit.attachment_link(attachment_uid, new_name)],
|
||||
)
|
||||
item = get_user_attachment(attachment_uid)
|
||||
return JSONResponse(UploadItemOut.model_validate(item).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.delete("/delete/{attachment_uid}")
|
||||
async def delete_attachment_route(request: Request, attachment_uid: str):
|
||||
user = require_user_api(request)
|
||||
|
||||
Reference in New Issue
Block a user