Files
devplacepy/devplacepy/routers/issues/attachments.py
T
retoor 7bbaf51450 feat: add file attachment support to issue tracker with Gitea mirroring
Implement full attachment CRUD for issues and comments, restricted to open issues only. Attachments are stored locally and mirrored to the Gitea tracker via new `mirror_attachment_to_gitea` and `set_gitea_asset_id` functions. Add `gitea_asset_id` column to the attachments table, extend `IssueForm` and `IssueCommentForm` with `attachment_uids` field, and expose new API endpoints for listing, adding, and deleting issue attachments. Update agent configuration to include web research tools, and document the new capability in README, AGENTS.md, and CLAUDE.md.
2026-06-19 11:22:05 +00:00

265 lines
9.5 KiB
Python

# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from devplacepy.attachments import (
get_attachments,
link_attachments,
mirror_attachment_to_gitea,
remove_gitea_asset,
soft_delete_attachment,
)
from devplacepy.database import get_table
from devplacepy.dependencies import json_or_form
from devplacepy.models import IssueAttachmentForm
from devplacepy.responses import action_result, json_error
from devplacepy.schemas import IssueAttachmentsOut
from devplacepy.services.audit import record as audit
from devplacepy.services.gitea import runtime
from devplacepy.services.gitea.client import STATE_OPEN, GiteaError
from devplacepy.services.gitea.config import gitea_config
from devplacepy.utils import get_current_user, is_admin, not_found, require_user
logger = logging.getLogger(__name__)
router = APIRouter()
def _split_uids(raw) -> list[str]:
return [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
def _can_modify(att: dict, user: dict | None, is_open: bool) -> bool:
if not user or not is_open:
return False
return att.get("user_uid") == user["uid"] or is_admin(user)
def _payload(rows: list[dict], user: dict | None, is_open: bool) -> list[dict]:
items = []
for row in rows:
att = dict(row)
att["can_modify"] = _can_modify(att, user, is_open)
items.append(att)
return items
def _claim_orphans(uids: list[str], user: dict) -> list[str]:
admin = is_admin(user)
owned = []
for uid in uids:
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
if not row:
continue
if row.get("user_uid") and row["user_uid"] != user["uid"] and not admin:
continue
if row.get("target_uid"):
continue
owned.append(uid)
return owned
async def _load_issue(number: int) -> dict:
try:
return await runtime.get_client().get_issue(number)
except GiteaError as exc:
if exc.status == 404:
raise not_found("Issue not found")
raise
@router.get("/{number}/attachments")
async def list_issue_attachments(request: Request, number: int):
if not gitea_config().is_configured:
return json_error(503, "The issue tracker is not configured")
user = get_current_user(request)
issue = await _load_issue(number)
is_open = issue.get("state") == STATE_OPEN
rows = get_attachments("issue", str(number))
payload = {"number": number, "attachments": _payload(rows, user, is_open)}
return JSONResponse(
IssueAttachmentsOut.model_validate(payload).model_dump(mode="json")
)
@router.post("/{number}/attachments")
async def add_issue_attachment(
request: Request,
number: int,
data: Annotated[IssueAttachmentForm, Depends(json_or_form(IssueAttachmentForm))],
):
user = require_user(request)
if not gitea_config().is_configured:
return json_error(503, "The issue tracker is not configured")
issue = await _load_issue(number)
if issue.get("state") != STATE_OPEN:
audit.record(
request,
"issue.attachment.add",
user=user,
result="denied",
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} tried to attach to closed issue #{number}",
)
return json_error(409, "Attachments cannot be changed on a closed issue")
owned = _claim_orphans(_split_uids(data.attachment_uids), user)
if not owned:
return json_error(400, "No valid attachments to add")
link_attachments(owned, "issue", str(number))
for uid in owned:
await mirror_attachment_to_gitea(uid)
audit.record(
request,
"issue.attachment.add",
user=user,
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} attached {len(owned)} file(s) to issue #{number}",
metadata={"number": number, "count": len(owned)},
links=[audit.target("issue", str(number))],
)
return action_result(
request, f"/issues/{number}", data={"count": len(owned), "uids": owned}
)
@router.delete("/{number}/attachments/{uid}")
async def delete_issue_attachment(request: Request, number: int, uid: str):
user = require_user(request)
if not gitea_config().is_configured:
return json_error(503, "The issue tracker is not configured")
issue = await _load_issue(number)
att = get_table("attachments").find_one(
uid=uid, target_type="issue", target_uid=str(number), deleted_at=None
)
if not att:
raise not_found("Attachment not found")
if att.get("user_uid") and att["user_uid"] != user["uid"] and not is_admin(user):
audit.record(
request,
"issue.attachment.delete",
user=user,
result="denied",
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} tried to delete another user's attachment on issue #{number}",
)
return json_error(403, "Not authorized")
if issue.get("state") != STATE_OPEN:
audit.record(
request,
"issue.attachment.delete",
user=user,
result="denied",
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} tried to delete an attachment on closed issue #{number}",
)
return json_error(409, "Attachments cannot be changed on a closed issue")
soft_delete_attachment(uid, deleted_by=user["uid"])
await remove_gitea_asset(att)
audit.record(
request,
"issue.attachment.delete",
user=user,
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} deleted an attachment on issue #{number}",
metadata={"number": number, "uid": uid},
links=[audit.target("issue", str(number))],
)
return action_result(request, f"/issues/{number}", data={"status": "deleted"})
@router.get("/{number}/comments/{cid}/attachments")
async def list_comment_attachments(request: Request, number: int, cid: int):
if not gitea_config().is_configured:
return json_error(503, "The issue tracker is not configured")
user = get_current_user(request)
issue = await _load_issue(number)
is_open = issue.get("state") == STATE_OPEN
rows = get_attachments("issue_comment", str(cid))
payload = {"number": number, "attachments": _payload(rows, user, is_open)}
return JSONResponse(
IssueAttachmentsOut.model_validate(payload).model_dump(mode="json")
)
@router.post("/{number}/comments/{cid}/attachments")
async def add_comment_attachment(
request: Request,
number: int,
cid: int,
data: Annotated[IssueAttachmentForm, Depends(json_or_form(IssueAttachmentForm))],
):
user = require_user(request)
if not gitea_config().is_configured:
return json_error(503, "The issue tracker is not configured")
issue = await _load_issue(number)
if issue.get("state") != STATE_OPEN:
return json_error(409, "Attachments cannot be changed on a closed issue")
owned = _claim_orphans(_split_uids(data.attachment_uids), user)
if not owned:
return json_error(400, "No valid attachments to add")
link_attachments(owned, "issue_comment", str(cid))
for uid in owned:
await mirror_attachment_to_gitea(uid)
audit.record(
request,
"issue.attachment.add",
user=user,
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} attached {len(owned)} file(s) to a comment on issue #{number}",
metadata={"number": number, "comment_id": cid, "count": len(owned)},
links=[audit.target("issue", str(number))],
)
return action_result(
request, f"/issues/{number}", data={"count": len(owned), "uids": owned}
)
@router.delete("/{number}/comments/{cid}/attachments/{uid}")
async def delete_comment_attachment(
request: Request, number: int, cid: int, uid: str
):
user = require_user(request)
if not gitea_config().is_configured:
return json_error(503, "The issue tracker is not configured")
issue = await _load_issue(number)
att = get_table("attachments").find_one(
uid=uid, target_type="issue_comment", target_uid=str(cid), deleted_at=None
)
if not att:
raise not_found("Attachment not found")
if att.get("user_uid") and att["user_uid"] != user["uid"] and not is_admin(user):
audit.record(
request,
"issue.attachment.delete",
user=user,
result="denied",
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} tried to delete another user's comment attachment on issue #{number}",
)
return json_error(403, "Not authorized")
if issue.get("state") != STATE_OPEN:
return json_error(409, "Attachments cannot be changed on a closed issue")
soft_delete_attachment(uid, deleted_by=user["uid"])
await remove_gitea_asset(att)
audit.record(
request,
"issue.attachment.delete",
user=user,
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} deleted a comment attachment on issue #{number}",
metadata={"number": number, "comment_id": cid, "uid": uid},
links=[audit.target("issue", str(number))],
)
return action_result(request, f"/issues/{number}", data={"status": "deleted"})