Attribute Devii AI spend to its invoking action, fix quiz question-at-a-time review, DB API/isslop result routes, workspace docs, and drop redundant docstrings
- 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
This commit is contained in:
@@ -89,6 +89,7 @@ async def admin_backups(request: Request):
|
||||
{"name": "Backups", "url": "/admin/backups"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
robots="noindex,nofollow",
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
|
||||
@@ -139,6 +139,7 @@ async def admin_devii_tasks(request: Request, state: str = "active"):
|
||||
{"name": "Devii tasks", "url": "/admin/devii-tasks"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
robots="noindex,nofollow",
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
|
||||
@@ -62,7 +62,7 @@ async def service_detail(request: Request, name: str):
|
||||
request,
|
||||
title=f"{info['title']} - Services",
|
||||
description=info["description"] or f"Configure the {info['title']} service.",
|
||||
robots="noindex",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
|
||||
@@ -89,6 +89,7 @@ async def admin_trash(request: Request, table: str = "posts", page: int = 1):
|
||||
{"name": "Trash", "url": "/admin/trash"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
robots="noindex,nofollow",
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.database import db, get_table, get_users_by_uids
|
||||
from devplacepy.database import get_projects_by_uids, get_table, get_users_by_uids
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import (
|
||||
EditorPrefsForm,
|
||||
@@ -34,13 +34,8 @@ router = APIRouter()
|
||||
def _decorate(rows: list[dict]) -> list[dict]:
|
||||
owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")}
|
||||
owners = get_users_by_uids(list(owner_uids)) if owner_uids else {}
|
||||
projects = {}
|
||||
if "projects" in db.tables:
|
||||
project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")}
|
||||
for uid in project_uids:
|
||||
found = get_table("projects").find_one(uid=uid)
|
||||
if found:
|
||||
projects[uid] = found
|
||||
project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")}
|
||||
projects = get_projects_by_uids(list(project_uids)) if project_uids else {}
|
||||
decorated = []
|
||||
for row in rows:
|
||||
view = provision.view(row)
|
||||
|
||||
@@ -22,12 +22,6 @@ async def token(
|
||||
request: Request,
|
||||
data: Annotated[LoginForm, Depends(json_or_form(LoginForm))],
|
||||
):
|
||||
"""Issue a DevPlace access token.
|
||||
|
||||
Accepts ``email`` + ``password`` (JSON or form-encoded). Returns a JSON
|
||||
object with ``access_token``, ``token_type``, and ``expires_in`` on success,
|
||||
or a ``401`` error on bad credentials.
|
||||
"""
|
||||
identifier = data.email.strip().lower()
|
||||
password = data.password
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import WarJoinForm
|
||||
from devplacepy.responses import action_result, json_error, respond, wants_json
|
||||
from devplacepy.schemas import BattlesOut, WarEventsOut, WarOut
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.services.opinionwar import WarError, rules, store
|
||||
from devplacepy.utils import get_current_user, not_found, require_user
|
||||
|
||||
@@ -43,6 +43,7 @@ async def battles_page(
|
||||
search=search,
|
||||
page=max(1, page),
|
||||
)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Opinion Wars",
|
||||
@@ -54,6 +55,7 @@ async def battles_page(
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Battles", "url": "/battles"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
|
||||
@@ -137,8 +137,6 @@ def _validate_target(value: str) -> str:
|
||||
|
||||
@router.get("/adopt")
|
||||
async def devii_adopt(request: Request):
|
||||
# The terminal's agent logged in; adopt the real session it minted into the browser so both
|
||||
# share one session, then return to where the user was.
|
||||
target = _validate_target(request.query_params.get("next", "/"))
|
||||
response = RedirectResponse(target, status_code=303)
|
||||
svc = _service()
|
||||
|
||||
@@ -66,7 +66,7 @@ The `endpoint()` factory derives `min_role` from `auth` (`public` -> Public, `us
|
||||
|
||||
## Admin-only pages
|
||||
|
||||
A group with `"admin": True` (currently `services`, `admin`) is hidden from the sidebar (`docs.py` filters `visible_pages`) and returns 404 for non-admins (`page.get("admin") and not is_admin -> not_found`, same message as unknown slugs). `index.html` wraps the operator links in `{% if user and user.get('role') == 'Admin' %}`.
|
||||
A group with `"admin": True` (currently `services`, `admin`, `containers`) is hidden from the sidebar (`docs.py` filters `visible_pages`) and returns 404 for non-admins (`page.get("admin") and not is_admin -> not_found`, same message as unknown slugs). `index.html` wraps the operator links in `{% if user and user.get('role') == 'Admin' %}`.
|
||||
|
||||
## Dynamic Background Services page
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ AUDIENCES = [
|
||||
]
|
||||
|
||||
DOCS_PAGES = [
|
||||
# General - how to use the site and Devii (everyone)
|
||||
{"slug": "index", "title": "Overview", "kind": "prose", "section": SECTION_GENERAL},
|
||||
{
|
||||
"slug": "getting-started",
|
||||
@@ -160,7 +159,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
# Legal - the policies the platform is operated under (everyone)
|
||||
{
|
||||
"slug": "terms",
|
||||
"title": "Terms of Service",
|
||||
@@ -204,7 +202,6 @@ DOCS_PAGES = [
|
||||
"section": SECTION_LEGAL,
|
||||
"admin": True,
|
||||
},
|
||||
# Tools - public developer tools (everyone)
|
||||
{
|
||||
"slug": "tools-seo",
|
||||
"title": "SEO Diagnostics",
|
||||
@@ -229,7 +226,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
# Claude Code - the native subagent, command, and workflow setup under .claude/
|
||||
{
|
||||
"slug": "claude",
|
||||
"title": "Claude Code setup",
|
||||
@@ -260,7 +256,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_CLAUDE,
|
||||
},
|
||||
# Components - custom HTML web components with live examples (everyone)
|
||||
{
|
||||
"slug": "components",
|
||||
"title": "Components overview",
|
||||
@@ -339,7 +334,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_COMPONENTS,
|
||||
},
|
||||
# Styles - the design system: colors, layout, responsiveness, and hard structural rules (everyone)
|
||||
{
|
||||
"slug": "styles",
|
||||
"title": "Design system overview",
|
||||
@@ -370,7 +364,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_STYLES,
|
||||
},
|
||||
# API - developer reference (everyone); admin-only groups are routed to Administration below
|
||||
{
|
||||
"slug": "authentication",
|
||||
"title": "Authentication",
|
||||
@@ -383,7 +376,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_API,
|
||||
},
|
||||
# devRant API - legacy-compatible protocol, spread over focused pages
|
||||
{
|
||||
"slug": "devrant",
|
||||
"title": "Overview",
|
||||
@@ -430,7 +422,6 @@ DOCS_PAGES = [
|
||||
{**page, "section": (SECTION_ADMIN if page.get("admin") else SECTION_API)}
|
||||
for page in api_doc_pages()
|
||||
],
|
||||
# Administration - operational guides (admins only)
|
||||
{
|
||||
"slug": "devii-admin",
|
||||
"title": "Devii for admins",
|
||||
@@ -473,7 +464,6 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_ADMIN,
|
||||
},
|
||||
# Devii internals - technical reference for the Devii assistant (admins only)
|
||||
{
|
||||
"slug": "audit-log",
|
||||
"title": "Audit Log",
|
||||
@@ -523,7 +513,6 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_DEVII,
|
||||
},
|
||||
# Bots internals - deep technical reference for the autonomous bot fleet (admins only)
|
||||
{
|
||||
"slug": "bots-internals",
|
||||
"title": "Bots internals",
|
||||
@@ -573,7 +562,6 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_BOTS,
|
||||
},
|
||||
# Services - the background service framework and every service in detail (admins only)
|
||||
{
|
||||
"slug": "services-overview",
|
||||
"title": "Services overview",
|
||||
@@ -651,7 +639,6 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_SERVICES,
|
||||
},
|
||||
# Architecture - platform design, structure, and development process (admins only)
|
||||
{
|
||||
"slug": "architecture",
|
||||
"title": "Architecture overview",
|
||||
@@ -701,7 +688,6 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_ARCH,
|
||||
},
|
||||
# Testing - test framework, load testing, and make targets (admins only)
|
||||
{
|
||||
"slug": "testing",
|
||||
"title": "Testing overview",
|
||||
@@ -737,7 +723,6 @@ DOCS_PAGES = [
|
||||
"admin": True,
|
||||
"section": SECTION_TESTING,
|
||||
},
|
||||
# Production - deployment and operations reference (admins only)
|
||||
{
|
||||
"slug": "production",
|
||||
"title": "Production overview",
|
||||
|
||||
@@ -9,6 +9,7 @@ from devplacepy.models import GameSlotForm
|
||||
from devplacepy.responses import respond, wants_json
|
||||
from devplacepy.schemas import GameFarmViewOut
|
||||
from devplacepy.services.game import GameError, store
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.utils import (
|
||||
create_notification,
|
||||
get_current_user,
|
||||
@@ -58,10 +59,24 @@ async def water_farm(
|
||||
if not owner:
|
||||
raise HTTPException(status_code=404, detail="Farm not found")
|
||||
try:
|
||||
store.water(viewer, owner, data.slot)
|
||||
result = store.water(viewer, owner, data.slot)
|
||||
except GameError as exc:
|
||||
return action_error(request, str(exc), f"/game/farm/{username}")
|
||||
track_action(viewer["uid"], "water")
|
||||
audit.record(
|
||||
request,
|
||||
"game.water",
|
||||
user=viewer,
|
||||
target_type="user",
|
||||
target_uid=owner["uid"],
|
||||
target_label=owner["username"],
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{viewer['username']} watered {owner['username']}'s build on plot "
|
||||
f"{result['slot']} and earned {result['reward_coins']} coins"
|
||||
),
|
||||
links=[audit.target("user", owner["uid"], owner["username"])],
|
||||
)
|
||||
await notify_farm(owner["username"])
|
||||
if wants_json(request):
|
||||
payload = store.serialize_farm(
|
||||
@@ -87,6 +102,31 @@ async def steal_farm(
|
||||
track_action(owner["uid"], "got_stolen_from")
|
||||
if result.get("underdog_triggered"):
|
||||
track_action(viewer["uid"], "underdog_raid")
|
||||
audit.record(
|
||||
request,
|
||||
"game.steal",
|
||||
user=viewer,
|
||||
target_type="user",
|
||||
target_uid=owner["uid"],
|
||||
target_label=owner["username"],
|
||||
metadata={
|
||||
"thief_uid": viewer["uid"],
|
||||
"thief_username": viewer["username"],
|
||||
"owner_uid": owner["uid"],
|
||||
"owner_username": owner["username"],
|
||||
"slot": result["slot"],
|
||||
"crop": result["crop"],
|
||||
"coins": result["coins"],
|
||||
"share": result["share"],
|
||||
"underdog_triggered": result.get("underdog_triggered", False),
|
||||
},
|
||||
summary=(
|
||||
f"{viewer['username']} raided {owner['username']}'s Code Farm and took "
|
||||
f"{result['coins']} coins ({round(result['share'] * 100)}%) from their "
|
||||
f"{result['crop_name']} build"
|
||||
),
|
||||
links=[audit.target("user", owner["uid"], owner["username"])],
|
||||
)
|
||||
create_notification(
|
||||
owner["uid"],
|
||||
"harvest_stolen",
|
||||
|
||||
@@ -80,8 +80,21 @@ async def _respond_action(request: Request, user: dict, fn, on_success=None):
|
||||
@router.post("/plant")
|
||||
async def game_plant(request: Request, data: Annotated[GamePlantForm, Form()]):
|
||||
user = require_user(request)
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.plant",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} planted {result['crop']} on plot "
|
||||
f"{result['slot']} for {result['spent']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.plant(user, data.slot, data.crop)
|
||||
request, user, lambda: store.plant(user, data.slot, data.crop), recorded
|
||||
)
|
||||
|
||||
|
||||
@@ -92,6 +105,16 @@ async def game_harvest(request: Request, data: Annotated[GameSlotForm, Form()]):
|
||||
def reward(result):
|
||||
track_action(user["uid"], "harvest")
|
||||
award_rewards(user["uid"], economy.site_xp_for(result.get("xp", 0)))
|
||||
audit.record(
|
||||
request,
|
||||
"game.harvest",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} harvested {result['crop']} on plot "
|
||||
f"{result['slot']} for {result['coins']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.harvest(user, data.slot), reward
|
||||
@@ -101,25 +124,79 @@ async def game_harvest(request: Request, data: Annotated[GameSlotForm, Form()]):
|
||||
@router.post("/buy-plot")
|
||||
async def game_buy_plot(request: Request):
|
||||
user = require_user(request)
|
||||
return await _respond_action(request, user, lambda: store.buy_plot(user))
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.plot.buy",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} bought Code Farm plot "
|
||||
f"{result['plot_count']} for {result['spent']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(request, user, lambda: store.buy_plot(user), recorded)
|
||||
|
||||
|
||||
@router.post("/upgrade")
|
||||
async def game_upgrade(request: Request):
|
||||
user = require_user(request)
|
||||
return await _respond_action(request, user, lambda: store.upgrade_ci(user))
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.ci.upgrade",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} upgraded Code Farm CI to tier "
|
||||
f"{result['ci_tier']} for {result['spent']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(request, user, lambda: store.upgrade_ci(user), recorded)
|
||||
|
||||
|
||||
@router.post("/fertilize")
|
||||
async def game_fertilize(request: Request, data: Annotated[GameSlotForm, Form()]):
|
||||
user = require_user(request)
|
||||
return await _respond_action(request, user, lambda: store.fertilize(user, data.slot))
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.fertilize",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} fertilized plot {result['slot']} "
|
||||
f"for {result['spent']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.fertilize(user, data.slot), recorded
|
||||
)
|
||||
|
||||
|
||||
@router.post("/daily")
|
||||
async def game_daily(request: Request):
|
||||
user = require_user(request)
|
||||
return await _respond_action(request, user, lambda: store.claim_daily(user))
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.daily.claim",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} claimed the Code Farm daily bonus of "
|
||||
f"{result['reward']} coins (streak {result['streak']})"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(request, user, lambda: store.claim_daily(user), recorded)
|
||||
|
||||
|
||||
@router.post("/grant")
|
||||
@@ -141,8 +218,21 @@ async def game_claim_grant(request: Request):
|
||||
@router.post("/perk")
|
||||
async def game_perk(request: Request, data: Annotated[GamePerkForm, Form()]):
|
||||
user = require_user(request)
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.perk.upgrade",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} upgraded perk {result['perk']} to level "
|
||||
f"{result['level']} for {result['spent']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.upgrade_perk(user, data.perk)
|
||||
request, user, lambda: store.upgrade_perk(user, data.perk), recorded
|
||||
)
|
||||
|
||||
|
||||
@@ -168,8 +258,21 @@ async def game_prestige(request: Request):
|
||||
@router.post("/legacy")
|
||||
async def game_legacy(request: Request, data: Annotated[GameLegacyForm, Form()]):
|
||||
user = require_user(request)
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.legacy.upgrade",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} upgraded Legacy {result['key']} to level "
|
||||
f"{result['level']} for {result['spent']} stars"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.upgrade_legacy(user, data.key)
|
||||
request, user, lambda: store.upgrade_legacy(user, data.key), recorded
|
||||
)
|
||||
|
||||
|
||||
@@ -179,6 +282,16 @@ async def game_claim_quest(request: Request, data: Annotated[GameQuestForm, Form
|
||||
|
||||
def reward(result):
|
||||
award_rewards(user["uid"], economy.site_xp_for(result.get("reward_xp", 0)))
|
||||
audit.record(
|
||||
request,
|
||||
"game.quest.claim",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} claimed quest {result['kind']} for "
|
||||
f"{result['reward_coins']} coins"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.claim_quest(user, data.quest, data.scope), reward
|
||||
@@ -251,8 +364,21 @@ async def game_buy_infrastructure(request: Request, data: Annotated[GameInfraFor
|
||||
@router.post("/mastery")
|
||||
async def game_upgrade_mastery(request: Request, data: Annotated[GameMasteryForm, Form()]):
|
||||
user = require_user(request)
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.mastery.upgrade",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=(
|
||||
f"{user['username']} upgraded Mastery {result['key']} to level "
|
||||
f"{result['level']} for {result['spent']} mastery points"
|
||||
),
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.upgrade_mastery(user, data.key)
|
||||
request, user, lambda: store.upgrade_mastery(user, data.key), recorded
|
||||
)
|
||||
|
||||
|
||||
@@ -281,6 +407,16 @@ async def game_buy_cosmetic(request: Request, data: Annotated[GameCosmeticForm,
|
||||
@router.post("/cosmetics/equip")
|
||||
async def game_equip_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]):
|
||||
user = require_user(request)
|
||||
|
||||
def recorded(result):
|
||||
audit.record(
|
||||
request,
|
||||
"game.cosmetic.equip",
|
||||
user=user,
|
||||
metadata=result,
|
||||
summary=f"{user['username']} equipped Code Farm title {result['active_title']}",
|
||||
)
|
||||
|
||||
return await _respond_action(
|
||||
request, user, lambda: store.equip_title(user, data.key)
|
||||
request, user, lambda: store.equip_title(user, data.key), recorded
|
||||
)
|
||||
|
||||
@@ -8,10 +8,12 @@ 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
|
||||
@@ -28,10 +30,6 @@ 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
|
||||
@@ -47,21 +45,6 @@ def _payload(rows: list[dict], user: dict | None, is_open: bool) -> list[dict]:
|
||||
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)
|
||||
@@ -106,7 +89,9 @@ async def add_issue_attachment(
|
||||
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)
|
||||
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))
|
||||
@@ -202,7 +187,9 @@ async def add_comment_attachment(
|
||||
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)
|
||||
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))
|
||||
|
||||
@@ -5,7 +5,12 @@ from typing import Annotated
|
||||
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
|
||||
from devplacepy.attachments import link_attachments, mirror_attachment_to_gitea
|
||||
from devplacepy.attachments import (
|
||||
get_orphan_attachments_batch,
|
||||
link_attachments,
|
||||
mirror_attachment_to_gitea,
|
||||
split_attachment_uids,
|
||||
)
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.models import IssueCommentForm
|
||||
from devplacepy.responses import action_result, json_error
|
||||
@@ -21,18 +26,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _owned_orphan_uids(raw: list[str], user: dict) -> list[str]:
|
||||
flat = [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
|
||||
owned = []
|
||||
for uid in flat:
|
||||
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
|
||||
if not row or row.get("target_uid"):
|
||||
continue
|
||||
if row.get("user_uid") and row["user_uid"] != user["uid"]:
|
||||
continue
|
||||
owned.append(uid)
|
||||
return owned
|
||||
|
||||
def _notify_admins(actor: dict, number: int) -> None:
|
||||
for admin in get_table("users").find(role="Admin"):
|
||||
if admin["uid"] == actor["uid"]:
|
||||
@@ -74,7 +67,9 @@ async def comment_issue(
|
||||
|
||||
comment_id = int(comment.get("id", 0))
|
||||
store.record_comment_author(comment_id, number, user["uid"])
|
||||
owned = _owned_orphan_uids(data.attachment_uids, user)
|
||||
owned = get_orphan_attachments_batch(
|
||||
split_attachment_uids(data.attachment_uids), user
|
||||
)
|
||||
if owned:
|
||||
link_attachments(owned, "issue_comment", str(comment_id))
|
||||
for uid in owned:
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Annotated
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.attachments import get_orphan_attachments_batch, split_attachment_uids
|
||||
from devplacepy.models import IssueForm
|
||||
from devplacepy.responses import json_error
|
||||
from devplacepy.schemas import IssueJobOut
|
||||
@@ -20,19 +20,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _owned_orphan_uids(raw: list[str], user: dict) -> list[str]:
|
||||
flat = [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
|
||||
owned = []
|
||||
for uid in flat:
|
||||
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
|
||||
if not row or row.get("target_uid"):
|
||||
continue
|
||||
if row.get("user_uid") and row["user_uid"] != user["uid"]:
|
||||
continue
|
||||
owned.append(uid)
|
||||
return owned
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json_or_form(IssueForm))]):
|
||||
user = require_user(request)
|
||||
@@ -45,7 +32,9 @@ async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json
|
||||
"author_uid": user["uid"],
|
||||
"title": title,
|
||||
"description": data.description.strip(),
|
||||
"attachment_uids": _owned_orphan_uids(data.attachment_uids, user),
|
||||
"attachment_uids": get_orphan_attachments_batch(
|
||||
split_attachment_uids(data.attachment_uids), user
|
||||
),
|
||||
},
|
||||
"user",
|
||||
user["uid"],
|
||||
|
||||
@@ -83,8 +83,6 @@ async def containers_json(request: Request, project_slug: str):
|
||||
}
|
||||
)
|
||||
|
||||
# ---------------- instances ----------------
|
||||
|
||||
@router.post("/{project_slug}/containers/instances")
|
||||
async def create_instance(
|
||||
request: Request, project_slug: str, data: Annotated[ContainerInstanceForm, Depends(json_or_form(ContainerInstanceForm))]
|
||||
|
||||
Reference in New Issue
Block a user