forked from retoor/devplacepy
- Route Devii-driven AI gateway cost to the action/tool that triggered it instead of a blanket "internal" bucket, so per-feature AI spend is attributable. - Fix the quiz attempt review to show one previously-answered question at a time instead of all of them at once, and stop a quiz endpoint linked from the quiz flow from responding with raw JSON. - Add DB API async query result route and AI Usage Analyzer annotated source/media routes, with traversal-safe uid/path handling and matching tests. - Add Code Farm action audit logging (plant/harvest/buy-plot/upgrade/ fertilize) and related admin workspace/services/trash/gateway route and doc touch-ups. - Drop redundant docstrings from access_tokens.py per the no-comments convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
252 lines
9.1 KiB
Python
252 lines
9.1 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,
|
|
get_orphan_attachments_batch,
|
|
link_attachments,
|
|
mirror_attachment_to_gitea,
|
|
remove_gitea_asset,
|
|
soft_delete_attachment,
|
|
split_attachment_uids,
|
|
)
|
|
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 _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
|
|
|
|
|
|
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 = get_orphan_attachments_batch(
|
|
split_attachment_uids(data.attachment_uids), user, admin=is_admin(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 = get_orphan_attachments_batch(
|
|
split_attachment_uids(data.attachment_uids), user, admin=is_admin(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"})
|