This commit is contained in:
2026-07-09 02:52:54 +02:00
parent 818568c609
commit 48bb6c2ec2
95 changed files with 6115 additions and 267 deletions
+4
View File
@@ -1,6 +1,7 @@
# retoor <retoor@molodetz.nl>
from devplacepy.routers.admin import (
awards,
aiquota,
aiusage,
auditlog,
@@ -14,13 +15,16 @@ from devplacepy.routers.admin import (
notifications,
services,
settings,
statistics,
trash,
users,
)
from devplacepy.routers.admin.index import router
router.include_router(awards.router)
router.include_router(users.router)
router.include_router(aiusage.router)
router.include_router(statistics.router)
router.include_router(aiquota.router)
router.include_router(media.router)
router.include_router(trash.router)
+49
View File
@@ -0,0 +1,49 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from devplacepy.database import get_table
from devplacepy.database.awards import revoke_award
from devplacepy.responses import action_result, json_error, wants_json
from devplacepy.services.audit import record as audit
from devplacepy.utils import not_found, require_admin, safe_next
logger = logging.getLogger(__name__)
router = APIRouter()
def _redirect_back(request: Request, award: dict) -> str:
referer = request.headers.get("referer", "")
if referer and safe_next(referer, "") == referer:
return referer
receiver = get_table("users").find_one(uid=award.get("receiver_uid", ""))
if receiver:
return f"/profile/{receiver['username']}?tab=awards"
return "/admin"
@router.post("/awards/{uid}/revoke")
async def admin_revoke_award(request: Request, uid: str):
admin = require_admin(request)
row = revoke_award(uid, admin["uid"])
if not row:
if wants_json(request):
return json_error(404, "Award not found")
raise not_found("Award not found")
receiver = get_table("users").find_one(uid=row.get("receiver_uid", ""))
logger.info("Admin %s revoked award %s", admin["username"], uid)
audit.record(
request,
"award.revoke",
user=admin,
target_type="award",
target_uid=uid,
target_label=row.get("slug", uid),
summary=f"admin {admin['username']} revoked award {row.get('slug', uid)}",
links=[
audit.target("award", uid, row.get("slug")),
audit.target("user", row.get("receiver_uid"), receiver.get("username") if receiver else None),
],
)
return action_result(request, _redirect_back(request, row))
@@ -25,6 +25,8 @@ def _default_provider_summary() -> dict:
"model": cfg.get("gateway_model", ""),
"embed_url": cfg.get("gateway_embed_url", ""),
"embed_model": cfg.get("gateway_embed_model", ""),
"image_url": cfg.get("gateway_image_url", ""),
"image_model": cfg.get("gateway_image_model", ""),
"vision_url": cfg.get("gateway_vision_url", ""),
"vision_model": cfg.get("gateway_vision_model", ""),
}
+90
View File
@@ -0,0 +1,90 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.responses import respond
from devplacepy.schemas.statistics import StatisticsOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.statistics.build import build_statistics_tab
from devplacepy.services.statistics.common import VALID_TABS
from devplacepy.utils import require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
TAB_LABELS = (
("overview", "Overview", "\U0001f4ca"),
("visitors", "Visitors", "\U0001f441\ufe0f"),
("members", "Members", "\U0001f465"),
("content", "Content", "\U0001f4dd"),
("engagement", "Engagement", "\U0001f525"),
("social", "Social", "\U0001f91d"),
("ai", "AI", "\U0001f916"),
("devii", "Devii", "\u2728"),
("services", "Services", "\u2699\ufe0f"),
("containers", "Containers", "\U0001f4e6"),
("game", "Game", "\U0001f3ae"),
("awards", "Awards", "\U0001f3c6"),
("moderation", "Moderation", "\U0001f6e1\ufe0f"),
("tools", "Tools", "\U0001f527"),
("storage", "Storage", "\U0001f4be"),
)
@router.get("/statistics", response_class=HTMLResponse)
async def admin_statistics(request: Request, tab: str = "overview", hours: int = 168):
admin = require_admin(request)
active = tab if tab in VALID_TABS else "overview"
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Statistics - Admin",
description="Platform statistics with trends, visitors, content, engagement, and operations.",
robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Statistics", "url": "/admin/statistics"},
],
schemas=[website_schema(base)],
)
initial = build_statistics_tab(active, hours, compare=True, top_n=10)
tabs = [
{"key": key, "label": label, "icon": icon, "active": key == active}
for key, label, icon in TAB_LABELS
]
return respond(
request,
"admin_statistics.html",
{
**seo_ctx,
"request": request,
"user": admin,
"admin_section": "statistics",
"tabs": tabs,
"active_tab": active,
"window_hours": hours,
"initial": initial,
},
model=StatisticsOut,
)
@router.get("/statistics/data")
async def admin_statistics_data(
request: Request,
tab: str = "overview",
hours: int = 168,
compare: int = 1,
top_n: int = 10,
):
require_admin(request)
return JSONResponse(
build_statistics_tab(
tab,
hours,
compare=bool(compare),
top_n=top_n,
)
)
+12 -7
View File
@@ -24,13 +24,14 @@ logger = logging.getLogger(__name__)
router = APIRouter()
TRASH_TABLES = [
{"key": "posts", "label": "Posts", "type": "post"},
{"key": "comments", "label": "Comments", "type": "comment"},
{"key": "gists", "label": "Gists", "type": "gist"},
{"key": "projects", "label": "Projects", "type": "project"},
{"key": "news", "label": "News", "type": "news"},
{"key": "project_files", "label": "Project files", "type": None},
{"key": "attachments", "label": "Attachments", "type": None},
{"key": "posts", "label": "Posts", "icon": "\U0001f4dd", "type": "post"},
{"key": "comments", "label": "Comments", "icon": "\U0001f4ac", "type": "comment"},
{"key": "gists", "label": "Gists", "icon": "\U0001f4cb", "type": "gist"},
{"key": "projects", "label": "Projects", "icon": "\U0001f680", "type": "project"},
{"key": "news", "label": "News", "icon": "\U0001f4f0", "type": "news"},
{"key": "awards", "label": "Awards", "icon": "\U0001f3c6", "type": "award"},
{"key": "project_files", "label": "Project files", "icon": "\U0001f4c1", "type": None},
{"key": "attachments", "label": "Attachments", "icon": "\U0001f4ce", "type": None},
]
_TRASH_KEYS = {entry["key"] for entry in TRASH_TABLES}
_TRASH_TYPE = {entry["key"]: entry["type"] for entry in TRASH_TABLES}
@@ -113,6 +114,10 @@ async def admin_trash_restore(request: Request, table: str, uid: str):
row = get_table(table).find_one(uid=uid)
if row and row.get("deleted_at"):
restored = restore_event(row["deleted_at"])
if table == "awards":
from devplacepy.database.awards import recompute_user_award_stats
recompute_user_award_stats(row.get("receiver_uid", ""))
logger.info(
f"Admin {admin['username']} restored {table} {uid} ({restored} rows)"
)
+36
View File
@@ -0,0 +1,36 @@
# retoor <retoor@molodetz.nl>
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from devplacepy.attachments import _row_to_attachment
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.utils import not_found
router = APIRouter()
_VALID_SIZES = {"512", "256", "64"}
_CACHE_CONTROL = "public, max-age=86400, immutable"
@router.get("/{slug_or_uid}/{size}")
async def award_image(request: Request, slug_or_uid: str, size: str):
if size not in _VALID_SIZES:
raise not_found("Award image not found")
award = resolve_by_slug(get_table("awards"), slug_or_uid)
if not award or not award.get("generated_at"):
raise not_found("Award image not found")
attachment_uid = award.get(f"attachment_uid_{size}") or ""
if not attachment_uid:
raise not_found("Award image not found")
row = get_table("attachments").find_one(uid=attachment_uid, deleted_at=None)
attachment = _row_to_attachment(row) if row else None
if not attachment:
raise not_found("Award image not found")
url = attachment.get("url") or ""
if not url:
raise not_found("Award image not found")
headers = {
"Cache-Control": _CACHE_CONTROL,
"ETag": f'"{attachment_uid}"',
}
return RedirectResponse(url=url, status_code=302, headers=headers)
+6
View File
@@ -123,6 +123,12 @@ DOCS_PAGES = [
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "awards",
"title": "Profile awards",
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "ai-correction",
"title": "AI content correction",
+2
View File
@@ -4,6 +4,7 @@ from devplacepy.routers.profile import (
ai_correction,
ai_modifier,
avatar,
award,
customization,
notifications,
telegram,
@@ -11,6 +12,7 @@ from devplacepy.routers.profile import (
from devplacepy.routers.profile.index import router
from devplacepy.routers.profile.usage import _ai_quota
router.include_router(award.router)
router.include_router(customization.router)
router.include_router(notifications.router)
router.include_router(ai_correction.router)
+106
View File
@@ -0,0 +1,106 @@
# retoor <retoor@molodetz.nl>
import logging
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Request
from devplacepy.database import get_blocked_uids, get_table
from devplacepy.database.awards import can_give_award, has_giver_cooldown, has_receiver_cooldown
from devplacepy.dependencies import json_or_form
from devplacepy.models import AwardGiveForm
from devplacepy.responses import action_result, json_error, wants_json
from devplacepy.services.audit import record as audit
from devplacepy.services.jobs import queue
from devplacepy.utils import generate_uid, make_combined_slug, require_user
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{username}/award")
async def give_award(
request: Request,
username: str,
data: Annotated[AwardGiveForm, json_or_form(AwardGiveForm)],
):
giver = require_user(request)
target = get_table("users").find_one(username=username)
redirect = f"/profile/{username}"
def deny(message: str, status: int = 400):
if wants_json(request):
return json_error(status, message)
return action_result(request, redirect, status_code=302)
if not target:
return deny("User not found", 404)
if target["uid"] == giver["uid"]:
return deny("You cannot give yourself an award")
blocked = get_blocked_uids(giver["uid"])
if target["uid"] in blocked:
return deny("You cannot give an award to a blocked user")
reverse_blocked = get_blocked_uids(target["uid"])
if giver["uid"] in reverse_blocked:
return deny("You cannot give an award to this user")
if has_giver_cooldown(giver["uid"]):
return deny("You can give another award later")
if has_receiver_cooldown(target["uid"]):
return deny("This user received an award recently")
if not (giver.get("api_key") or "").strip():
return deny("Your account has no API key for award generation")
description = data.description.strip()
uid = generate_uid()
slug = make_combined_slug(description, uid)
now = datetime.now(timezone.utc).isoformat()
get_table("awards").insert(
{
"uid": uid,
"slug": slug,
"description": description,
"giver_uid": giver["uid"],
"receiver_uid": target["uid"],
"attachment_uid_512": "",
"attachment_uid_256": "",
"attachment_uid_64": "",
"generated_at": None,
"created_at": now,
"job_uid": "",
"deleted_at": None,
"deleted_by": None,
}
)
job_uid = queue.enqueue(
"award",
{
"award_uid": uid,
"giver_uid": giver["uid"],
"receiver_uid": target["uid"],
"description": description,
"api_key": giver.get("api_key", ""),
},
"user",
giver["uid"],
)
get_table("awards").update({"uid": uid, "job_uid": job_uid}, ["uid"])
logger.info("%s gave award %s to %s", giver["username"], uid, username)
audit.record(
request,
"award.give",
user=giver,
target_type="user",
target_uid=target["uid"],
target_label=username,
summary=f"{giver['username']} gave award to {username}",
links=[
audit.target("user", target["uid"], username),
audit.target("award", uid, slug),
audit.job(job_uid),
],
)
return action_result(
request,
redirect,
data={"ok": True, "award_uid": uid, "award_slug": slug},
)
+21
View File
@@ -28,6 +28,11 @@ from devplacepy.database import (
mark_notifications_read_by_target,
resolve_object_url,
)
from devplacepy.database.awards import (
can_give_award,
get_prominent_award,
get_user_awards,
)
from devplacepy.content import can_view_project, enrich_items
from devplacepy.utils import (
get_current_user,
@@ -147,6 +152,17 @@ async def profile_page(
if tab == "media":
media, media_pagination = get_user_media(profile_user["uid"], page)
awards, awards_pagination = [], None
if tab == "awards":
awards, awards_pagination = get_user_awards(profile_user["uid"], page)
prominent_award = get_prominent_award(profile_user)
awards_count = int(profile_user.get("award_count") or 0)
can_give = bool(
current_user
and current_user["uid"] != profile_user["uid"]
and can_give_award(current_user["uid"], profile_user["uid"])
)
posts = []
if tab == "posts":
posts_table = get_table("posts")
@@ -404,6 +420,11 @@ async def profile_page(
"follow_pagination": follow_pagination,
"followers_count": follow_counts["followers"],
"following_count": follow_counts["following"],
"awards": awards,
"awards_pagination": awards_pagination,
"awards_count": awards_count,
"prominent_award": prominent_award,
"can_give_award": can_give,
},
model=ProfileOut,
)