Update
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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", ""),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user