forked from retoor/devplacepy
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.
265 lines
9.5 KiB
Python
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"})
|