Compare commits
12 Commits
3fbac87723
...
67caf3f479
| Author | SHA1 | Date | |
|---|---|---|---|
| 67caf3f479 | |||
| 0d560c3d18 | |||
| 63125e7aa5 | |||
| df03febff0 | |||
| c8be71219d | |||
| 12285ecba3 | |||
| caf59108a7 | |||
| cd37d19d8d | |||
| 32d4ee6e69 | |||
| afb954deaa | |||
| c60d89b304 | |||
| 6941c51560 |
24
AGENTS.md
24
AGENTS.md
@ -510,6 +510,30 @@ code stays tiny. Reach for these instead of hand-rolling a loop, a fetch, or a c
|
||||
`window.innerWidth` change closes it. All paths fall back to `window.inner*` when `visualViewport`
|
||||
is absent, so desktop and old browsers are untouched.
|
||||
|
||||
## Profile activity tab (clickable comments)
|
||||
|
||||
The profile **Activity** tab (`/profile/{username}?tab=activity`, public) interleaves the user's 10
|
||||
most recent posts and 10 most recent comments, newest first. Each activity item is a plain dict
|
||||
built inline in `routers/profile/index.py` (no dedicated DB helper) and carries a `url` so the whole
|
||||
card is clickable, exactly like the feed's post cards:
|
||||
|
||||
- **Post items** get `"url": resolve_object_url("post", p["uid"])` -> `/posts/{slug}`.
|
||||
- **Comment items** get `"url": f"{resolve_object_url(target_type, target_uid)}#comment-{c['uid']}"`,
|
||||
the SEO-friendly parent detail URL plus the `#comment-{uid}` anchor. This is **byte-identical to a
|
||||
comment notification's `target_url`** (`content.create_comment_record`), so clicking an activity
|
||||
comment reproduces the notification click exactly: it lands on the parent post and
|
||||
`NotificationManager.js` scrolls to / highlights the `id="comment-{uid}"` element
|
||||
(`_comment.html`). Reuse `resolve_object_url` (`database.py`) - never rebuild this URL by hand, and
|
||||
do not call `resolve_object_url("comment", uid)` here (it re-fetches the row the loop already has).
|
||||
- **Frontend:** the card is wrapped in the shared full-card overlay link (`card-link-host` +
|
||||
`_card_link.html`), the same pattern `_post_card.html` uses; inner content links (mentions, URLs
|
||||
rendered by `render_content`) stay clickable via the existing `card-link-host a:not(.card-link)`
|
||||
z-index rule (`base.css`). No new CSS.
|
||||
- **API:** `ProfileOut.activities` is `list[Any]`, so the added `url` (and, on comments, the existing
|
||||
`target_type` + `uid` = parent target uid) flow into the JSON response with no schema change - this
|
||||
is the parent-post reference exposed on each comment item. Covered by `tests/api/profile/index.py`
|
||||
(activity `url` assertions) and `tests/e2e/profile/index.py` (click-through).
|
||||
|
||||
## Profile media gallery and soft-deleted attachments
|
||||
|
||||
The profile **Media** tab (`/profile/{username}?tab=media`, public) is a paginated grid of every
|
||||
|
||||
1080
devplacepy/cli.py
1080
devplacepy/cli.py
File diff suppressed because it is too large
Load Diff
83
devplacepy/cli/__init__.py
Normal file
83
devplacepy/cli/__init__.py
Normal file
@ -0,0 +1,83 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.cli.main import main, build_parser
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
from devplacepy.cli.roles import cmd_role_get, cmd_role_set
|
||||
from devplacepy.cli.apikeys import cmd_apikey_get, cmd_apikey_reset, cmd_apikey_backfill
|
||||
from devplacepy.cli.tokens import (
|
||||
cmd_token_issue,
|
||||
cmd_token_list,
|
||||
cmd_token_revoke,
|
||||
cmd_token_revoke_all,
|
||||
cmd_token_prune,
|
||||
)
|
||||
from devplacepy.cli.devii import cmd_devii_reset_quota
|
||||
from devplacepy.cli.news import cmd_news_clear, cmd_news_sanitize
|
||||
from devplacepy.cli.attachments import cmd_attachments_prune
|
||||
from devplacepy.cli.jobs import (
|
||||
cmd_zips_prune,
|
||||
cmd_zips_clear,
|
||||
cmd_forks_prune,
|
||||
cmd_forks_clear,
|
||||
cmd_seo_prune,
|
||||
cmd_seo_clear,
|
||||
cmd_seo_meta_prune,
|
||||
cmd_seo_meta_clear,
|
||||
cmd_deepsearch_prune,
|
||||
cmd_deepsearch_clear,
|
||||
)
|
||||
from devplacepy.cli.backups import (
|
||||
cmd_backups_list,
|
||||
cmd_backups_run,
|
||||
cmd_backups_prune,
|
||||
cmd_backups_clear,
|
||||
)
|
||||
from devplacepy.cli.containers import (
|
||||
cmd_containers_list,
|
||||
cmd_containers_reconcile,
|
||||
cmd_containers_prune,
|
||||
cmd_containers_prune_builds,
|
||||
cmd_containers_gc_workspaces,
|
||||
)
|
||||
from devplacepy.cli.migrate import cmd_emoji_sync, cmd_migrate_data
|
||||
|
||||
__all__ = [
|
||||
"main",
|
||||
"build_parser",
|
||||
"_audit_cli",
|
||||
"cmd_role_get",
|
||||
"cmd_role_set",
|
||||
"cmd_apikey_get",
|
||||
"cmd_apikey_reset",
|
||||
"cmd_apikey_backfill",
|
||||
"cmd_token_issue",
|
||||
"cmd_token_list",
|
||||
"cmd_token_revoke",
|
||||
"cmd_token_revoke_all",
|
||||
"cmd_token_prune",
|
||||
"cmd_devii_reset_quota",
|
||||
"cmd_news_clear",
|
||||
"cmd_news_sanitize",
|
||||
"cmd_attachments_prune",
|
||||
"cmd_zips_prune",
|
||||
"cmd_zips_clear",
|
||||
"cmd_forks_prune",
|
||||
"cmd_forks_clear",
|
||||
"cmd_seo_prune",
|
||||
"cmd_seo_clear",
|
||||
"cmd_seo_meta_prune",
|
||||
"cmd_seo_meta_clear",
|
||||
"cmd_deepsearch_prune",
|
||||
"cmd_deepsearch_clear",
|
||||
"cmd_backups_list",
|
||||
"cmd_backups_run",
|
||||
"cmd_backups_prune",
|
||||
"cmd_backups_clear",
|
||||
"cmd_containers_list",
|
||||
"cmd_containers_reconcile",
|
||||
"cmd_containers_prune",
|
||||
"cmd_containers_prune_builds",
|
||||
"cmd_containers_gc_workspaces",
|
||||
"cmd_emoji_sync",
|
||||
"cmd_migrate_data",
|
||||
]
|
||||
6
devplacepy/cli/__main__.py
Normal file
6
devplacepy/cli/__main__.py
Normal file
@ -0,0 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.cli.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
18
devplacepy/cli/_shared.py
Normal file
18
devplacepy/cli/_shared.py
Normal file
@ -0,0 +1,18 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
|
||||
def _audit_cli(event_key, summary, metadata=None, target_type=None, target_uid=None, target_label=None, links=None):
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
audit.record_system(
|
||||
event_key,
|
||||
actor_kind="cli",
|
||||
actor_role="system",
|
||||
origin="cli",
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
target_label=target_label,
|
||||
summary=summary,
|
||||
metadata=metadata,
|
||||
links=links,
|
||||
)
|
||||
65
devplacepy/cli/apikeys.py
Normal file
65
devplacepy/cli/apikeys.py
Normal file
@ -0,0 +1,65 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import sys
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_apikey_get(args):
|
||||
users = get_table("users")
|
||||
user = users.find_one(username=args.username)
|
||||
if not user:
|
||||
print(f"User '{args.username}' not found")
|
||||
sys.exit(1)
|
||||
print(user.get("api_key", "") or "")
|
||||
|
||||
|
||||
def cmd_apikey_reset(args):
|
||||
from devplacepy.utils import generate_uid, clear_user_cache
|
||||
|
||||
users = get_table("users")
|
||||
user = users.find_one(username=args.username)
|
||||
if not user:
|
||||
print(f"User '{args.username}' not found")
|
||||
sys.exit(1)
|
||||
new_key = generate_uid()
|
||||
users.update({"uid": user["uid"], "api_key": new_key}, ["uid"])
|
||||
clear_user_cache(user["uid"])
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
_audit_cli(
|
||||
"cli.apikey.reset",
|
||||
f"CLI regenerated the API key of user {args.username}",
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=args.username,
|
||||
links=[audit.target("user", user["uid"], args.username)],
|
||||
)
|
||||
print(new_key)
|
||||
|
||||
|
||||
def cmd_apikey_backfill(args):
|
||||
from devplacepy.database import backfill_api_keys
|
||||
|
||||
updated = backfill_api_keys()
|
||||
_audit_cli(
|
||||
"cli.apikey.backfill",
|
||||
f"CLI backfilled API keys for {updated} users",
|
||||
metadata={"count": updated},
|
||||
)
|
||||
print(f"Assigned API keys to {updated} user(s) without one")
|
||||
|
||||
|
||||
def register_apikeys(subparsers):
|
||||
apikey = subparsers.add_parser("apikey", help="Manage user API keys")
|
||||
apikey_sub = apikey.add_subparsers(title="action", dest="action")
|
||||
apikey_get = apikey_sub.add_parser("get", help="Print a user's API key")
|
||||
apikey_get.add_argument("username")
|
||||
apikey_get.set_defaults(func=cmd_apikey_get)
|
||||
apikey_reset = apikey_sub.add_parser("reset", help="Regenerate a user's API key")
|
||||
apikey_reset.add_argument("username")
|
||||
apikey_reset.set_defaults(func=cmd_apikey_reset)
|
||||
apikey_backfill = apikey_sub.add_parser(
|
||||
"backfill", help="Assign API keys to users that lack one"
|
||||
)
|
||||
apikey_backfill.set_defaults(func=cmd_apikey_backfill)
|
||||
43
devplacepy/cli/attachments.py
Normal file
43
devplacepy/cli/attachments.py
Normal file
@ -0,0 +1,43 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_attachments_prune(args):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from devplacepy.database import db
|
||||
from devplacepy.attachments import delete_attachment
|
||||
|
||||
if "attachments" not in db.tables:
|
||||
print("Attachments table does not exist")
|
||||
return
|
||||
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat()
|
||||
orphans = [
|
||||
att
|
||||
for att in db["attachments"].find(target_type="", target_uid="")
|
||||
if att.get("created_at", "") < cutoff
|
||||
]
|
||||
for att in orphans:
|
||||
delete_attachment(att["uid"])
|
||||
_audit_cli(
|
||||
"cli.attachments.prune",
|
||||
f"CLI pruned {len(orphans)} orphan attachments",
|
||||
metadata={"count": len(orphans), "hours": args.hours},
|
||||
)
|
||||
print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h")
|
||||
|
||||
|
||||
def register_attachments(subparsers):
|
||||
attachments = subparsers.add_parser("attachments", help="Attachment management")
|
||||
att_sub = attachments.add_subparsers(title="action", dest="action")
|
||||
att_prune = att_sub.add_parser(
|
||||
"prune", help="Remove orphaned attachment records and files"
|
||||
)
|
||||
att_prune.add_argument(
|
||||
"--hours",
|
||||
type=int,
|
||||
default=24,
|
||||
help="Only prune orphans older than this many hours",
|
||||
)
|
||||
att_prune.set_defaults(func=cmd_attachments_prune)
|
||||
82
devplacepy/cli/backups.py
Normal file
82
devplacepy/cli/backups.py
Normal file
@ -0,0 +1,82 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import sys
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_backups_list(args):
|
||||
from devplacepy.services.backup import store
|
||||
|
||||
backups = store.list_backups()
|
||||
if not backups:
|
||||
print("No backups recorded")
|
||||
return
|
||||
for backup in backups:
|
||||
size = store.human_bytes(int(backup.get("size_bytes") or 0))
|
||||
print(
|
||||
f"{backup['uid']} {backup.get('target', ''):<9} "
|
||||
f"{backup.get('status', ''):<8} {size:>10} "
|
||||
f"{backup.get('created_at', '')} {backup.get('filename', '')}"
|
||||
)
|
||||
|
||||
|
||||
def cmd_backups_run(args):
|
||||
from devplacepy.services.backup import store
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
if not store.is_valid_target(args.target):
|
||||
print(f"Unknown target '{args.target}'. Choose one of: {', '.join(store.BACKUP_TARGETS)}")
|
||||
sys.exit(1)
|
||||
job_uid = queue.enqueue(
|
||||
"backup",
|
||||
{"target": args.target, "schedule_uid": "", "created_by": "cli"},
|
||||
owner_kind="system",
|
||||
owner_id="cli",
|
||||
preferred_name=f"{store.target_label(args.target)} (cli)",
|
||||
)
|
||||
store.create_backup(target=args.target, created_by="cli", job_uid=job_uid)
|
||||
_audit_cli(
|
||||
"cli.backups.run",
|
||||
f"CLI enqueued {args.target} backup",
|
||||
metadata={"target": args.target, "job_uid": job_uid},
|
||||
)
|
||||
print(f"Enqueued {args.target} backup job {job_uid} (processed by the running server)")
|
||||
|
||||
|
||||
def cmd_backups_prune(args):
|
||||
from devplacepy.services.backup import store
|
||||
|
||||
removed = store.prune_orphans()
|
||||
_audit_cli("cli.backups.prune", f"CLI pruned {removed} orphan backups", metadata={"count": removed})
|
||||
print(f"Pruned {removed} orphan backup record(s)")
|
||||
|
||||
|
||||
def cmd_backups_clear(args):
|
||||
from devplacepy.services.backup import store
|
||||
|
||||
removed = store.clear_all()
|
||||
_audit_cli("cli.backups.clear", f"CLI cleared all backups ({removed})", metadata={"count": removed})
|
||||
print(f"Cleared {removed} backup(s) and their archives")
|
||||
|
||||
|
||||
def register_backups(subparsers):
|
||||
backups = subparsers.add_parser("backups", help="Backup management")
|
||||
backups_sub = backups.add_subparsers(title="action", dest="action")
|
||||
backups_sub.add_parser("list", help="List recorded backups").set_defaults(
|
||||
func=cmd_backups_list
|
||||
)
|
||||
backups_run = backups_sub.add_parser(
|
||||
"run", help="Enqueue a backup (processed by the running server)"
|
||||
)
|
||||
backups_run.add_argument(
|
||||
"target",
|
||||
choices=["database", "uploads", "keys", "full"],
|
||||
help="What to back up",
|
||||
)
|
||||
backups_run.set_defaults(func=cmd_backups_run)
|
||||
backups_sub.add_parser(
|
||||
"prune", help="Remove backup records whose archive file is missing"
|
||||
).set_defaults(func=cmd_backups_prune)
|
||||
backups_sub.add_parser(
|
||||
"clear", help="Delete every backup archive and record"
|
||||
).set_defaults(func=cmd_backups_clear)
|
||||
107
devplacepy/cli/containers.py
Normal file
107
devplacepy/cli/containers.py
Normal file
@ -0,0 +1,107 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_containers_list(args):
|
||||
from devplacepy.services.containers import store
|
||||
|
||||
instances = store.all_instances()
|
||||
if not instances:
|
||||
print("No container instances")
|
||||
return
|
||||
for inst in instances:
|
||||
print(
|
||||
f"{inst['uid'][:8]} {inst.get('name', ''):24.24} {inst.get('status', ''):10} "
|
||||
f"desired={inst.get('desired_state', '')} policy={inst.get('restart_policy', '')}"
|
||||
)
|
||||
|
||||
|
||||
def cmd_containers_reconcile(args):
|
||||
import asyncio
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
|
||||
asyncio.run(ContainerService().run_once())
|
||||
_audit_cli("cli.containers.reconcile", "CLI ran one container reconcile pass")
|
||||
print("Reconcile pass complete")
|
||||
|
||||
|
||||
def cmd_containers_prune(args):
|
||||
import asyncio
|
||||
from devplacepy.services.containers.runtime import get_backend
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
|
||||
async def run():
|
||||
await ContainerService().run_once()
|
||||
await get_backend().image_prune()
|
||||
|
||||
asyncio.run(run())
|
||||
_audit_cli("cli.containers.prune", "CLI reaped orphan containers and dangling images")
|
||||
print("Reaped orphans and pruned dangling images")
|
||||
|
||||
|
||||
def cmd_containers_prune_builds(args):
|
||||
import asyncio
|
||||
from devplacepy.database import db, get_table
|
||||
from devplacepy.services.containers.runtime import get_backend
|
||||
|
||||
async def run():
|
||||
backend = get_backend()
|
||||
removed = 0
|
||||
if "builds" in db.tables:
|
||||
for build in list(get_table("builds").find()):
|
||||
tag = build.get("image_tag")
|
||||
if tag:
|
||||
await backend.remove_image(tag)
|
||||
removed += 1
|
||||
for table in ("builds", "dockerfile_versions", "dockerfiles"):
|
||||
if table in db.tables:
|
||||
get_table(table).delete()
|
||||
return removed
|
||||
|
||||
removed = asyncio.run(run())
|
||||
_audit_cli("cli.containers.prune_builds", "CLI removed legacy images and build tables", metadata={"removed": removed})
|
||||
print(
|
||||
f"Removed {removed} legacy per-project image(s) and cleared the dockerfiles/builds tables"
|
||||
)
|
||||
|
||||
|
||||
def cmd_containers_gc_workspaces(args):
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from devplacepy import config
|
||||
from devplacepy.services.containers import store
|
||||
|
||||
active = {inst["project_uid"] for inst in store.all_instances()}
|
||||
base = Path(config.CONTAINER_WORKSPACES_DIR)
|
||||
removed = 0
|
||||
if base.is_dir():
|
||||
for child in base.iterdir():
|
||||
if child.is_dir() and child.name not in active:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
removed += 1
|
||||
_audit_cli("cli.containers.gc_workspaces", f"CLI removed {removed} unused workspace dirs", metadata={"count": removed})
|
||||
print(
|
||||
f"Removed {removed} unused workspace director{'y' if removed == 1 else 'ies'}"
|
||||
)
|
||||
|
||||
|
||||
def register_containers(subparsers):
|
||||
containers = subparsers.add_parser("containers", help="Container manager")
|
||||
containers_sub = containers.add_subparsers(title="action", dest="action")
|
||||
containers_sub.add_parser("list", help="List container instances").set_defaults(
|
||||
func=cmd_containers_list
|
||||
)
|
||||
containers_sub.add_parser("reconcile", help="Run one reconcile pass").set_defaults(
|
||||
func=cmd_containers_reconcile
|
||||
)
|
||||
containers_sub.add_parser(
|
||||
"prune", help="Reap orphan containers and dangling images"
|
||||
).set_defaults(func=cmd_containers_prune)
|
||||
containers_sub.add_parser(
|
||||
"prune-builds",
|
||||
help="Remove legacy per-project images and clear the dockerfiles/builds tables",
|
||||
).set_defaults(func=cmd_containers_prune_builds)
|
||||
containers_sub.add_parser(
|
||||
"gc-workspaces", help="Remove workspace dirs with no instances"
|
||||
).set_defaults(func=cmd_containers_gc_workspaces)
|
||||
66
devplacepy/cli/devii.py
Normal file
66
devplacepy/cli/devii.py
Normal file
@ -0,0 +1,66 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import sys
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_devii_reset_quota(args):
|
||||
from devplacepy.database import db
|
||||
|
||||
table_name = "devii_usage_ledger"
|
||||
if table_name not in db.tables:
|
||||
print(f"Table '{table_name}' does not exist, nothing to reset")
|
||||
return
|
||||
table = db[table_name]
|
||||
if args.all:
|
||||
count = table.count()
|
||||
table.delete()
|
||||
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (all)", metadata={"scope": "all", "rows_removed": count})
|
||||
print(f"Reset all AI quotas ({count} ledger rows deleted)")
|
||||
return
|
||||
if args.guests:
|
||||
count = table.count(owner_kind="guest")
|
||||
table.delete(owner_kind="guest")
|
||||
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (guests)", metadata={"scope": "guests", "rows_removed": count})
|
||||
print(f"Reset all guest AI quotas ({count} ledger rows deleted)")
|
||||
return
|
||||
if not args.username:
|
||||
print("Provide a username, or --guests, or --all")
|
||||
sys.exit(1)
|
||||
user = get_table("users").find_one(username=args.username)
|
||||
if not user:
|
||||
print(f"User '{args.username}' not found")
|
||||
sys.exit(1)
|
||||
count = table.count(owner_kind="user", owner_id=user["uid"])
|
||||
table.delete(owner_kind="user", owner_id=user["uid"])
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
_audit_cli(
|
||||
"cli.devii.quota.reset",
|
||||
f"CLI reset AI quota for {args.username}",
|
||||
metadata={"scope": "user", "rows_removed": count},
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=args.username,
|
||||
links=[audit.target("user", user["uid"], args.username)],
|
||||
)
|
||||
print(f"Reset AI quota for '{args.username}' ({count} ledger rows deleted)")
|
||||
|
||||
|
||||
def register_devii(subparsers):
|
||||
devii = subparsers.add_parser("devii", help="Devii assistant management")
|
||||
devii_sub = devii.add_subparsers(title="action", dest="action")
|
||||
devii_reset = devii_sub.add_parser(
|
||||
"reset-quota", help="Reset the rolling 24h AI spend quota"
|
||||
)
|
||||
devii_reset.add_argument(
|
||||
"username", nargs="?", help="Reset the quota for a single user"
|
||||
)
|
||||
devii_reset.add_argument(
|
||||
"--guests", action="store_true", help="Reset every guest quota"
|
||||
)
|
||||
devii_reset.add_argument(
|
||||
"--all", action="store_true", help="Reset every quota (users and guests)"
|
||||
)
|
||||
devii_reset.set_defaults(func=cmd_devii_reset_quota)
|
||||
267
devplacepy/cli/jobs.py
Normal file
267
devplacepy/cli/jobs.py
Normal file
@ -0,0 +1,267 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def _remove_zip_artifacts(job):
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from devplacepy.services.jobs.zip_service import STAGING_DIR
|
||||
|
||||
local_path = (job.get("result") or {}).get("local_path")
|
||||
if local_path:
|
||||
Path(local_path).unlink(missing_ok=True)
|
||||
shutil.rmtree(STAGING_DIR / job["uid"], ignore_errors=True)
|
||||
|
||||
|
||||
def cmd_zips_prune(args):
|
||||
from datetime import datetime, timezone
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
removed = 0
|
||||
for job in queue.list_jobs(kind="zip", status=queue.DONE):
|
||||
expires_at = job.get("expires_at")
|
||||
if not expires_at:
|
||||
continue
|
||||
try:
|
||||
expiry = datetime.fromisoformat(expires_at)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if expiry < now:
|
||||
_remove_zip_artifacts(job)
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
removed += 1
|
||||
_audit_cli("cli.zips.prune", f"CLI pruned {removed} expired zip jobs", metadata={"count": removed})
|
||||
print(f"Pruned {removed} expired zip job(s)")
|
||||
|
||||
|
||||
def cmd_zips_clear(args):
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
jobs = queue.list_jobs(kind="zip")
|
||||
for job in jobs:
|
||||
_remove_zip_artifacts(job)
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
_audit_cli("cli.zips.clear", f"CLI cleared all zip jobs ({len(jobs)})", metadata={"count": len(jobs)})
|
||||
print(f"Cleared {len(jobs)} zip job(s) and their archives")
|
||||
|
||||
|
||||
def cmd_forks_prune(args):
|
||||
from datetime import datetime, timezone
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
removed = 0
|
||||
for job in queue.list_jobs(kind="fork", status=queue.DONE):
|
||||
expires_at = job.get("expires_at")
|
||||
if not expires_at:
|
||||
continue
|
||||
try:
|
||||
expiry = datetime.fromisoformat(expires_at)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if expiry < now:
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
removed += 1
|
||||
_audit_cli("cli.forks.prune", f"CLI pruned {removed} expired fork jobs", metadata={"count": removed})
|
||||
print(f"Pruned {removed} expired fork job(s)")
|
||||
|
||||
|
||||
def cmd_forks_clear(args):
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
jobs = queue.list_jobs(kind="fork")
|
||||
for job in jobs:
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
_audit_cli("cli.forks.clear", f"CLI cleared all fork jobs ({len(jobs)})", metadata={"count": len(jobs)})
|
||||
print(f"Cleared {len(jobs)} fork job(s)")
|
||||
|
||||
|
||||
def _remove_seo_artifacts(job):
|
||||
import shutil
|
||||
from devplacepy.config import SEO_REPORTS_DIR
|
||||
|
||||
shutil.rmtree(SEO_REPORTS_DIR / job["uid"], ignore_errors=True)
|
||||
|
||||
|
||||
def cmd_seo_prune(args):
|
||||
from datetime import datetime, timezone
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
removed = 0
|
||||
for job in queue.list_jobs(kind="seo", status=queue.DONE):
|
||||
expires_at = job.get("expires_at")
|
||||
if not expires_at:
|
||||
continue
|
||||
try:
|
||||
expiry = datetime.fromisoformat(expires_at)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if expiry < now:
|
||||
_remove_seo_artifacts(job)
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
removed += 1
|
||||
_audit_cli("cli.seo.prune", f"CLI pruned {removed} expired SEO jobs", metadata={"count": removed})
|
||||
print(f"Pruned {removed} expired SEO audit(s)")
|
||||
|
||||
|
||||
def cmd_seo_clear(args):
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
jobs = queue.list_jobs(kind="seo")
|
||||
for job in jobs:
|
||||
_remove_seo_artifacts(job)
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
_audit_cli("cli.seo.clear", f"CLI cleared all SEO jobs ({len(jobs)})", metadata={"count": len(jobs)})
|
||||
print(f"Cleared {len(jobs)} SEO audit(s) and their reports")
|
||||
|
||||
|
||||
def cmd_seo_meta_prune(args):
|
||||
from datetime import datetime, timezone
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
removed = 0
|
||||
for job in queue.list_jobs(kind="seo_meta", status=queue.DONE):
|
||||
expires_at = job.get("expires_at")
|
||||
if not expires_at:
|
||||
continue
|
||||
try:
|
||||
expiry = datetime.fromisoformat(expires_at)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if expiry < now:
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
removed += 1
|
||||
_audit_cli(
|
||||
"cli.seo_meta.prune",
|
||||
f"CLI pruned {removed} expired SEO metadata jobs",
|
||||
metadata={"count": removed},
|
||||
)
|
||||
print(f"Pruned {removed} expired SEO metadata job(s)")
|
||||
|
||||
|
||||
def cmd_seo_meta_clear(args):
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
jobs = queue.list_jobs(kind="seo_meta")
|
||||
for job in jobs:
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
_audit_cli(
|
||||
"cli.seo_meta.clear",
|
||||
f"CLI cleared all SEO metadata jobs ({len(jobs)})",
|
||||
metadata={"count": len(jobs)},
|
||||
)
|
||||
print(f"Cleared {len(jobs)} SEO metadata job(s); generated metadata persists")
|
||||
|
||||
|
||||
def _remove_deepsearch_artifacts(job):
|
||||
import shutil
|
||||
from devplacepy.config import DEEPSEARCH_DIR
|
||||
from devplacepy.services.deepsearch.store import VectorStore
|
||||
|
||||
uid = job["uid"]
|
||||
collection = f"ds_{uid.replace('-', '')}"
|
||||
VectorStore(collection).drop()
|
||||
shutil.rmtree(DEEPSEARCH_DIR / uid, ignore_errors=True)
|
||||
|
||||
|
||||
def cmd_deepsearch_prune(args):
|
||||
from datetime import datetime, timezone
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
removed = 0
|
||||
for job in queue.list_jobs(kind="deepsearch", status=queue.DONE):
|
||||
expires_at = job.get("expires_at")
|
||||
if not expires_at:
|
||||
continue
|
||||
try:
|
||||
expiry = datetime.fromisoformat(expires_at)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if expiry < now:
|
||||
_remove_deepsearch_artifacts(job)
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
removed += 1
|
||||
_audit_cli(
|
||||
"cli.deepsearch.prune",
|
||||
f"CLI pruned {removed} expired DeepSearch jobs",
|
||||
metadata={"count": removed},
|
||||
)
|
||||
print(f"Pruned {removed} expired DeepSearch job(s)")
|
||||
|
||||
|
||||
def cmd_deepsearch_clear(args):
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
jobs = queue.list_jobs(kind="deepsearch")
|
||||
for job in jobs:
|
||||
_remove_deepsearch_artifacts(job)
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
_audit_cli(
|
||||
"cli.deepsearch.clear",
|
||||
f"CLI cleared all DeepSearch jobs ({len(jobs)})",
|
||||
metadata={"count": len(jobs)},
|
||||
)
|
||||
print(f"Cleared {len(jobs)} DeepSearch job(s) and their collections")
|
||||
|
||||
|
||||
def register_jobs(subparsers):
|
||||
zips = subparsers.add_parser("zips", help="Zip archive job management")
|
||||
zips_sub = zips.add_subparsers(title="action", dest="action")
|
||||
zips_prune = zips_sub.add_parser(
|
||||
"prune", help="Delete expired zip archives and their job rows"
|
||||
)
|
||||
zips_prune.set_defaults(func=cmd_zips_prune)
|
||||
zips_clear = zips_sub.add_parser(
|
||||
"clear", help="Delete every zip archive and job row"
|
||||
)
|
||||
zips_clear.set_defaults(func=cmd_zips_clear)
|
||||
|
||||
forks = subparsers.add_parser("forks", help="Fork job management")
|
||||
forks_sub = forks.add_subparsers(title="action", dest="action")
|
||||
forks_prune = forks_sub.add_parser(
|
||||
"prune", help="Delete expired completed fork job rows (forked projects persist)"
|
||||
)
|
||||
forks_prune.set_defaults(func=cmd_forks_prune)
|
||||
forks_clear = forks_sub.add_parser(
|
||||
"clear", help="Delete every fork job row (forked projects persist)"
|
||||
)
|
||||
forks_clear.set_defaults(func=cmd_forks_clear)
|
||||
|
||||
seo = subparsers.add_parser("seo", help="SEO Diagnostics job management")
|
||||
seo_sub = seo.add_subparsers(title="action", dest="action")
|
||||
seo_prune = seo_sub.add_parser(
|
||||
"prune", help="Delete expired SEO audit reports and their job rows"
|
||||
)
|
||||
seo_prune.set_defaults(func=cmd_seo_prune)
|
||||
seo_clear = seo_sub.add_parser(
|
||||
"clear", help="Delete every SEO audit report and job row"
|
||||
)
|
||||
seo_clear.set_defaults(func=cmd_seo_clear)
|
||||
|
||||
seo_meta = subparsers.add_parser("seo-meta", help="SEO metadata job management")
|
||||
seo_meta_sub = seo_meta.add_subparsers(title="action", dest="action")
|
||||
seo_meta_prune = seo_meta_sub.add_parser(
|
||||
"prune", help="Delete expired SEO metadata job rows (generated metadata persists)"
|
||||
)
|
||||
seo_meta_prune.set_defaults(func=cmd_seo_meta_prune)
|
||||
seo_meta_clear = seo_meta_sub.add_parser(
|
||||
"clear", help="Delete every SEO metadata job row (generated metadata persists)"
|
||||
)
|
||||
seo_meta_clear.set_defaults(func=cmd_seo_meta_clear)
|
||||
|
||||
deepsearch = subparsers.add_parser("deepsearch", help="DeepSearch job management")
|
||||
deepsearch_sub = deepsearch.add_subparsers(title="action", dest="action")
|
||||
deepsearch_prune = deepsearch_sub.add_parser(
|
||||
"prune", help="Delete expired DeepSearch sessions and their job rows"
|
||||
)
|
||||
deepsearch_prune.set_defaults(func=cmd_deepsearch_prune)
|
||||
deepsearch_clear = deepsearch_sub.add_parser(
|
||||
"clear", help="Delete every DeepSearch session and job row"
|
||||
)
|
||||
deepsearch_clear.set_defaults(func=cmd_deepsearch_clear)
|
||||
46
devplacepy/cli/main.py
Normal file
46
devplacepy/cli/main.py
Normal file
@ -0,0 +1,46 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from devplacepy.cli.roles import register_roles
|
||||
from devplacepy.cli.apikeys import register_apikeys
|
||||
from devplacepy.cli.tokens import register_tokens
|
||||
from devplacepy.cli.news import register_news
|
||||
from devplacepy.cli.attachments import register_attachments
|
||||
from devplacepy.cli.devii import register_devii
|
||||
from devplacepy.cli.jobs import register_jobs
|
||||
from devplacepy.cli.backups import register_backups
|
||||
from devplacepy.cli.containers import register_containers
|
||||
from devplacepy.cli.migrate import register_migrate
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(description="DevPlace admin CLI")
|
||||
sub = parser.add_subparsers(title="commands", dest="command")
|
||||
|
||||
register_roles(sub)
|
||||
register_apikeys(sub)
|
||||
register_tokens(sub)
|
||||
register_news(sub)
|
||||
register_attachments(sub)
|
||||
register_devii(sub)
|
||||
register_jobs(sub)
|
||||
register_backups(sub)
|
||||
register_containers(sub)
|
||||
register_migrate(sub)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
if hasattr(args, "func"):
|
||||
args.func(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
233
devplacepy/cli/migrate.py
Normal file
233
devplacepy/cli/migrate.py
Normal file
@ -0,0 +1,233 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import sys
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def _crc32(path):
|
||||
import zlib
|
||||
|
||||
crc = 0
|
||||
with open(path, "rb") as handle:
|
||||
while True:
|
||||
chunk = handle.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
crc = zlib.crc32(chunk, crc)
|
||||
return crc & 0xFFFFFFFF
|
||||
|
||||
|
||||
def _migrate_file(source, dest, dry_run, report):
|
||||
import os
|
||||
import shutil
|
||||
|
||||
if not source.exists():
|
||||
return
|
||||
if source.resolve() == dest.resolve():
|
||||
return
|
||||
size = source.stat().st_size
|
||||
if dest.exists():
|
||||
if dest.stat().st_size == size and _crc32(dest) == _crc32(source):
|
||||
report.append(("done", source, dest, size))
|
||||
if not dry_run:
|
||||
source.unlink()
|
||||
return
|
||||
report.append(("conflict", source, dest, size))
|
||||
return
|
||||
report.append(("move", source, dest, size))
|
||||
if dry_run:
|
||||
return
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dest.with_name(dest.name + ".migrating")
|
||||
shutil.copyfile(source, tmp)
|
||||
with open(tmp, "rb") as handle:
|
||||
os.fsync(handle.fileno())
|
||||
if tmp.stat().st_size != size or _crc32(tmp) != _crc32(source):
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"verification failed copying {source} -> {dest}")
|
||||
os.replace(tmp, dest)
|
||||
source.unlink()
|
||||
|
||||
|
||||
def _prune_empty_dirs(root):
|
||||
if not root.exists():
|
||||
return
|
||||
for path in sorted(root.rglob("*"), reverse=True):
|
||||
if path.is_dir():
|
||||
try:
|
||||
path.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
root.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _migrate_tree(source, dest, dry_run, report):
|
||||
if not source.exists():
|
||||
return
|
||||
if source.resolve() == dest.resolve():
|
||||
return
|
||||
for child in sorted(source.rglob("*")):
|
||||
if child.is_file():
|
||||
_migrate_file(child, dest / child.relative_to(source), dry_run, report)
|
||||
if not dry_run:
|
||||
_prune_empty_dirs(source)
|
||||
|
||||
|
||||
def _db_is_locked(path):
|
||||
import sqlite3
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(path), timeout=0.5)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
except sqlite3.OperationalError:
|
||||
return True
|
||||
|
||||
|
||||
def _checkpoint(path):
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(str(path), timeout=5)
|
||||
try:
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _migrate_db(source, dest, dry_run, report):
|
||||
if not source.exists():
|
||||
return
|
||||
if source.resolve() == dest.resolve():
|
||||
return
|
||||
if _db_is_locked(source):
|
||||
raise RuntimeError(
|
||||
f"{source} is locked - stop the app before running migrate-data"
|
||||
)
|
||||
if not dry_run:
|
||||
_checkpoint(source)
|
||||
_migrate_file(source, dest, dry_run, report)
|
||||
for suffix in ("-wal", "-shm"):
|
||||
_migrate_file(
|
||||
source.with_name(source.name + suffix),
|
||||
dest.with_name(dest.name + suffix),
|
||||
dry_run,
|
||||
report,
|
||||
)
|
||||
|
||||
|
||||
def cmd_emoji_sync(args):
|
||||
from devplacepy.rendering import EMOJI_JS_PATH, write_emoji_module
|
||||
|
||||
count = write_emoji_module()
|
||||
_audit_cli("cli.emoji.sync", f"CLI regenerated {count} emoji shortcodes", metadata={"count": count})
|
||||
print(f"Wrote {count} emoji shortcodes to {EMOJI_JS_PATH}")
|
||||
|
||||
|
||||
def cmd_migrate_data(args):
|
||||
import os
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
from devplacepy import config
|
||||
|
||||
base = config.BASE_DIR
|
||||
home = Path.home()
|
||||
dry = args.dry_run
|
||||
report = []
|
||||
|
||||
config.ensure_data_dirs()
|
||||
|
||||
db_items = []
|
||||
if config.DATABASE_URL == f"sqlite:///{config.DATA_DIR / 'devplace.db'}":
|
||||
db_items.append((base / "devplace.db", config.DATA_DIR / "devplace.db"))
|
||||
else:
|
||||
print("Skipping main DB: DEVPLACE_DATABASE_URL points outside the data dir.")
|
||||
if not os.environ.get("DEVII_TASKS_DB"):
|
||||
db_items.append((base / "devii_tasks.db", config.DEVII_TASKS_DB))
|
||||
if not os.environ.get("DEVII_LESSONS_DB"):
|
||||
db_items.append((base / "devii_lessons.db", config.DEVII_LESSONS_DB))
|
||||
|
||||
file_items = [
|
||||
(base / name, config.KEYS_DIR / name)
|
||||
for name in (
|
||||
"notification-private.pem",
|
||||
"notification-private.pkcs8.pem",
|
||||
"notification-public.pem",
|
||||
)
|
||||
]
|
||||
registry_dest = config.BOT_DIR / "article_registry.json"
|
||||
registry_sources = [
|
||||
path
|
||||
for path in (
|
||||
home / ".dpbot_article_registry.json",
|
||||
base / ".dpbot_article_registry.json",
|
||||
)
|
||||
if path.exists()
|
||||
]
|
||||
registry_sources.sort(key=lambda path: path.stat().st_mtime, reverse=True)
|
||||
if registry_sources:
|
||||
file_items.append((registry_sources[0], registry_dest))
|
||||
for stale in registry_sources[1:]:
|
||||
print(f"Leaving older duplicate registry untouched: {stale}")
|
||||
|
||||
legacy_var = base / "var"
|
||||
tree_items = [
|
||||
(base / "devplacepy" / "static" / "uploads", config.UPLOADS_DIR),
|
||||
(home / ".devplace_bots", config.BOT_DIR),
|
||||
]
|
||||
for sub_name in ("container_workspaces", "zips", "zip_staging", "fork_staging"):
|
||||
tree_items.append((legacy_var / sub_name, config.DATA_PATHS[sub_name]))
|
||||
|
||||
try:
|
||||
for source, dest in db_items:
|
||||
_migrate_db(source, dest, dry, report)
|
||||
for source, dest in file_items:
|
||||
_migrate_file(source, dest, dry, report)
|
||||
for source, dest in tree_items:
|
||||
_migrate_tree(source, dest, dry, report)
|
||||
except RuntimeError as exc:
|
||||
print(f"ERROR: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
if not report:
|
||||
print("Nothing to migrate; the data directory is already consolidated.")
|
||||
return
|
||||
for status, source, dest, size in report:
|
||||
print(f" [{status}] {source} -> {dest} ({size} bytes)")
|
||||
counts = Counter(status for status, *_ in report)
|
||||
print()
|
||||
print(
|
||||
("Planned: " if dry else "Migrated: ")
|
||||
+ ", ".join(f"{count} {status}" for status, count in sorted(counts.items()))
|
||||
)
|
||||
if any(status == "conflict" for status, *_ in report):
|
||||
print(
|
||||
"Conflicts left both source and destination untouched; resolve them by hand."
|
||||
)
|
||||
if dry:
|
||||
print("Dry run - nothing changed. Re-run without --dry-run to apply.")
|
||||
|
||||
|
||||
def register_migrate(subparsers):
|
||||
subparsers.add_parser(
|
||||
"emoji-sync",
|
||||
help="Regenerate static/js/emoji-shortcodes.js from the emoji library",
|
||||
).set_defaults(func=cmd_emoji_sync)
|
||||
|
||||
migrate = subparsers.add_parser(
|
||||
"migrate-data",
|
||||
help="Relocate legacy runtime files into the consolidated data/ directory",
|
||||
)
|
||||
migrate.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print the source-to-destination plan without changing anything",
|
||||
)
|
||||
migrate.set_defaults(func=cmd_migrate_data)
|
||||
53
devplacepy/cli/news.py
Normal file
53
devplacepy/cli/news.py
Normal file
@ -0,0 +1,53 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.utils import strip_html
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_news_clear(args):
|
||||
from devplacepy.database import db
|
||||
|
||||
deleted = {}
|
||||
for table in ("news", "news_images", "news_sync"):
|
||||
if table in db.tables:
|
||||
count = db[table].count()
|
||||
db[table].delete()
|
||||
deleted[table] = count
|
||||
print(f"Deleted {count} rows from '{table}'")
|
||||
else:
|
||||
print(f"Table '{table}' does not exist, skipping")
|
||||
_audit_cli("cli.news.clear", "CLI cleared all news data", metadata={"deleted": deleted})
|
||||
print("News data cleared")
|
||||
|
||||
|
||||
def cmd_news_sanitize(args):
|
||||
from devplacepy.database import db
|
||||
|
||||
if "news" not in db.tables:
|
||||
print("News table does not exist")
|
||||
return
|
||||
news_table = db["news"]
|
||||
updated = 0
|
||||
for row in news_table.all():
|
||||
desc = (strip_html(row.get("description", "") or ""))[:5000]
|
||||
content = (strip_html(row.get("content", "") or ""))[:10000]
|
||||
if desc != row.get("description", "") or content != row.get("content", ""):
|
||||
news_table.update(
|
||||
{"id": row["id"], "description": desc, "content": content}, ["id"]
|
||||
)
|
||||
updated += 1
|
||||
_audit_cli("cli.news.sanitize", f"CLI sanitized {updated} news articles", metadata={"count": updated})
|
||||
print(f"Sanitized {updated} news article(s)")
|
||||
|
||||
|
||||
def register_news(subparsers):
|
||||
news = subparsers.add_parser("news", help="News management")
|
||||
news_sub = news.add_subparsers(title="action", dest="action")
|
||||
news_clear = news_sub.add_parser(
|
||||
"clear", help="Delete all news from local database"
|
||||
)
|
||||
news_clear.set_defaults(func=cmd_news_clear)
|
||||
news_sanitize = news_sub.add_parser(
|
||||
"sanitize", help="Strip HTML from all existing news descriptions and content"
|
||||
)
|
||||
news_sanitize.set_defaults(func=cmd_news_sanitize)
|
||||
57
devplacepy/cli/roles.py
Normal file
57
devplacepy/cli/roles.py
Normal file
@ -0,0 +1,57 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import sys
|
||||
from devplacepy.database import get_table, invalidate_admins_cache
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_role_get(args):
|
||||
users = get_table("users")
|
||||
user = users.find_one(username=args.username)
|
||||
if not user:
|
||||
print(f"User '{args.username}' not found")
|
||||
sys.exit(1)
|
||||
print(user.get("role", "member").lower())
|
||||
|
||||
|
||||
def cmd_role_set(args):
|
||||
role = args.role.lower()
|
||||
if role not in ("member", "admin"):
|
||||
print("Role must be 'member' or 'admin'")
|
||||
sys.exit(1)
|
||||
|
||||
users = get_table("users")
|
||||
user = users.find_one(username=args.username)
|
||||
if not user:
|
||||
print(f"User '{args.username}' not found")
|
||||
sys.exit(1)
|
||||
|
||||
old_role = user.get("role")
|
||||
users.update({"uid": user["uid"], "role": role.capitalize()}, ["uid"])
|
||||
invalidate_admins_cache()
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
_audit_cli(
|
||||
"cli.role.set",
|
||||
f"CLI set role of user {args.username} from {old_role} to {role.capitalize()}",
|
||||
metadata={"old": old_role, "new": role.capitalize()},
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=args.username,
|
||||
links=[audit.target("user", user["uid"], args.username)],
|
||||
)
|
||||
print(f"User '{args.username}' role set to '{role}'")
|
||||
|
||||
|
||||
def register_roles(subparsers):
|
||||
role = subparsers.add_parser("role", help="Manage user roles")
|
||||
role_sub = role.add_subparsers(title="action", dest="action")
|
||||
|
||||
role_get = role_sub.add_parser("get", help="Get a user's role")
|
||||
role_get.add_argument("username")
|
||||
role_get.set_defaults(func=cmd_role_get)
|
||||
|
||||
role_set = role_sub.add_parser("set", help="Set a user's role")
|
||||
role_set.add_argument("username")
|
||||
role_set.add_argument("role", choices=["member", "admin"])
|
||||
role_set.set_defaults(func=cmd_role_set)
|
||||
125
devplacepy/cli/tokens.py
Normal file
125
devplacepy/cli/tokens.py
Normal file
@ -0,0 +1,125 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import sys
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def cmd_token_issue(args):
|
||||
from devplacepy.services.access_tokens import issue_token
|
||||
|
||||
users = get_table("users")
|
||||
user = users.find_one(username=args.username)
|
||||
if not user:
|
||||
print(f"User '{args.username}' not found")
|
||||
sys.exit(1)
|
||||
|
||||
result = issue_token(user, label=args.label or "cli")
|
||||
_audit_cli(
|
||||
"cli.token.issue",
|
||||
f"CLI issued access token for {args.username}",
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=args.username,
|
||||
metadata={"token_uid": result["uid"]},
|
||||
)
|
||||
print(result["access_token"])
|
||||
|
||||
|
||||
def cmd_token_list(args):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
users = get_table("users")
|
||||
user = users.find_one(username=args.username)
|
||||
if not user:
|
||||
print(f"User '{args.username}' not found")
|
||||
sys.exit(1)
|
||||
|
||||
tokens = get_table("access_tokens")
|
||||
now = datetime.now(timezone.utc)
|
||||
found = False
|
||||
for t in tokens.find(user_uid=user["uid"], deleted_at=None):
|
||||
found = True
|
||||
expires_at = t.get("expires_at", "")
|
||||
try:
|
||||
expires = datetime.fromisoformat(expires_at)
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
status = "expired" if expires < now else "active"
|
||||
except (ValueError, TypeError):
|
||||
status = "unknown"
|
||||
label = t.get("label", "") or "-"
|
||||
print(
|
||||
f" uid={t['uid']} token={t['token'][:12]}... "
|
||||
f"label={label} expires={expires_at} status={status}"
|
||||
)
|
||||
if not found:
|
||||
print(f"No active tokens for '{args.username}'")
|
||||
|
||||
|
||||
def cmd_token_revoke(args):
|
||||
from devplacepy.services.access_tokens import revoke_token
|
||||
|
||||
ok = revoke_token(args.token_uid)
|
||||
if not ok:
|
||||
print(f"Token uid='{args.token_uid}' not found or already revoked")
|
||||
sys.exit(1)
|
||||
_audit_cli(
|
||||
"cli.token.revoke",
|
||||
f"CLI revoked access token uid={args.token_uid}",
|
||||
metadata={"token_uid": args.token_uid},
|
||||
)
|
||||
print(f"Revoked token uid='{args.token_uid}'")
|
||||
|
||||
|
||||
def cmd_token_revoke_all(args):
|
||||
from devplacepy.services.access_tokens import revoke_all
|
||||
|
||||
users = get_table("users")
|
||||
user = users.find_one(username=args.username)
|
||||
if not user:
|
||||
print(f"User '{args.username}' not found")
|
||||
sys.exit(1)
|
||||
|
||||
count = revoke_all(user["uid"])
|
||||
_audit_cli(
|
||||
"cli.token.revoke_all",
|
||||
f"CLI revoked all access tokens for {args.username}",
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=args.username,
|
||||
metadata={"count": count},
|
||||
)
|
||||
print(f"Revoked {count} token(s) for '{args.username}'")
|
||||
|
||||
|
||||
def cmd_token_prune(args):
|
||||
from devplacepy.services.access_tokens import prune_expired
|
||||
|
||||
count = prune_expired()
|
||||
_audit_cli(
|
||||
"cli.token.prune",
|
||||
f"CLI pruned {count} expired access tokens",
|
||||
metadata={"count": count},
|
||||
)
|
||||
print(f"Pruned {count} expired token(s)")
|
||||
|
||||
|
||||
def register_tokens(subparsers):
|
||||
token = subparsers.add_parser("token", help="Manage DevPlace access tokens")
|
||||
token_sub = token.add_subparsers(title="action", dest="action")
|
||||
token_issue = token_sub.add_parser("issue", help="Issue an access token for a user")
|
||||
token_issue.add_argument("username")
|
||||
token_issue.add_argument("--label", default="cli", help="Optional label for the token")
|
||||
token_issue.set_defaults(func=cmd_token_issue)
|
||||
token_list = token_sub.add_parser("list", help="List a user's active access tokens")
|
||||
token_list.add_argument("username")
|
||||
token_list.set_defaults(func=cmd_token_list)
|
||||
token_revoke = token_sub.add_parser("revoke", help="Revoke a single access token by uid")
|
||||
token_revoke.add_argument("token_uid")
|
||||
token_revoke.set_defaults(func=cmd_token_revoke)
|
||||
token_revoke_all = token_sub.add_parser("revoke-all", help="Revoke all access tokens for a user")
|
||||
token_revoke_all.add_argument("username")
|
||||
token_revoke_all.set_defaults(func=cmd_token_revoke_all)
|
||||
token_prune = token_sub.add_parser("prune", help="Soft-delete all expired access tokens")
|
||||
token_prune.set_defaults(func=cmd_token_prune)
|
||||
File diff suppressed because it is too large
Load Diff
230
devplacepy/database/__init__.py
Normal file
230
devplacepy/database/__init__.py
Normal file
@ -0,0 +1,230 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import dataset, logging, Path, or_, defaultdict, datetime, timedelta, timezone, TTLCache, DATABASE_URL, DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, ensure_data_dirs, logger, db
|
||||
from .core import refresh_snapshot, _local_cache_versions, _cache_version_cache, _cache_state_ready, _ensure_cache_state, get_cache_version, bump_cache_version, sync_local_cache, _index, _drop_index, _uid_index, get_table, _in_clause, _now_iso
|
||||
from .settings import _settings_cache, get_setting, get_int_setting, set_setting, clear_settings_cache, internal_gateway_key
|
||||
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, get_primary_admin_uid, search_users_by_username
|
||||
from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations
|
||||
from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, build_pagination
|
||||
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event
|
||||
from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post
|
||||
from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage
|
||||
from .seo_meta import SEO_META_TYPES, get_seo_metadata, get_seo_metadata_batch, has_fresh_seo_metadata, upsert_seo_metadata, mark_seo_metadata_stale
|
||||
from .activity import record_activity, record_unique_activity, get_user_activity, _activity_cache, _ACTIVITY_TABLES, get_activity_calendar, _activity_level, get_first_activity_date, HEATMAP_WEEKS, get_activity_heatmap, get_activity_months, get_streaks
|
||||
from .customization import CUSTOMIZATION_GLOBAL_SCOPE, CUSTOMIZATION_LANGS, _customizations_cache, _customization_key, CUSTOMIZATION_PREF_COLUMNS, get_customization_prefs, set_customization_pref, get_custom_overrides, get_custom_override, list_custom_overrides, set_custom_override, delete_custom_override
|
||||
from .email import EMAIL_ACCOUNT_DEFAULTS, list_email_accounts, get_email_account, set_email_account, delete_email_account
|
||||
from .notifications import NOTIFICATION_TYPES, NOTIFICATION_CHANNELS, _NOTIFICATION_CHANNEL_COLUMNS, _NOTIFICATION_CHANNEL_DEFAULTS, _NOTIFICATION_TYPE_KEYS, _notification_prefs_cache, _notification_default, get_notification_default, set_notification_default, _notification_overrides, notification_enabled, get_notification_prefs, set_notification_pref, reset_notification_prefs, mark_notifications_read_by_target
|
||||
from .forks import record_fork, get_fork_parent, count_forks, soft_delete_fork_relations, delete_fork_relations
|
||||
from .follows import get_follow_counts, get_follow_list, get_following_among
|
||||
from .deepsearch import _ds_now, create_deepsearch_session, update_deepsearch_session, get_deepsearch_session, add_deepsearch_message, get_deepsearch_messages, get_cached_deepsearch_url, upsert_deepsearch_url_cache
|
||||
from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid
|
||||
from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids
|
||||
from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news
|
||||
from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_deleted_media
|
||||
from .stats import _stats_cache, get_site_stats, _analytics_cache, get_platform_analytics, _gist_languages_cache, get_gist_languages
|
||||
from .schema import BUG_TABLE_RENAMES, migrate_bug_tables_to_issue_tables, init_db, _refresh_query_planner_stats, OLD_GATEWAY_URL, migrate_ai_gateway_settings, backfill_api_keys, _backfill_gamification
|
||||
|
||||
__all__ = [
|
||||
"dataset",
|
||||
"logging",
|
||||
"Path",
|
||||
"or_",
|
||||
"defaultdict",
|
||||
"datetime",
|
||||
"timedelta",
|
||||
"timezone",
|
||||
"TTLCache",
|
||||
"DATABASE_URL",
|
||||
"DEFAULT_CORRECTION_PROMPT",
|
||||
"DEFAULT_MODIFIER_PROMPT",
|
||||
"INTERNAL_GATEWAY_URL",
|
||||
"ensure_data_dirs",
|
||||
"logger",
|
||||
"db",
|
||||
"refresh_snapshot",
|
||||
"_local_cache_versions",
|
||||
"_cache_version_cache",
|
||||
"_cache_state_ready",
|
||||
"_ensure_cache_state",
|
||||
"get_cache_version",
|
||||
"bump_cache_version",
|
||||
"sync_local_cache",
|
||||
"_index",
|
||||
"_drop_index",
|
||||
"_uid_index",
|
||||
"get_table",
|
||||
"_in_clause",
|
||||
"_now_iso",
|
||||
"_settings_cache",
|
||||
"get_setting",
|
||||
"get_int_setting",
|
||||
"set_setting",
|
||||
"clear_settings_cache",
|
||||
"internal_gateway_key",
|
||||
"get_users_by_uids",
|
||||
"_admins_cache",
|
||||
"invalidate_admins_cache",
|
||||
"get_admin_uids",
|
||||
"set_user_timezone",
|
||||
"get_primary_admin_uid",
|
||||
"search_users_by_username",
|
||||
"_relations_cache",
|
||||
"get_user_relations",
|
||||
"get_blocked_uids",
|
||||
"get_muted_uids",
|
||||
"get_silenced_uids",
|
||||
"invalidate_user_relations",
|
||||
"PAGE_SIZE",
|
||||
"paginate",
|
||||
"interleave_by_author",
|
||||
"paginate_diverse",
|
||||
"get_user_post_count",
|
||||
"build_pagination",
|
||||
"SOFT_DELETE_TABLES",
|
||||
"ensure_soft_delete_columns",
|
||||
"soft_delete",
|
||||
"soft_delete_in",
|
||||
"restore",
|
||||
"purge",
|
||||
"list_deleted",
|
||||
"count_deleted",
|
||||
"restore_event",
|
||||
"purge_event",
|
||||
"_comment_count_cache",
|
||||
"get_comment_counts_by_post_uids",
|
||||
"get_post_counts_by_user_uids",
|
||||
"get_vote_counts",
|
||||
"get_user_votes",
|
||||
"get_reactions_by_targets",
|
||||
"get_user_bookmarks",
|
||||
"get_polls_by_post_uids",
|
||||
"get_poll_for_post",
|
||||
"_add_usage",
|
||||
"_get_usage",
|
||||
"add_correction_usage",
|
||||
"get_correction_usage",
|
||||
"add_modifier_usage",
|
||||
"get_modifier_usage",
|
||||
"NEWS_USAGE_KEY",
|
||||
"add_news_usage",
|
||||
"get_news_usage",
|
||||
"ISSUE_USAGE_KEY",
|
||||
"add_issue_usage",
|
||||
"get_issue_usage",
|
||||
"SEO_USAGE_KEY",
|
||||
"add_seo_usage",
|
||||
"get_seo_usage",
|
||||
"SEO_META_TYPES",
|
||||
"get_seo_metadata",
|
||||
"get_seo_metadata_batch",
|
||||
"has_fresh_seo_metadata",
|
||||
"upsert_seo_metadata",
|
||||
"mark_seo_metadata_stale",
|
||||
"record_activity",
|
||||
"record_unique_activity",
|
||||
"get_user_activity",
|
||||
"_activity_cache",
|
||||
"_ACTIVITY_TABLES",
|
||||
"get_activity_calendar",
|
||||
"_activity_level",
|
||||
"get_first_activity_date",
|
||||
"HEATMAP_WEEKS",
|
||||
"get_activity_heatmap",
|
||||
"get_activity_months",
|
||||
"get_streaks",
|
||||
"CUSTOMIZATION_GLOBAL_SCOPE",
|
||||
"CUSTOMIZATION_LANGS",
|
||||
"_customizations_cache",
|
||||
"_customization_key",
|
||||
"CUSTOMIZATION_PREF_COLUMNS",
|
||||
"get_customization_prefs",
|
||||
"set_customization_pref",
|
||||
"get_custom_overrides",
|
||||
"get_custom_override",
|
||||
"list_custom_overrides",
|
||||
"set_custom_override",
|
||||
"delete_custom_override",
|
||||
"EMAIL_ACCOUNT_DEFAULTS",
|
||||
"list_email_accounts",
|
||||
"get_email_account",
|
||||
"set_email_account",
|
||||
"delete_email_account",
|
||||
"NOTIFICATION_TYPES",
|
||||
"NOTIFICATION_CHANNELS",
|
||||
"_NOTIFICATION_CHANNEL_COLUMNS",
|
||||
"_NOTIFICATION_CHANNEL_DEFAULTS",
|
||||
"_NOTIFICATION_TYPE_KEYS",
|
||||
"_notification_prefs_cache",
|
||||
"_notification_default",
|
||||
"get_notification_default",
|
||||
"set_notification_default",
|
||||
"_notification_overrides",
|
||||
"notification_enabled",
|
||||
"get_notification_prefs",
|
||||
"set_notification_pref",
|
||||
"reset_notification_prefs",
|
||||
"mark_notifications_read_by_target",
|
||||
"record_fork",
|
||||
"get_fork_parent",
|
||||
"count_forks",
|
||||
"soft_delete_fork_relations",
|
||||
"delete_fork_relations",
|
||||
"get_follow_counts",
|
||||
"get_follow_list",
|
||||
"get_following_among",
|
||||
"_ds_now",
|
||||
"create_deepsearch_session",
|
||||
"update_deepsearch_session",
|
||||
"get_deepsearch_session",
|
||||
"add_deepsearch_message",
|
||||
"get_deepsearch_messages",
|
||||
"get_cached_deepsearch_url",
|
||||
"upsert_deepsearch_url_cache",
|
||||
"VOTABLE_TARGETS",
|
||||
"STAR_TARGETS",
|
||||
"_authors_cache",
|
||||
"_ranked_authors",
|
||||
"_rank_map",
|
||||
"get_top_authors",
|
||||
"get_leaderboard",
|
||||
"get_user_rank",
|
||||
"get_user_stars",
|
||||
"update_target_stars",
|
||||
"soft_delete_engagement",
|
||||
"delete_engagement",
|
||||
"get_target_owner_uid",
|
||||
"_drop_blocked",
|
||||
"_build_comment_items",
|
||||
"load_comments",
|
||||
"get_recent_comments_by_target_uids",
|
||||
"get_recent_comments_by_post_uids",
|
||||
"load_comments_by_target_uids",
|
||||
"resolve_by_slug",
|
||||
"resolve_object_url",
|
||||
"get_uids_by_username_match",
|
||||
"text_search_clause",
|
||||
"get_daily_topic",
|
||||
"get_featured_news",
|
||||
"get_attachments",
|
||||
"get_attachments_by_type",
|
||||
"get_news_images_by_uids",
|
||||
"delete_attachment_record",
|
||||
"delete_attachments",
|
||||
"_delete_attachment_file",
|
||||
"get_user_media",
|
||||
"get_deleted_media",
|
||||
"_stats_cache",
|
||||
"get_site_stats",
|
||||
"_analytics_cache",
|
||||
"get_platform_analytics",
|
||||
"_gist_languages_cache",
|
||||
"get_gist_languages",
|
||||
"BUG_TABLE_RENAMES",
|
||||
"migrate_bug_tables_to_issue_tables",
|
||||
"init_db",
|
||||
"_refresh_query_planner_stats",
|
||||
"OLD_GATEWAY_URL",
|
||||
"migrate_ai_gateway_settings",
|
||||
"backfill_api_keys",
|
||||
"_backfill_gamification",
|
||||
]
|
||||
182
devplacepy/database/activity.py
Normal file
182
devplacepy/database/activity.py
Normal file
@ -0,0 +1,182 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import TTLCache, datetime, db, timedelta, timezone
|
||||
|
||||
|
||||
def record_activity(user_uid: str, action: str) -> int:
|
||||
if not user_uid or not action or "user_activity" not in db.tables:
|
||||
return 0
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with db:
|
||||
db.query(
|
||||
"INSERT INTO user_activity (user_uid, action, count, first_at, last_at) "
|
||||
"VALUES (:u, :a, 1, :now, :now) "
|
||||
"ON CONFLICT(user_uid, action) DO UPDATE SET "
|
||||
"count = count + 1, last_at = excluded.last_at",
|
||||
u=user_uid,
|
||||
a=action,
|
||||
now=now,
|
||||
)
|
||||
rows = list(
|
||||
db.query(
|
||||
"SELECT count FROM user_activity WHERE user_uid = :u AND action = :a",
|
||||
u=user_uid,
|
||||
a=action,
|
||||
)
|
||||
)
|
||||
return int(rows[0]["count"]) if rows else 0
|
||||
|
||||
|
||||
def record_unique_activity(user_uid: str, action: str, target: str) -> int | None:
|
||||
if not user_uid or not action or "user_activity_seen" not in db.tables:
|
||||
return None
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with db:
|
||||
db.query(
|
||||
"INSERT OR IGNORE INTO user_activity_seen "
|
||||
"(user_uid, action, target, created_at) VALUES (:u, :a, :t, :now)",
|
||||
u=user_uid,
|
||||
a=action,
|
||||
t=str(target),
|
||||
now=now,
|
||||
)
|
||||
changed = list(db.query("SELECT changes() AS c"))
|
||||
if not changed or not changed[0]["c"]:
|
||||
return None
|
||||
rows = list(
|
||||
db.query(
|
||||
"SELECT COUNT(*) AS c FROM user_activity_seen "
|
||||
"WHERE user_uid = :u AND action = :a",
|
||||
u=user_uid,
|
||||
a=action,
|
||||
)
|
||||
)
|
||||
return int(rows[0]["c"]) if rows else 0
|
||||
|
||||
|
||||
def get_user_activity(user_uid: str) -> dict:
|
||||
if not user_uid or "user_activity" not in db.tables:
|
||||
return {}
|
||||
rows = db.query(
|
||||
"SELECT action, count FROM user_activity WHERE user_uid = :u",
|
||||
u=user_uid,
|
||||
)
|
||||
return {row["action"]: int(row["count"]) for row in rows}
|
||||
|
||||
|
||||
_activity_cache = TTLCache(ttl=300, max_size=1000)
|
||||
|
||||
|
||||
_ACTIVITY_TABLES = ("posts", "comments", "gists", "projects")
|
||||
|
||||
|
||||
def get_activity_calendar(user_uid: str) -> dict:
|
||||
cached = _activity_cache.get(user_uid)
|
||||
if cached is not None:
|
||||
return cached
|
||||
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
|
||||
calendar: dict[str, int] = {}
|
||||
if sources:
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=364)).date().isoformat()
|
||||
union = " UNION ALL ".join(
|
||||
f"SELECT created_at FROM {table} WHERE user_uid = :u AND deleted_at IS NULL"
|
||||
for table in sources
|
||||
)
|
||||
rows = db.query(
|
||||
f"SELECT date(created_at) AS day, COUNT(*) AS c FROM ({union}) WHERE date(created_at) >= :cutoff GROUP BY day",
|
||||
u=user_uid,
|
||||
cutoff=cutoff,
|
||||
)
|
||||
for row in rows:
|
||||
if row["day"]:
|
||||
calendar[row["day"]] = row["c"]
|
||||
_activity_cache.set(user_uid, calendar)
|
||||
return calendar
|
||||
|
||||
|
||||
def _activity_level(count: int) -> int:
|
||||
if count <= 0:
|
||||
return 0
|
||||
if count == 1:
|
||||
return 1
|
||||
if count <= 3:
|
||||
return 2
|
||||
if count <= 6:
|
||||
return 3
|
||||
return 4
|
||||
|
||||
|
||||
def get_first_activity_date(user_uid: str):
|
||||
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
|
||||
if not sources:
|
||||
return None
|
||||
union = " UNION ALL ".join(
|
||||
f"SELECT MIN(created_at) AS m FROM {table} WHERE user_uid = :u AND deleted_at IS NULL"
|
||||
for table in sources
|
||||
)
|
||||
for row in db.query(f"SELECT MIN(m) AS first FROM ({union})", u=user_uid):
|
||||
if row["first"]:
|
||||
return datetime.fromisoformat(row["first"]).date()
|
||||
return None
|
||||
|
||||
|
||||
HEATMAP_WEEKS = 53
|
||||
|
||||
|
||||
def get_activity_heatmap(user_uid: str) -> list:
|
||||
calendar = get_activity_calendar(user_uid)
|
||||
today = datetime.now(timezone.utc).date()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
start = week_start - timedelta(weeks=HEATMAP_WEEKS - 1)
|
||||
first = get_first_activity_date(user_uid)
|
||||
if first:
|
||||
first_week = first - timedelta(days=first.weekday())
|
||||
if first_week > start:
|
||||
start = first_week
|
||||
weeks = []
|
||||
for w in range(HEATMAP_WEEKS):
|
||||
week = []
|
||||
for d in range(7):
|
||||
day = start + timedelta(days=w * 7 + d)
|
||||
iso = day.isoformat()
|
||||
count = calendar.get(iso, 0)
|
||||
week.append({"date": iso, "count": count, "level": _activity_level(count)})
|
||||
weeks.append(week)
|
||||
return weeks
|
||||
|
||||
|
||||
def get_activity_months(weeks: list) -> list:
|
||||
if not weeks:
|
||||
return []
|
||||
last = len(weeks) - 1
|
||||
labels = []
|
||||
for i in range(6):
|
||||
column = round(i * last / 5)
|
||||
iso = weeks[column][0]["date"]
|
||||
labels.append(datetime.fromisoformat(iso).strftime("%b"))
|
||||
return labels
|
||||
|
||||
|
||||
def get_streaks(user_uid: str) -> dict:
|
||||
calendar = get_activity_calendar(user_uid)
|
||||
if not calendar:
|
||||
return {"current": 0, "longest": 0}
|
||||
dates = sorted(datetime.fromisoformat(day).date() for day in calendar)
|
||||
date_set = set(dates)
|
||||
longest = 1
|
||||
run = 1
|
||||
for index in range(1, len(dates)):
|
||||
if (dates[index] - dates[index - 1]).days == 1:
|
||||
run += 1
|
||||
else:
|
||||
run = 1
|
||||
longest = max(longest, run)
|
||||
today = datetime.now(timezone.utc).date()
|
||||
cursor = today
|
||||
if today not in date_set and (today - timedelta(days=1)) in date_set:
|
||||
cursor = today - timedelta(days=1)
|
||||
current = 0
|
||||
while cursor in date_set:
|
||||
current += 1
|
||||
cursor = cursor - timedelta(days=1)
|
||||
return {"current": current, "longest": longest}
|
||||
153
devplacepy/database/attachments_data.py
Normal file
153
devplacepy/database/attachments_data.py
Normal file
@ -0,0 +1,153 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import db, logger
|
||||
from .users import get_users_by_uids
|
||||
from .pagination import build_pagination
|
||||
from .content import resolve_object_url
|
||||
|
||||
|
||||
def get_attachments(resource_type: str, resource_uid: str) -> list:
|
||||
if "attachments" not in db.tables:
|
||||
return []
|
||||
return list(
|
||||
db["attachments"].find(
|
||||
resource_type=resource_type,
|
||||
resource_uid=resource_uid,
|
||||
deleted_at=None,
|
||||
order_by=["created_at"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_attachments_by_type(resource_type: str, resource_uids: list) -> dict:
|
||||
if not resource_uids or "attachments" not in db.tables:
|
||||
return {}
|
||||
rows = list(
|
||||
db["attachments"].find(
|
||||
db["attachments"].table.columns.resource_uid.in_(resource_uids),
|
||||
db["attachments"].table.columns.deleted_at.is_(None),
|
||||
resource_type=resource_type,
|
||||
)
|
||||
)
|
||||
result = {}
|
||||
for a in rows:
|
||||
key = a["resource_uid"]
|
||||
if key not in result:
|
||||
result[key] = []
|
||||
result[key].append(a)
|
||||
return result
|
||||
|
||||
|
||||
def get_news_images_by_uids(news_uids: list) -> dict:
|
||||
if not news_uids or "news_images" not in db.tables:
|
||||
return {}
|
||||
images_table = db["news_images"]
|
||||
if not images_table.has_column("news_uid"):
|
||||
return {}
|
||||
rows = images_table.find(
|
||||
images_table.table.columns.news_uid.in_(news_uids),
|
||||
images_table.table.columns.deleted_at.is_(None),
|
||||
order_by=["uid"],
|
||||
)
|
||||
result = {}
|
||||
for r in rows:
|
||||
result.setdefault(r["news_uid"], r["url"])
|
||||
return result
|
||||
|
||||
|
||||
def delete_attachment_record(uid: str) -> None:
|
||||
if "attachments" not in db.tables:
|
||||
return
|
||||
att = db["attachments"].find_one(uid=uid)
|
||||
if att:
|
||||
_delete_attachment_file(att)
|
||||
db["attachments"].delete(id=att["id"])
|
||||
|
||||
|
||||
def delete_attachments(resource_type: str, resource_uid: str) -> None:
|
||||
if "attachments" not in db.tables:
|
||||
return
|
||||
for a in db["attachments"].find(
|
||||
resource_type=resource_type, resource_uid=resource_uid
|
||||
):
|
||||
_delete_attachment_file(a)
|
||||
db["attachments"].delete(resource_type=resource_type, resource_uid=resource_uid)
|
||||
|
||||
|
||||
def _delete_attachment_file(att: dict) -> None:
|
||||
from devplacepy.config import ATTACHMENTS_DIR
|
||||
|
||||
directory = att.get("directory", "")
|
||||
stored_name = att.get("stored_name", "")
|
||||
if not (directory and stored_name):
|
||||
return
|
||||
file_path = ATTACHMENTS_DIR / directory / stored_name
|
||||
try:
|
||||
file_path.unlink(missing_ok=True)
|
||||
parent = file_path.parent
|
||||
if parent.exists() and not any(parent.iterdir()):
|
||||
parent.rmdir()
|
||||
grandparent = parent.parent
|
||||
if grandparent.exists() and not any(grandparent.iterdir()):
|
||||
grandparent.rmdir()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete attachment file {stored_name}: {e}")
|
||||
|
||||
|
||||
def get_user_media(user_uid: str, page: int = 1, per_page: int = 24) -> tuple:
|
||||
if "attachments" not in db.tables:
|
||||
return [], build_pagination(page, 0, per_page)
|
||||
from devplacepy.attachments import _row_to_attachment
|
||||
|
||||
total = list(
|
||||
db.query(
|
||||
"SELECT COUNT(*) AS n FROM attachments "
|
||||
"WHERE user_uid=:u AND target_type != '' AND deleted_at IS NULL",
|
||||
u=user_uid,
|
||||
)
|
||||
)[0]["n"]
|
||||
pagination = build_pagination(page, total, per_page)
|
||||
offset = (pagination["page"] - 1) * pagination["per_page"]
|
||||
rows = db.query(
|
||||
"SELECT * FROM attachments "
|
||||
"WHERE user_uid=:u AND target_type != '' AND deleted_at IS NULL "
|
||||
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
|
||||
u=user_uid,
|
||||
limit=pagination["per_page"],
|
||||
offset=offset,
|
||||
)
|
||||
items = []
|
||||
for row in rows:
|
||||
item = _row_to_attachment(row)
|
||||
item["target_url"] = resolve_object_url(item["target_type"], item["target_uid"])
|
||||
items.append(item)
|
||||
return items, pagination
|
||||
|
||||
|
||||
def get_deleted_media(page: int = 1, per_page: int = 24) -> tuple:
|
||||
if "attachments" not in db.tables:
|
||||
return [], build_pagination(page, 0, per_page)
|
||||
from devplacepy.attachments import _row_to_attachment
|
||||
|
||||
total = list(
|
||||
db.query("SELECT COUNT(*) AS n FROM attachments WHERE deleted_at IS NOT NULL")
|
||||
)[0]["n"]
|
||||
pagination = build_pagination(page, total, per_page)
|
||||
offset = (pagination["page"] - 1) * pagination["per_page"]
|
||||
rows = db.query(
|
||||
"SELECT * FROM attachments WHERE deleted_at IS NOT NULL "
|
||||
"ORDER BY deleted_at DESC LIMIT :limit OFFSET :offset",
|
||||
limit=pagination["per_page"],
|
||||
offset=offset,
|
||||
)
|
||||
rows = list(rows)
|
||||
uploaders = get_users_by_uids([row.get("user_uid") for row in rows])
|
||||
items = []
|
||||
for row in rows:
|
||||
item = _row_to_attachment(row)
|
||||
item["target_url"] = resolve_object_url(item["target_type"], item["target_uid"])
|
||||
item["deleted_at"] = row.get("deleted_at", "")
|
||||
uploader = uploaders.get(row.get("user_uid"))
|
||||
item["uploader"] = uploader["username"] if uploader else "unknown"
|
||||
items.append(item)
|
||||
return items, pagination
|
||||
155
devplacepy/database/comments.py
Normal file
155
devplacepy/database/comments.py
Normal file
@ -0,0 +1,155 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import _in_clause, db, defaultdict
|
||||
from .users import get_users_by_uids
|
||||
from .relations import get_blocked_uids
|
||||
from .engagement import get_reactions_by_targets, get_user_votes, get_vote_counts
|
||||
|
||||
|
||||
def _drop_blocked(raw, user):
|
||||
if not user:
|
||||
return raw
|
||||
blocked = get_blocked_uids(user["uid"])
|
||||
if not blocked:
|
||||
return raw
|
||||
return [c for c in raw if c["user_uid"] not in blocked]
|
||||
|
||||
|
||||
def _build_comment_items(raw, user=None):
|
||||
uids = [c["user_uid"] for c in raw]
|
||||
cids = [c["uid"] for c in raw]
|
||||
users = get_users_by_uids(uids)
|
||||
ups, downs = get_vote_counts(cids)
|
||||
user_votes = get_user_votes(user["uid"], cids) if user else {}
|
||||
reactions = get_reactions_by_targets("comment", cids, user)
|
||||
from devplacepy.utils import time_ago
|
||||
from devplacepy.attachments import get_attachments_batch as _gab
|
||||
|
||||
atts_map = _gab("comment", cids) if "attachments" in db.tables else {}
|
||||
items = {}
|
||||
for c in raw:
|
||||
items[c["uid"]] = {
|
||||
"comment": c,
|
||||
"author": users.get(c["user_uid"]),
|
||||
"time_ago": time_ago(c["created_at"]),
|
||||
"votes": {"up": ups.get(c["uid"], 0), "down": downs.get(c["uid"], 0)},
|
||||
"my_vote": user_votes.get(c["uid"], 0),
|
||||
"children": [],
|
||||
"attachments": atts_map.get(c["uid"], []),
|
||||
"reactions": reactions.get(c["uid"], {"counts": {}, "mine": []}),
|
||||
}
|
||||
return items
|
||||
|
||||
|
||||
def load_comments(target_type, target_uid, user=None):
|
||||
if "comments" not in db.tables:
|
||||
return []
|
||||
comments_table = db["comments"]
|
||||
raw = list(
|
||||
comments_table.find(
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
deleted_at=None,
|
||||
order_by=["created_at"],
|
||||
)
|
||||
)
|
||||
if not raw and target_type == "post":
|
||||
raw = list(
|
||||
comments_table.find(
|
||||
post_uid=target_uid, deleted_at=None, order_by=["created_at"]
|
||||
)
|
||||
)
|
||||
raw = _drop_blocked(raw, user)
|
||||
if not raw:
|
||||
return []
|
||||
cmap = _build_comment_items(raw, user)
|
||||
top = []
|
||||
for item in cmap.values():
|
||||
parent = item["comment"].get("parent_uid")
|
||||
if parent and parent in cmap:
|
||||
cmap[parent]["children"].append(item)
|
||||
else:
|
||||
top.append(item)
|
||||
return top
|
||||
|
||||
|
||||
def get_recent_comments_by_target_uids(target_type, target_uids, limit=3, user=None):
|
||||
if not target_uids or "comments" not in db.tables:
|
||||
return {}
|
||||
placeholders, params = _in_clause(target_uids)
|
||||
params["tt"] = target_type
|
||||
params["lim"] = limit
|
||||
raw = list(
|
||||
db.query(
|
||||
f"SELECT * FROM ("
|
||||
f" SELECT *, ROW_NUMBER() OVER ("
|
||||
f" PARTITION BY target_uid ORDER BY created_at DESC, id DESC"
|
||||
f" ) AS rn FROM comments"
|
||||
f" WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL"
|
||||
f") WHERE rn <= :lim ORDER BY target_uid, created_at ASC",
|
||||
**params,
|
||||
)
|
||||
)
|
||||
raw = _drop_blocked(raw, user)
|
||||
if not raw:
|
||||
return {}
|
||||
items = _build_comment_items(raw, user)
|
||||
by_target = defaultdict(list)
|
||||
for c in raw:
|
||||
by_target[c["target_uid"]].append(c)
|
||||
result = {}
|
||||
for target_uid, group in by_target.items():
|
||||
in_group = {c["uid"] for c in group}
|
||||
top = []
|
||||
for c in group:
|
||||
item = items[c["uid"]]
|
||||
item["children"] = []
|
||||
for c in group:
|
||||
item = items[c["uid"]]
|
||||
parent = c.get("parent_uid")
|
||||
if parent and parent in in_group:
|
||||
items[parent]["children"].append(item)
|
||||
else:
|
||||
top.append(item)
|
||||
result[target_uid] = top
|
||||
return result
|
||||
|
||||
|
||||
def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
|
||||
return get_recent_comments_by_target_uids("post", post_uids, limit, user)
|
||||
|
||||
|
||||
def load_comments_by_target_uids(target_type, target_uids, user=None):
|
||||
if not target_uids or "comments" not in db.tables:
|
||||
return {}
|
||||
placeholders, params = _in_clause(target_uids)
|
||||
params["tt"] = target_type
|
||||
raw = list(
|
||||
db.query(
|
||||
f"SELECT * FROM comments WHERE target_type=:tt AND target_uid IN ({placeholders}) ORDER BY created_at",
|
||||
**params,
|
||||
)
|
||||
)
|
||||
raw = _drop_blocked(raw, user)
|
||||
if not raw:
|
||||
return {}
|
||||
from collections import defaultdict
|
||||
by_uid = defaultdict(list)
|
||||
for c in raw:
|
||||
by_uid[c["target_uid"]].append(c)
|
||||
result = {}
|
||||
for uid in target_uids:
|
||||
group = by_uid.get(uid, [])
|
||||
if not group:
|
||||
result[uid] = []
|
||||
continue
|
||||
cmap = _build_comment_items(group, user)
|
||||
tree = []
|
||||
for item in cmap.values():
|
||||
parent = item["comment"].get("parent_uid")
|
||||
if parent and parent in cmap:
|
||||
cmap[parent]["children"].append(item)
|
||||
else:
|
||||
tree.append(item)
|
||||
result[uid] = tree
|
||||
return result
|
||||
124
devplacepy/database/content.py
Normal file
124
devplacepy/database/content.py
Normal file
@ -0,0 +1,124 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import db, get_table, or_
|
||||
|
||||
|
||||
def resolve_by_slug(table, slug, include_deleted=False):
|
||||
has_soft_delete = table.has_column("deleted_at")
|
||||
flt = {} if include_deleted or not has_soft_delete else {"deleted_at": None}
|
||||
entry = table.find_one(slug=slug, **flt)
|
||||
if not entry:
|
||||
entry = table.find_one(uid=slug, **flt)
|
||||
return entry
|
||||
|
||||
|
||||
def resolve_object_url(target_type: str, target_uid: str) -> str:
|
||||
if target_type == "post":
|
||||
post = resolve_by_slug(get_table("posts"), target_uid)
|
||||
return f"/posts/{post['slug'] or post['uid']}" if post else "/feed"
|
||||
if target_type == "project":
|
||||
project = resolve_by_slug(get_table("projects"), target_uid)
|
||||
return (
|
||||
f"/projects/{project['slug'] or project['uid']}" if project else "/projects"
|
||||
)
|
||||
if target_type == "news":
|
||||
article = resolve_by_slug(get_table("news"), target_uid)
|
||||
if article:
|
||||
return f"/news/{article.get('slug', '') or article['uid']}"
|
||||
return "/news"
|
||||
if target_type == "issue":
|
||||
return f"/issues?highlight={target_uid}"
|
||||
if target_type == "gist":
|
||||
gist = resolve_by_slug(get_table("gists"), target_uid)
|
||||
return f"/gists/{gist['slug'] or gist['uid']}" if gist else "/gists"
|
||||
if target_type == "comment":
|
||||
comment = get_table("comments").find_one(uid=target_uid, deleted_at=None)
|
||||
if not comment:
|
||||
return "/feed"
|
||||
parent_url = resolve_object_url(
|
||||
comment.get("target_type", "post"),
|
||||
comment.get("target_uid") or comment.get("post_uid", ""),
|
||||
)
|
||||
return f"{parent_url}#comment-{target_uid}"
|
||||
return "/feed"
|
||||
|
||||
|
||||
def get_uids_by_username_match(search, limit=200):
|
||||
term = (search or "").strip()
|
||||
if not term or "users" not in db.tables:
|
||||
return []
|
||||
rows = db.query(
|
||||
"SELECT uid FROM users WHERE username LIKE :q LIMIT :limit",
|
||||
q=f"%{term}%",
|
||||
limit=limit,
|
||||
)
|
||||
return [row["uid"] for row in rows]
|
||||
|
||||
|
||||
def text_search_clause(
|
||||
table, search, fields=("title", "description"), author_field=None
|
||||
):
|
||||
if not search or not search.strip() or not table.exists:
|
||||
return None
|
||||
columns = table.table.columns
|
||||
like = f"%{search.strip()}%"
|
||||
matches = [columns[field].ilike(like) for field in fields if field in columns]
|
||||
if author_field and author_field in columns:
|
||||
author_uids = get_uids_by_username_match(search)
|
||||
if author_uids:
|
||||
matches.append(columns[author_field].in_(author_uids))
|
||||
return or_(*matches) if matches else None
|
||||
|
||||
|
||||
def get_daily_topic():
|
||||
if "news" in db.tables:
|
||||
article = db["news"].find_one(
|
||||
status="published", deleted_at=None, order_by=["-synced_at"]
|
||||
)
|
||||
if article:
|
||||
desc = (article.get("description") or "")[:200] or (
|
||||
article.get("content") or ""
|
||||
)[:200]
|
||||
return {
|
||||
"title": article.get("title", ""),
|
||||
"summary": desc,
|
||||
"slug": article.get("slug", ""),
|
||||
"url": article.get("url", ""),
|
||||
"image_url": article.get("image_url", ""),
|
||||
}
|
||||
return {
|
||||
"title": "Welcome to DevPlace",
|
||||
"summary": "Stay tuned for the latest dev news.",
|
||||
}
|
||||
|
||||
|
||||
def get_featured_news(limit=5):
|
||||
if "news" not in db.tables:
|
||||
return []
|
||||
from devplacepy.utils import time_ago
|
||||
|
||||
rows = list(
|
||||
db["news"].find(
|
||||
show_on_landing=1, deleted_at=None, order_by=["-synced_at"], _limit=limit
|
||||
)
|
||||
)
|
||||
articles = []
|
||||
for article in rows:
|
||||
summary = (article.get("description") or "")[:120] or (
|
||||
article.get("content") or ""
|
||||
)[:120]
|
||||
articles.append(
|
||||
{
|
||||
"title": article.get("title", ""),
|
||||
"summary": summary,
|
||||
"slug": article.get("slug", ""),
|
||||
"url": article.get("url", ""),
|
||||
"source_name": article.get("source_name", ""),
|
||||
"featured": article.get("featured", 0),
|
||||
"image_url": article.get("image_url", "") or "",
|
||||
"time_ago": time_ago(article["synced_at"])
|
||||
if article.get("synced_at")
|
||||
else "",
|
||||
}
|
||||
)
|
||||
return articles
|
||||
166
devplacepy/database/core.py
Normal file
166
devplacepy/database/core.py
Normal file
@ -0,0 +1,166 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import dataset
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from sqlalchemy import or_
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy.config import (
|
||||
DATABASE_URL,
|
||||
DEFAULT_CORRECTION_PROMPT,
|
||||
DEFAULT_MODIFIER_PROMPT,
|
||||
INTERNAL_GATEWAY_URL,
|
||||
ensure_data_dirs,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ensure_data_dirs()
|
||||
if DATABASE_URL.startswith("sqlite:///"):
|
||||
_db_file = DATABASE_URL[len("sqlite:///") :]
|
||||
if _db_file and _db_file != ":memory:":
|
||||
Path(_db_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
db = dataset.connect(
|
||||
DATABASE_URL,
|
||||
engine_kwargs={
|
||||
"connect_args": {
|
||||
"timeout": 30,
|
||||
"check_same_thread": False,
|
||||
},
|
||||
},
|
||||
on_connect_statements=[
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=NORMAL",
|
||||
"PRAGMA busy_timeout=30000",
|
||||
"PRAGMA cache_size=-8000",
|
||||
"PRAGMA temp_store=MEMORY",
|
||||
"PRAGMA mmap_size=268435456",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def refresh_snapshot() -> None:
|
||||
connection = db.executable
|
||||
if connection.in_transaction() and not db.in_transaction:
|
||||
connection.commit()
|
||||
|
||||
|
||||
_local_cache_versions: dict = {}
|
||||
|
||||
|
||||
_cache_version_cache = TTLCache(ttl=1)
|
||||
|
||||
|
||||
_cache_state_ready = False
|
||||
|
||||
|
||||
def _ensure_cache_state() -> None:
|
||||
global _cache_state_ready
|
||||
if _cache_state_ready:
|
||||
return
|
||||
db.query(
|
||||
"CREATE TABLE IF NOT EXISTS cache_state "
|
||||
"(name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)"
|
||||
)
|
||||
_cache_state_ready = True
|
||||
|
||||
|
||||
def get_cache_version(name: str) -> int:
|
||||
cached = _cache_version_cache.get(name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
_ensure_cache_state()
|
||||
row = next(
|
||||
iter(
|
||||
db.query(
|
||||
"SELECT version FROM cache_state WHERE name = :name", name=name
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
version = int(row["version"]) if row else 0
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read cache version {name}: {e}")
|
||||
return 0
|
||||
_cache_version_cache.set(name, version)
|
||||
return version
|
||||
|
||||
|
||||
def bump_cache_version(name: str) -> None:
|
||||
try:
|
||||
_ensure_cache_state()
|
||||
db.query(
|
||||
"INSERT OR IGNORE INTO cache_state (name, version) VALUES (:name, 0)",
|
||||
name=name,
|
||||
)
|
||||
db.query(
|
||||
"UPDATE cache_state SET version = version + 1 WHERE name = :name", name=name
|
||||
)
|
||||
connection = db.executable
|
||||
if connection.in_transaction():
|
||||
connection.commit()
|
||||
_cache_version_cache.pop(name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not bump cache version {name}: {e}")
|
||||
|
||||
|
||||
def sync_local_cache(name: str, cache) -> None:
|
||||
current = get_cache_version(name)
|
||||
if name not in _local_cache_versions:
|
||||
_local_cache_versions[name] = current
|
||||
return
|
||||
if _local_cache_versions[name] != current:
|
||||
cache.clear()
|
||||
_local_cache_versions[name] = current
|
||||
|
||||
|
||||
def _index(db, table, name, columns, *, where=None, unique=False):
|
||||
try:
|
||||
if table in db.tables:
|
||||
cols = ", ".join(columns)
|
||||
kind = "UNIQUE INDEX" if unique else "INDEX"
|
||||
clause = f" WHERE {where}" if where else ""
|
||||
with db:
|
||||
db.query(
|
||||
f"CREATE {kind} IF NOT EXISTS {name} ON {table} ({cols}){clause}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create index {name} on {table}: {e}")
|
||||
|
||||
|
||||
def _drop_index(db, name):
|
||||
try:
|
||||
with db:
|
||||
db.query(f"DROP INDEX IF EXISTS {name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not drop index {name}: {e}")
|
||||
|
||||
|
||||
def _uid_index(db, table):
|
||||
if table not in db.tables or "uid" not in get_table(table).columns:
|
||||
return
|
||||
name = f"idx_{table}_uid"
|
||||
try:
|
||||
with db:
|
||||
db.query(f"CREATE UNIQUE INDEX IF NOT EXISTS {name} ON {table} (uid)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unique uid index on {table} failed ({e}); using non-unique")
|
||||
_index(db, table, name, ["uid"])
|
||||
|
||||
|
||||
def get_table(name):
|
||||
return db[name]
|
||||
|
||||
|
||||
def _in_clause(uids, prefix="p"):
|
||||
placeholders = ", ".join(f":{prefix}{i}" for i in range(len(uids)))
|
||||
params = {f"{prefix}{i}": uid for i, uid in enumerate(uids)}
|
||||
return placeholders, params
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
167
devplacepy/database/customization.py
Normal file
167
devplacepy/database/customization.py
Normal file
@ -0,0 +1,167 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import TTLCache, bump_cache_version, datetime, db, get_table, sync_local_cache, timezone
|
||||
from .soft_delete import soft_delete
|
||||
|
||||
|
||||
CUSTOMIZATION_GLOBAL_SCOPE = "global"
|
||||
|
||||
|
||||
CUSTOMIZATION_LANGS = ("css", "js")
|
||||
|
||||
|
||||
_customizations_cache = TTLCache(ttl=300, max_size=100)
|
||||
|
||||
|
||||
def _customization_key(owner_kind: str, owner_id: str, page_type: str) -> str:
|
||||
return f"{owner_kind}\x1f{owner_id}\x1f{page_type}"
|
||||
|
||||
|
||||
CUSTOMIZATION_PREF_COLUMNS = {
|
||||
"global": "cust_disable_global",
|
||||
"pagetype": "cust_disable_pagetype",
|
||||
}
|
||||
|
||||
|
||||
def get_customization_prefs(owner_kind: str, owner_id: str) -> dict:
|
||||
if owner_kind != "user" or "users" not in db.tables:
|
||||
return {"disable_global": False, "disable_pagetype": False}
|
||||
user = db["users"].find_one(uid=owner_id)
|
||||
if user is None:
|
||||
return {"disable_global": False, "disable_pagetype": False}
|
||||
return {
|
||||
"disable_global": bool(user.get("cust_disable_global", 0)),
|
||||
"disable_pagetype": bool(user.get("cust_disable_pagetype", 0)),
|
||||
}
|
||||
|
||||
|
||||
def set_customization_pref(owner_id: str, category: str, disabled: bool) -> None:
|
||||
column = CUSTOMIZATION_PREF_COLUMNS.get(category)
|
||||
if column is None:
|
||||
raise ValueError(f"Unknown customization category: {category}")
|
||||
from devplacepy.utils import clear_user_cache
|
||||
|
||||
get_table("users").update(
|
||||
{"uid": owner_id, column: 1 if disabled else 0}, ["uid"]
|
||||
)
|
||||
clear_user_cache(owner_id)
|
||||
bump_cache_version("customizations")
|
||||
|
||||
|
||||
def get_custom_overrides(owner_kind: str, owner_id: str, page_type: str) -> dict:
|
||||
sync_local_cache("customizations", _customizations_cache)
|
||||
key = _customization_key(owner_kind, owner_id, page_type)
|
||||
cached = _customizations_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = {"css": "", "js": ""}
|
||||
if "user_customizations" in db.tables:
|
||||
prefs = get_customization_prefs(owner_kind, owner_id)
|
||||
scopes = (CUSTOMIZATION_GLOBAL_SCOPE, page_type)
|
||||
rows = db["user_customizations"].find(
|
||||
owner_kind=owner_kind,
|
||||
owner_id=owner_id,
|
||||
enabled=1,
|
||||
deleted_at=None,
|
||||
)
|
||||
pieces: dict[str, dict[str, str]] = {lang: {} for lang in CUSTOMIZATION_LANGS}
|
||||
for row in rows:
|
||||
lang = row.get("lang")
|
||||
scope = row.get("scope")
|
||||
if lang not in pieces or scope not in scopes:
|
||||
continue
|
||||
if scope == CUSTOMIZATION_GLOBAL_SCOPE and prefs["disable_global"]:
|
||||
continue
|
||||
if scope != CUSTOMIZATION_GLOBAL_SCOPE and prefs["disable_pagetype"]:
|
||||
continue
|
||||
pieces[lang][scope] = row.get("code") or ""
|
||||
for lang in CUSTOMIZATION_LANGS:
|
||||
ordered = [pieces[lang][scope] for scope in scopes if scope in pieces[lang]]
|
||||
result[lang] = "\n".join(part for part in ordered if part.strip())
|
||||
_customizations_cache.set(key, result)
|
||||
return result
|
||||
|
||||
|
||||
def get_custom_override(
|
||||
owner_kind: str, owner_id: str, scope: str, lang: str
|
||||
) -> dict | None:
|
||||
if "user_customizations" not in db.tables:
|
||||
return None
|
||||
return db["user_customizations"].find_one(
|
||||
owner_kind=owner_kind,
|
||||
owner_id=owner_id,
|
||||
scope=scope,
|
||||
lang=lang,
|
||||
deleted_at=None,
|
||||
)
|
||||
|
||||
|
||||
def list_custom_overrides(owner_kind: str, owner_id: str) -> list:
|
||||
if "user_customizations" not in db.tables:
|
||||
return []
|
||||
return list(
|
||||
db["user_customizations"].find(
|
||||
owner_kind=owner_kind, owner_id=owner_id, deleted_at=None
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def set_custom_override(
|
||||
owner_kind: str, owner_id: str, scope: str, lang: str, code: str
|
||||
) -> dict:
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
table = get_table("user_customizations")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
existing = table.find_one(
|
||||
owner_kind=owner_kind, owner_id=owner_id, scope=scope, lang=lang
|
||||
)
|
||||
if existing:
|
||||
record = {
|
||||
"id": existing["id"],
|
||||
"code": code,
|
||||
"enabled": 1,
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
table.update(record, ["id"])
|
||||
result = {**existing, **record}
|
||||
else:
|
||||
result = {
|
||||
"uid": generate_uid(),
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
"scope": scope,
|
||||
"lang": lang,
|
||||
"code": code,
|
||||
"enabled": 1,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
table.insert(result)
|
||||
bump_cache_version("customizations")
|
||||
return result
|
||||
|
||||
|
||||
def delete_custom_override(
|
||||
owner_kind: str,
|
||||
owner_id: str,
|
||||
scope: str | None = None,
|
||||
lang: str | None = None,
|
||||
deleted_by: str | None = None,
|
||||
) -> int:
|
||||
if "user_customizations" not in db.tables:
|
||||
return 0
|
||||
criteria: dict = {"owner_kind": owner_kind, "owner_id": owner_id}
|
||||
if scope is not None:
|
||||
criteria["scope"] = scope
|
||||
if lang is not None:
|
||||
criteria["lang"] = lang
|
||||
count = soft_delete(
|
||||
"user_customizations", deleted_by or f"{owner_kind}:{owner_id}", **criteria
|
||||
)
|
||||
bump_cache_version("customizations")
|
||||
return int(count)
|
||||
119
devplacepy/database/deepsearch.py
Normal file
119
devplacepy/database/deepsearch.py
Normal file
@ -0,0 +1,119 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import datetime, db, get_table, timezone
|
||||
|
||||
|
||||
def _ds_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def create_deepsearch_session(
|
||||
uid: str,
|
||||
owner_kind: str,
|
||||
owner_id: str,
|
||||
query: str,
|
||||
depth: int,
|
||||
max_pages: int,
|
||||
collection: str,
|
||||
) -> None:
|
||||
get_table("deepsearch_sessions").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
"query": query,
|
||||
"status": "pending",
|
||||
"depth": depth,
|
||||
"max_pages": max_pages,
|
||||
"score": 0,
|
||||
"confidence": 0.0,
|
||||
"source_diversity": 0.0,
|
||||
"page_count": 0,
|
||||
"chunk_count": 0,
|
||||
"collection": collection,
|
||||
"summary": "",
|
||||
"created_at": _ds_now(),
|
||||
"completed_at": "",
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def update_deepsearch_session(uid: str, fields: dict) -> None:
|
||||
if "deepsearch_sessions" not in db.tables:
|
||||
return
|
||||
payload = dict(fields)
|
||||
payload["uid"] = uid
|
||||
get_table("deepsearch_sessions").update(payload, ["uid"])
|
||||
|
||||
|
||||
def get_deepsearch_session(uid: str) -> dict | None:
|
||||
if "deepsearch_sessions" not in db.tables:
|
||||
return None
|
||||
return get_table("deepsearch_sessions").find_one(uid=uid, deleted_at=None)
|
||||
|
||||
|
||||
def add_deepsearch_message(
|
||||
uid: str, session_uid: str, role: str, content: str, citations: str = ""
|
||||
) -> None:
|
||||
get_table("deepsearch_messages").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"session_uid": session_uid,
|
||||
"role": role,
|
||||
"content": content,
|
||||
"citations": citations,
|
||||
"created_at": _ds_now(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_deepsearch_messages(session_uid: str, limit: int = 50) -> list[dict]:
|
||||
if "deepsearch_messages" not in db.tables:
|
||||
return []
|
||||
return list(
|
||||
get_table("deepsearch_messages").find(
|
||||
session_uid=session_uid,
|
||||
deleted_at=None,
|
||||
order_by=["created_at"],
|
||||
_limit=limit,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_cached_deepsearch_url(url_hash: str) -> dict | None:
|
||||
if "deepsearch_url_cache" not in db.tables:
|
||||
return None
|
||||
return get_table("deepsearch_url_cache").find_one(url_hash=url_hash)
|
||||
|
||||
|
||||
def upsert_deepsearch_url_cache(
|
||||
url_hash: str,
|
||||
url: str,
|
||||
title: str,
|
||||
content_hash: str,
|
||||
status: int,
|
||||
byte_size: int,
|
||||
) -> None:
|
||||
table = get_table("deepsearch_url_cache")
|
||||
existing = table.find_one(url_hash=url_hash)
|
||||
row = {
|
||||
"url_hash": url_hash,
|
||||
"url": url,
|
||||
"title": title,
|
||||
"content_hash": content_hash,
|
||||
"status": status,
|
||||
"byte_size": byte_size,
|
||||
"fetched_at": _ds_now(),
|
||||
}
|
||||
if existing:
|
||||
row["uid"] = existing["uid"]
|
||||
table.update(row, ["uid"])
|
||||
else:
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
row["uid"] = generate_uid()
|
||||
table.insert(row)
|
||||
95
devplacepy/database/email.py
Normal file
95
devplacepy/database/email.py
Normal file
@ -0,0 +1,95 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import datetime, db, get_table, timezone
|
||||
from .soft_delete import soft_delete
|
||||
|
||||
|
||||
EMAIL_ACCOUNT_DEFAULTS: dict[str, object] = {
|
||||
"imap_host": "",
|
||||
"imap_port": 993,
|
||||
"imap_ssl": 1,
|
||||
"imap_starttls": 0,
|
||||
"smtp_host": "",
|
||||
"smtp_port": 587,
|
||||
"smtp_ssl": 0,
|
||||
"smtp_starttls": 1,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"from_address": "",
|
||||
"from_name": "",
|
||||
}
|
||||
|
||||
|
||||
def list_email_accounts(owner_kind: str, owner_id: str) -> list:
|
||||
if "email_accounts" not in db.tables:
|
||||
return []
|
||||
return list(
|
||||
db["email_accounts"].find(
|
||||
owner_kind=owner_kind, owner_id=owner_id, deleted_at=None
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_email_account(owner_kind: str, owner_id: str, label: str) -> dict | None:
|
||||
if "email_accounts" not in db.tables:
|
||||
return None
|
||||
return db["email_accounts"].find_one(
|
||||
owner_kind=owner_kind, owner_id=owner_id, label=label, deleted_at=None
|
||||
)
|
||||
|
||||
|
||||
def set_email_account(
|
||||
owner_kind: str, owner_id: str, label: str, fields: dict
|
||||
) -> dict:
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
table = get_table("email_accounts")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
existing = table.find_one(owner_kind=owner_kind, owner_id=owner_id, label=label)
|
||||
values = {**EMAIL_ACCOUNT_DEFAULTS, **(existing or {}), **fields}
|
||||
if not values.get("from_address"):
|
||||
values["from_address"] = values.get("username") or ""
|
||||
record = {
|
||||
key: values.get(key, default)
|
||||
for key, default in EMAIL_ACCOUNT_DEFAULTS.items()
|
||||
}
|
||||
if existing:
|
||||
record.update(
|
||||
{
|
||||
"id": existing["id"],
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
table.update(record, ["id"])
|
||||
result = {**existing, **record}
|
||||
else:
|
||||
result = {
|
||||
"uid": generate_uid(),
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
"label": label,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
**record,
|
||||
}
|
||||
table.insert(result)
|
||||
return result
|
||||
|
||||
|
||||
def delete_email_account(
|
||||
owner_kind: str, owner_id: str, label: str, deleted_by: str | None = None
|
||||
) -> int:
|
||||
if "email_accounts" not in db.tables:
|
||||
return 0
|
||||
count = soft_delete(
|
||||
"email_accounts",
|
||||
deleted_by or f"{owner_kind}:{owner_id}",
|
||||
owner_kind=owner_kind,
|
||||
owner_id=owner_id,
|
||||
label=label,
|
||||
)
|
||||
return int(count)
|
||||
187
devplacepy/database/engagement.py
Normal file
187
devplacepy/database/engagement.py
Normal file
@ -0,0 +1,187 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import TTLCache, _in_clause, db, defaultdict
|
||||
|
||||
|
||||
_comment_count_cache = TTLCache(ttl=15, max_size=10000)
|
||||
|
||||
|
||||
def get_comment_counts_by_post_uids(post_uids):
|
||||
if not post_uids or "comments" not in db.tables:
|
||||
return {}
|
||||
result = {}
|
||||
misses = []
|
||||
for uid in post_uids:
|
||||
cached = _comment_count_cache.get(uid)
|
||||
if cached is None:
|
||||
misses.append(uid)
|
||||
else:
|
||||
result[uid] = cached
|
||||
if misses:
|
||||
placeholders, params = _in_clause(misses)
|
||||
rows = db.query(
|
||||
f"SELECT target_uid, COUNT(*) as c FROM comments WHERE target_type='post' AND target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid",
|
||||
**params,
|
||||
)
|
||||
fetched = {r["target_uid"]: r["c"] for r in rows}
|
||||
for uid in misses:
|
||||
count = fetched.get(uid, 0)
|
||||
_comment_count_cache.set(uid, count)
|
||||
result[uid] = count
|
||||
return result
|
||||
|
||||
|
||||
def get_post_counts_by_user_uids(user_uids):
|
||||
if not user_uids or "posts" not in db.tables:
|
||||
return {}
|
||||
placeholders, params = _in_clause(user_uids)
|
||||
rows = db.query(
|
||||
f"SELECT user_uid, COUNT(*) as c FROM posts WHERE user_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY user_uid",
|
||||
**params,
|
||||
)
|
||||
return {r["user_uid"]: r["c"] for r in rows}
|
||||
|
||||
|
||||
def get_vote_counts(target_uids):
|
||||
if not target_uids or "votes" not in db.tables:
|
||||
return {}, {}
|
||||
placeholders, params = _in_clause(target_uids)
|
||||
rows = db.query(
|
||||
f"SELECT target_uid, value, COUNT(*) as c FROM votes WHERE target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid, value",
|
||||
**params,
|
||||
)
|
||||
ups = {}
|
||||
downs = {}
|
||||
for r in rows:
|
||||
if r["value"] == 1:
|
||||
ups[r["target_uid"]] = r["c"]
|
||||
else:
|
||||
downs[r["target_uid"]] = r["c"]
|
||||
return ups, downs
|
||||
|
||||
|
||||
def get_user_votes(user_uid, target_uids):
|
||||
if not user_uid or not target_uids or "votes" not in db.tables:
|
||||
return {}
|
||||
placeholders, params = _in_clause(target_uids)
|
||||
params["uid"] = user_uid
|
||||
rows = db.query(
|
||||
f"SELECT target_uid, value FROM votes WHERE user_uid = :uid AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
|
||||
**params,
|
||||
)
|
||||
return {r["target_uid"]: r["value"] for r in rows}
|
||||
|
||||
|
||||
def get_reactions_by_targets(target_type, target_uids, user=None):
|
||||
if not target_uids or "reactions" not in db.tables:
|
||||
return {}
|
||||
placeholders, params = _in_clause(target_uids)
|
||||
params["tt"] = target_type
|
||||
rows = db.query(
|
||||
f"SELECT target_uid, emoji, COUNT(*) as c FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid, emoji",
|
||||
**params,
|
||||
)
|
||||
counts = defaultdict(dict)
|
||||
for row in rows:
|
||||
counts[row["target_uid"]][row["emoji"]] = row["c"]
|
||||
mine = defaultdict(list)
|
||||
if user:
|
||||
placeholders, params = _in_clause(target_uids, prefix="m")
|
||||
params["tt"] = target_type
|
||||
params["u"] = user["uid"]
|
||||
for row in db.query(
|
||||
f"SELECT target_uid, emoji FROM reactions WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
|
||||
**params,
|
||||
):
|
||||
mine[row["target_uid"]].append(row["emoji"])
|
||||
result = {}
|
||||
for uid in target_uids:
|
||||
result[uid] = {
|
||||
"counts": dict(counts.get(uid, {})),
|
||||
"mine": list(mine.get(uid, [])),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def get_user_bookmarks(user_uid, target_type, target_uids):
|
||||
if not user_uid or not target_uids or "bookmarks" not in db.tables:
|
||||
return set()
|
||||
placeholders, params = _in_clause(target_uids)
|
||||
params["u"] = user_uid
|
||||
params["tt"] = target_type
|
||||
rows = db.query(
|
||||
f"SELECT target_uid FROM bookmarks WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
|
||||
**params,
|
||||
)
|
||||
return {row["target_uid"] for row in rows}
|
||||
|
||||
|
||||
def get_polls_by_post_uids(post_uids, user=None):
|
||||
if not post_uids or "polls" not in db.tables:
|
||||
return {}
|
||||
placeholders, params = _in_clause(post_uids)
|
||||
polls = list(
|
||||
db.query(
|
||||
f"SELECT * FROM polls WHERE post_uid IN ({placeholders}) AND deleted_at IS NULL",
|
||||
**params,
|
||||
)
|
||||
)
|
||||
if not polls:
|
||||
return {}
|
||||
poll_uids = [poll["uid"] for poll in polls]
|
||||
option_placeholders, option_params = _in_clause(poll_uids, prefix="o")
|
||||
options = list(
|
||||
db.query(
|
||||
f"SELECT * FROM poll_options WHERE poll_uid IN ({option_placeholders}) AND deleted_at IS NULL ORDER BY position",
|
||||
**option_params,
|
||||
)
|
||||
)
|
||||
counts = defaultdict(dict)
|
||||
totals = defaultdict(int)
|
||||
if "poll_votes" in db.tables:
|
||||
vote_placeholders, vote_params = _in_clause(poll_uids, prefix="v")
|
||||
for row in db.query(
|
||||
f"SELECT poll_uid, option_uid, COUNT(*) as c FROM poll_votes WHERE poll_uid IN ({vote_placeholders}) AND deleted_at IS NULL GROUP BY poll_uid, option_uid",
|
||||
**vote_params,
|
||||
):
|
||||
counts[row["poll_uid"]][row["option_uid"]] = row["c"]
|
||||
totals[row["poll_uid"]] += row["c"]
|
||||
user_choice = {}
|
||||
if user and "poll_votes" in db.tables:
|
||||
placeholders, params = _in_clause(poll_uids, prefix="m")
|
||||
params["u"] = user["uid"]
|
||||
for row in db.query(
|
||||
f"SELECT poll_uid, option_uid FROM poll_votes WHERE user_uid=:u AND poll_uid IN ({placeholders}) AND deleted_at IS NULL",
|
||||
**params,
|
||||
):
|
||||
user_choice[row["poll_uid"]] = row["option_uid"]
|
||||
options_by_poll = defaultdict(list)
|
||||
for option in options:
|
||||
options_by_poll[option["poll_uid"]].append(option)
|
||||
result = {}
|
||||
for poll in polls:
|
||||
poll_uid = poll["uid"]
|
||||
total = totals.get(poll_uid, 0)
|
||||
rendered = []
|
||||
for option in options_by_poll.get(poll_uid, []):
|
||||
count = counts.get(poll_uid, {}).get(option["uid"], 0)
|
||||
rendered.append(
|
||||
{
|
||||
"uid": option["uid"],
|
||||
"label": option["label"],
|
||||
"count": count,
|
||||
"pct": round(count * 100 / total) if total else 0,
|
||||
}
|
||||
)
|
||||
result[poll["post_uid"]] = {
|
||||
"uid": poll_uid,
|
||||
"question": poll["question"],
|
||||
"options": rendered,
|
||||
"total": total,
|
||||
"my_choice": user_choice.get(poll_uid),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def get_poll_for_post(post_uid, user=None):
|
||||
return get_polls_by_post_uids([post_uid], user).get(post_uid)
|
||||
63
devplacepy/database/follows.py
Normal file
63
devplacepy/database/follows.py
Normal file
@ -0,0 +1,63 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import _in_clause, db, get_table
|
||||
from .users import get_users_by_uids
|
||||
from .pagination import build_pagination
|
||||
|
||||
|
||||
def get_follow_counts(user_uid: str) -> dict:
|
||||
if "follows" not in db.tables:
|
||||
return {"followers": 0, "following": 0}
|
||||
follows = get_table("follows")
|
||||
return {
|
||||
"followers": follows.count(following_uid=user_uid, deleted_at=None),
|
||||
"following": follows.count(follower_uid=user_uid, deleted_at=None),
|
||||
}
|
||||
|
||||
|
||||
def get_follow_list(
|
||||
user_uid: str, mode: str, page: int = 1, per_page: int = 25
|
||||
) -> tuple:
|
||||
if "follows" not in db.tables:
|
||||
return [], build_pagination(page, 0, per_page)
|
||||
follows = get_table("follows")
|
||||
key = "following_uid" if mode == "followers" else "follower_uid"
|
||||
other = "follower_uid" if mode == "followers" else "following_uid"
|
||||
total = follows.count(deleted_at=None, **{key: user_uid})
|
||||
pagination = build_pagination(page, total, per_page)
|
||||
offset = (pagination["page"] - 1) * pagination["per_page"]
|
||||
rows = list(
|
||||
follows.find(
|
||||
order_by=["-created_at"],
|
||||
_limit=pagination["per_page"],
|
||||
_offset=offset,
|
||||
deleted_at=None,
|
||||
**{key: user_uid},
|
||||
)
|
||||
)
|
||||
users_map = get_users_by_uids([row[other] for row in rows])
|
||||
people = []
|
||||
for row in rows:
|
||||
person = users_map.get(row[other])
|
||||
if person:
|
||||
people.append(
|
||||
{
|
||||
"uid": person["uid"],
|
||||
"username": person["username"],
|
||||
"bio": (person.get("bio") or "")[:140],
|
||||
"followed_at": row.get("created_at"),
|
||||
}
|
||||
)
|
||||
return people, pagination
|
||||
|
||||
|
||||
def get_following_among(follower_uid: str, target_uids: list) -> set:
|
||||
if not follower_uid or not target_uids or "follows" not in db.tables:
|
||||
return set()
|
||||
placeholders, params = _in_clause(target_uids)
|
||||
params["f"] = follower_uid
|
||||
rows = db.query(
|
||||
f"SELECT following_uid FROM follows WHERE follower_uid = :f AND following_uid IN ({placeholders}) AND deleted_at IS NULL",
|
||||
**params,
|
||||
)
|
||||
return {row["following_uid"] for row in rows}
|
||||
59
devplacepy/database/forks.py
Normal file
59
devplacepy/database/forks.py
Normal file
@ -0,0 +1,59 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import _now_iso, datetime, db, get_table, timezone
|
||||
from .soft_delete import soft_delete
|
||||
|
||||
|
||||
def record_fork(
|
||||
source_project_uid: str, forked_project_uid: str, forked_by_uid: str
|
||||
) -> None:
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
get_table("project_forks").insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"source_project_uid": source_project_uid,
|
||||
"forked_project_uid": forked_project_uid,
|
||||
"forked_by_uid": forked_by_uid,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_fork_parent(forked_project_uid: str) -> dict | None:
|
||||
if "project_forks" not in db.tables:
|
||||
return None
|
||||
relation = get_table("project_forks").find_one(
|
||||
forked_project_uid=forked_project_uid, deleted_at=None
|
||||
)
|
||||
if not relation:
|
||||
return None
|
||||
return get_table("projects").find_one(
|
||||
uid=relation["source_project_uid"], deleted_at=None
|
||||
)
|
||||
|
||||
|
||||
def count_forks(source_project_uid: str) -> int:
|
||||
if "project_forks" not in db.tables:
|
||||
return 0
|
||||
return get_table("project_forks").count(
|
||||
source_project_uid=source_project_uid, deleted_at=None
|
||||
)
|
||||
|
||||
|
||||
def soft_delete_fork_relations(project_uid: str, deleted_by: str) -> None:
|
||||
if "project_forks" not in db.tables:
|
||||
return
|
||||
stamp = _now_iso()
|
||||
soft_delete("project_forks", deleted_by, stamp=stamp, forked_project_uid=project_uid)
|
||||
soft_delete("project_forks", deleted_by, stamp=stamp, source_project_uid=project_uid)
|
||||
|
||||
|
||||
def delete_fork_relations(project_uid: str) -> None:
|
||||
if "project_forks" not in db.tables:
|
||||
return
|
||||
forks = get_table("project_forks")
|
||||
forks.delete(forked_project_uid=project_uid)
|
||||
forks.delete(source_project_uid=project_uid)
|
||||
198
devplacepy/database/notifications.py
Normal file
198
devplacepy/database/notifications.py
Normal file
@ -0,0 +1,198 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import TTLCache, bump_cache_version, datetime, db, get_table, sync_local_cache, timezone
|
||||
from .settings import get_int_setting, set_setting
|
||||
from .soft_delete import soft_delete
|
||||
|
||||
|
||||
NOTIFICATION_TYPES = [
|
||||
{"key": "comment", "label": "Comments", "description": "Someone comments on your post"},
|
||||
{"key": "reply", "label": "Replies", "description": "Someone replies to your comment"},
|
||||
{"key": "mention", "label": "Mentions", "description": "Someone mentions you with @username"},
|
||||
{"key": "vote", "label": "Upvotes", "description": "Someone ++'d your content"},
|
||||
{"key": "follow", "label": "Followers", "description": "Someone starts following you"},
|
||||
{"key": "message", "label": "Direct messages", "description": "Someone sends you a message"},
|
||||
{"key": "badge", "label": "Badges", "description": "You earn a badge"},
|
||||
{"key": "level", "label": "Level-ups", "description": "You reach a new level"},
|
||||
{"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"},
|
||||
{"key": "reminder", "label": "Reminders", "description": "A reminder or scheduled task you asked Devii to run fires"},
|
||||
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
|
||||
]
|
||||
|
||||
|
||||
NOTIFICATION_CHANNELS = ("in_app", "push", "telegram")
|
||||
|
||||
|
||||
_NOTIFICATION_CHANNEL_COLUMNS = {
|
||||
"in_app": "in_app_enabled",
|
||||
"push": "push_enabled",
|
||||
"telegram": "telegram_enabled",
|
||||
}
|
||||
|
||||
|
||||
_NOTIFICATION_CHANNEL_DEFAULTS = {"in_app": 1, "push": 1, "telegram": 0}
|
||||
|
||||
|
||||
_NOTIFICATION_TYPE_KEYS = {entry["key"] for entry in NOTIFICATION_TYPES}
|
||||
|
||||
|
||||
_notification_prefs_cache = TTLCache(ttl=300, max_size=500)
|
||||
|
||||
|
||||
def _notification_default(notification_type: str, channel: str) -> bool:
|
||||
fallback = _NOTIFICATION_CHANNEL_DEFAULTS.get(channel, 1)
|
||||
return get_int_setting(f"notif_default_{notification_type}_{channel}", fallback) != 0
|
||||
|
||||
|
||||
def get_notification_default(notification_type: str, channel: str) -> bool:
|
||||
return _notification_default(notification_type, channel)
|
||||
|
||||
|
||||
def set_notification_default(
|
||||
notification_type: str, channel: str, enabled: bool
|
||||
) -> None:
|
||||
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
|
||||
raise ValueError(f"Unknown notification channel: {channel}")
|
||||
set_setting(f"notif_default_{notification_type}_{channel}", "1" if enabled else "0")
|
||||
|
||||
|
||||
def _notification_overrides(user_uid: str) -> dict:
|
||||
sync_local_cache("notif_prefs", _notification_prefs_cache)
|
||||
cached = _notification_prefs_cache.get(user_uid)
|
||||
if cached is not None:
|
||||
return cached
|
||||
overrides: dict = {}
|
||||
if "notification_preferences" in db.tables:
|
||||
for row in db["notification_preferences"].find(
|
||||
user_uid=user_uid, deleted_at=None
|
||||
):
|
||||
overrides[row["notification_type"]] = {
|
||||
channel: bool(
|
||||
row.get(column, _NOTIFICATION_CHANNEL_DEFAULTS.get(channel, 1))
|
||||
)
|
||||
for channel, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
|
||||
}
|
||||
_notification_prefs_cache.set(user_uid, overrides)
|
||||
return overrides
|
||||
|
||||
|
||||
def notification_enabled(user_uid: str, notification_type: str, channel: str) -> bool:
|
||||
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
|
||||
return True
|
||||
override = _notification_overrides(user_uid).get(notification_type)
|
||||
if override is not None:
|
||||
return bool(override[channel])
|
||||
return _notification_default(notification_type, channel)
|
||||
|
||||
|
||||
def get_notification_prefs(user_uid: str) -> list:
|
||||
overrides = _notification_overrides(user_uid)
|
||||
result = []
|
||||
for entry in NOTIFICATION_TYPES:
|
||||
key = entry["key"]
|
||||
override = overrides.get(key)
|
||||
channels = {
|
||||
channel: bool(override[channel])
|
||||
if override
|
||||
else _notification_default(key, channel)
|
||||
for channel in _NOTIFICATION_CHANNEL_COLUMNS
|
||||
}
|
||||
result.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": entry["label"],
|
||||
"description": entry["description"],
|
||||
**channels,
|
||||
"customized": override is not None,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def set_notification_pref(
|
||||
user_uid: str, notification_type: str, channel: str, enabled: bool
|
||||
) -> dict:
|
||||
if notification_type not in _NOTIFICATION_TYPE_KEYS:
|
||||
raise ValueError(f"Unknown notification type: {notification_type}")
|
||||
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
|
||||
raise ValueError(f"Unknown notification channel: {channel}")
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
table = get_table("notification_preferences")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
existing = table.find_one(user_uid=user_uid, notification_type=notification_type)
|
||||
if existing:
|
||||
values = {
|
||||
name: bool(
|
||||
existing.get(column, _NOTIFICATION_CHANNEL_DEFAULTS.get(name, 1))
|
||||
)
|
||||
for name, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
|
||||
}
|
||||
else:
|
||||
values = {
|
||||
name: _notification_default(notification_type, name)
|
||||
for name in _NOTIFICATION_CHANNEL_COLUMNS
|
||||
}
|
||||
values[channel] = enabled
|
||||
columns = {
|
||||
column: 1 if values[name] else 0
|
||||
for name, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
|
||||
}
|
||||
if existing:
|
||||
record = {
|
||||
"id": existing["id"],
|
||||
**columns,
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
table.update(record, ["id"])
|
||||
result = {**existing, **record}
|
||||
else:
|
||||
result = {
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"notification_type": notification_type,
|
||||
**columns,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
table.insert(result)
|
||||
bump_cache_version("notif_prefs")
|
||||
return result
|
||||
|
||||
|
||||
def reset_notification_prefs(user_uid: str, deleted_by: str | None = None) -> int:
|
||||
if "notification_preferences" not in db.tables:
|
||||
return 0
|
||||
count = soft_delete(
|
||||
"notification_preferences", deleted_by or f"user:{user_uid}", user_uid=user_uid
|
||||
)
|
||||
bump_cache_version("notif_prefs")
|
||||
return int(count)
|
||||
|
||||
|
||||
def mark_notifications_read_by_target(user_uid: str, target_url: str) -> int:
|
||||
if not user_uid or not target_url or "notifications" not in db.tables:
|
||||
return 0
|
||||
notifications_table = get_table("notifications")
|
||||
ids = [
|
||||
n["id"]
|
||||
for n in notifications_table.find(user_uid=user_uid, read=False)
|
||||
if n.get("target_url")
|
||||
and (
|
||||
n["target_url"] == target_url
|
||||
or n["target_url"].startswith(f"{target_url}#")
|
||||
)
|
||||
]
|
||||
if not ids:
|
||||
return 0
|
||||
with db:
|
||||
for notification_id in ids:
|
||||
notifications_table.update({"id": notification_id, "read": True}, ["id"])
|
||||
from devplacepy.templating import clear_unread_cache
|
||||
|
||||
clear_unread_cache(user_uid)
|
||||
return len(ids)
|
||||
95
devplacepy/database/pagination.py
Normal file
95
devplacepy/database/pagination.py
Normal file
@ -0,0 +1,95 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import db, get_table
|
||||
from .relations import get_blocked_uids
|
||||
|
||||
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
def paginate(
|
||||
table,
|
||||
*clauses,
|
||||
before=None,
|
||||
order=None,
|
||||
cursor_field="created_at",
|
||||
viewer_uid=None,
|
||||
**filters,
|
||||
):
|
||||
order = order or ["-" + cursor_field]
|
||||
clauses = list(clauses)
|
||||
if table.has_column("deleted_at") and "deleted_at" not in filters:
|
||||
clauses.append(table.table.columns.deleted_at.is_(None))
|
||||
if viewer_uid and table.has_column("user_uid"):
|
||||
blocked = get_blocked_uids(viewer_uid)
|
||||
if blocked:
|
||||
clauses.append(table.table.columns.user_uid.notin_(blocked))
|
||||
if before:
|
||||
clauses.append(table.table.columns[cursor_field] < before)
|
||||
rows = list(table.find(*clauses, **filters, order_by=order, _limit=PAGE_SIZE + 1))
|
||||
has_more = len(rows) > PAGE_SIZE
|
||||
rows = rows[:PAGE_SIZE]
|
||||
next_cursor = rows[-1][cursor_field] if has_more and rows else None
|
||||
return rows, next_cursor
|
||||
|
||||
|
||||
def interleave_by_author(rows, uid_key="user_uid"):
|
||||
remaining = list(rows)
|
||||
spread = []
|
||||
last_owner = object()
|
||||
while remaining:
|
||||
pick = next(
|
||||
(
|
||||
index
|
||||
for index, row in enumerate(remaining)
|
||||
if row.get(uid_key) != last_owner
|
||||
),
|
||||
0,
|
||||
)
|
||||
row = remaining.pop(pick)
|
||||
spread.append(row)
|
||||
last_owner = row.get(uid_key)
|
||||
return spread
|
||||
|
||||
|
||||
def paginate_diverse(
|
||||
table,
|
||||
*clauses,
|
||||
before=None,
|
||||
order=None,
|
||||
cursor_field="created_at",
|
||||
uid_key="user_uid",
|
||||
viewer_uid=None,
|
||||
**filters,
|
||||
):
|
||||
rows, next_cursor = paginate(
|
||||
table,
|
||||
*clauses,
|
||||
before=before,
|
||||
order=order,
|
||||
cursor_field=cursor_field,
|
||||
viewer_uid=viewer_uid,
|
||||
**filters,
|
||||
)
|
||||
return interleave_by_author(rows, uid_key=uid_key), next_cursor
|
||||
|
||||
|
||||
def get_user_post_count(user_uid: str) -> int:
|
||||
if "posts" not in db.tables:
|
||||
return 0
|
||||
return get_table("posts").count(user_uid=user_uid, deleted_at=None)
|
||||
|
||||
|
||||
def build_pagination(page, total, per_page=25):
|
||||
total_pages = max(1, __import__("math").ceil(total / per_page))
|
||||
page = max(1, min(page, total_pages))
|
||||
return {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"total_pages": total_pages,
|
||||
"has_prev": page > 1,
|
||||
"has_next": page < total_pages,
|
||||
"prev_page": page - 1,
|
||||
"next_page": page + 1,
|
||||
}
|
||||
168
devplacepy/database/ranking.py
Normal file
168
devplacepy/database/ranking.py
Normal file
@ -0,0 +1,168 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import TTLCache, _in_clause, _now_iso, db, get_table
|
||||
from .users import get_users_by_uids
|
||||
from .soft_delete import soft_delete, soft_delete_in
|
||||
|
||||
|
||||
VOTABLE_TARGETS: dict[str, str] = {
|
||||
"post": "posts",
|
||||
"project": "projects",
|
||||
"gist": "gists",
|
||||
"comment": "comments",
|
||||
}
|
||||
|
||||
|
||||
STAR_TARGETS: set[str] = {"post", "project", "gist"}
|
||||
|
||||
|
||||
_authors_cache = TTLCache(ttl=300, max_size=200)
|
||||
|
||||
|
||||
def _ranked_authors() -> list:
|
||||
cached = _authors_cache.get("ranked")
|
||||
if cached is not None:
|
||||
return cached
|
||||
sources = [
|
||||
(target_type, table_name)
|
||||
for target_type, table_name in VOTABLE_TARGETS.items()
|
||||
if table_name in db.tables
|
||||
]
|
||||
if "votes" not in db.tables or not sources:
|
||||
_authors_cache.set("ranked", [])
|
||||
return []
|
||||
target_union = " UNION ALL ".join(
|
||||
f"SELECT uid, user_uid, '{target_type}' AS target_type FROM {table_name} WHERE deleted_at IS NULL"
|
||||
for target_type, table_name in sources
|
||||
)
|
||||
rows = db.query(
|
||||
f"SELECT t.user_uid, SUM(v.value) AS total "
|
||||
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
|
||||
f"WHERE v.deleted_at IS NULL "
|
||||
f"GROUP BY t.user_uid HAVING SUM(v.value) > 0 ORDER BY total DESC"
|
||||
)
|
||||
ranked = [(row["user_uid"], row["total"]) for row in rows]
|
||||
users_map = get_users_by_uids([uid for uid, _ in ranked])
|
||||
authors = []
|
||||
for uid, total in ranked:
|
||||
user = users_map.get(uid)
|
||||
if user:
|
||||
author = dict(user)
|
||||
author["stars"] = total
|
||||
authors.append(author)
|
||||
_authors_cache.set("ranked", authors)
|
||||
_authors_cache.set(
|
||||
"rank_map",
|
||||
{author["uid"]: position for position, author in enumerate(authors, start=1)},
|
||||
)
|
||||
return authors
|
||||
|
||||
|
||||
def _rank_map() -> dict:
|
||||
cached = _authors_cache.get("rank_map")
|
||||
if cached is not None:
|
||||
return cached
|
||||
_ranked_authors()
|
||||
return _authors_cache.get("rank_map") or {}
|
||||
|
||||
|
||||
def get_top_authors(limit: int = 5) -> list:
|
||||
return _ranked_authors()[:limit]
|
||||
|
||||
|
||||
def get_leaderboard(limit: int = 50, offset: int = 0) -> list:
|
||||
sliced = _ranked_authors()[offset : offset + limit]
|
||||
leaderboard = []
|
||||
for position, author in enumerate(sliced, start=offset + 1):
|
||||
entry = dict(author)
|
||||
entry["rank"] = position
|
||||
leaderboard.append(entry)
|
||||
return leaderboard
|
||||
|
||||
|
||||
def get_user_rank(user_uid: str):
|
||||
return _rank_map().get(user_uid)
|
||||
|
||||
|
||||
def get_user_stars(user_uid: str) -> int:
|
||||
if "votes" not in db.tables:
|
||||
return 0
|
||||
target_union = " UNION ALL ".join(
|
||||
f"SELECT uid, '{target_type}' AS target_type FROM {table_name} WHERE user_uid = :u AND deleted_at IS NULL"
|
||||
for target_type, table_name in VOTABLE_TARGETS.items()
|
||||
if table_name in db.tables
|
||||
)
|
||||
if not target_union:
|
||||
return 0
|
||||
for row in db.query(
|
||||
f"SELECT COALESCE(SUM(v.value), 0) AS s "
|
||||
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
|
||||
f"WHERE v.deleted_at IS NULL",
|
||||
u=user_uid,
|
||||
):
|
||||
return row["s"] or 0
|
||||
return 0
|
||||
|
||||
|
||||
def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None:
|
||||
table_name = VOTABLE_TARGETS.get(target_type)
|
||||
if not table_name:
|
||||
return
|
||||
if target_type in STAR_TARGETS:
|
||||
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
|
||||
_authors_cache.clear()
|
||||
|
||||
|
||||
def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None:
|
||||
uids = [uid for uid in (target_uids or []) if uid]
|
||||
if not uids:
|
||||
return
|
||||
stamp = _now_iso()
|
||||
soft_delete_in(
|
||||
"reactions", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
|
||||
)
|
||||
soft_delete_in(
|
||||
"bookmarks", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
|
||||
)
|
||||
if target_type == "post" and "polls" in db.tables:
|
||||
for uid in uids:
|
||||
for poll in db["polls"].find(post_uid=uid, deleted_at=None):
|
||||
soft_delete("poll_votes", deleted_by, stamp=stamp, poll_uid=poll["uid"])
|
||||
soft_delete("poll_options", deleted_by, stamp=stamp, poll_uid=poll["uid"])
|
||||
soft_delete("polls", deleted_by, stamp=stamp, post_uid=uid)
|
||||
|
||||
|
||||
def delete_engagement(target_type: str, target_uids: list) -> None:
|
||||
uids = [uid for uid in (target_uids or []) if uid]
|
||||
if not uids:
|
||||
return
|
||||
if "reactions" in db.tables:
|
||||
placeholders, params = _in_clause(uids)
|
||||
params["tt"] = target_type
|
||||
db.query(
|
||||
f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
if "bookmarks" in db.tables:
|
||||
placeholders, params = _in_clause(uids)
|
||||
params["tt"] = target_type
|
||||
db.query(
|
||||
f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
if target_type == "post" and "polls" in db.tables:
|
||||
for uid in uids:
|
||||
for poll in db["polls"].find(post_uid=uid):
|
||||
if "poll_votes" in db.tables:
|
||||
db["poll_votes"].delete(poll_uid=poll["uid"])
|
||||
if "poll_options" in db.tables:
|
||||
db["poll_options"].delete(poll_uid=poll["uid"])
|
||||
db["polls"].delete(post_uid=uid)
|
||||
|
||||
|
||||
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
|
||||
table_name = VOTABLE_TARGETS.get(target_type)
|
||||
if not table_name:
|
||||
return None
|
||||
row = get_table(table_name).find_one(uid=target_uid, deleted_at=None)
|
||||
return row["user_uid"] if row else None
|
||||
45
devplacepy/database/relations.py
Normal file
45
devplacepy/database/relations.py
Normal file
@ -0,0 +1,45 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import TTLCache, bump_cache_version, db, sync_local_cache
|
||||
|
||||
|
||||
_relations_cache = TTLCache(ttl=300, max_size=2000)
|
||||
|
||||
|
||||
def get_user_relations(viewer_uid: str | None) -> dict:
|
||||
if not viewer_uid:
|
||||
return {"block": frozenset(), "mute": frozenset()}
|
||||
sync_local_cache("relations", _relations_cache)
|
||||
cached = _relations_cache.get(viewer_uid)
|
||||
if cached is not None:
|
||||
return cached
|
||||
block: set = set()
|
||||
mute: set = set()
|
||||
if "user_relations" in db.tables:
|
||||
for row in db["user_relations"].find(user_uid=viewer_uid, deleted_at=None):
|
||||
target = row["target_uid"]
|
||||
if row["kind"] == "block":
|
||||
block.add(target)
|
||||
elif row["kind"] == "mute":
|
||||
mute.add(target)
|
||||
result = {"block": frozenset(block), "mute": frozenset(mute)}
|
||||
_relations_cache.set(viewer_uid, result)
|
||||
return result
|
||||
|
||||
|
||||
def get_blocked_uids(viewer_uid: str | None) -> frozenset:
|
||||
return get_user_relations(viewer_uid)["block"]
|
||||
|
||||
|
||||
def get_muted_uids(viewer_uid: str | None) -> frozenset:
|
||||
return get_user_relations(viewer_uid)["mute"]
|
||||
|
||||
|
||||
def get_silenced_uids(viewer_uid: str | None) -> frozenset:
|
||||
relations = get_user_relations(viewer_uid)
|
||||
return relations["block"] | relations["mute"]
|
||||
|
||||
|
||||
def invalidate_user_relations(viewer_uid: str) -> None:
|
||||
_relations_cache.pop(viewer_uid)
|
||||
bump_cache_version("relations")
|
||||
1207
devplacepy/database/schema.py
Normal file
1207
devplacepy/database/schema.py
Normal file
File diff suppressed because it is too large
Load Diff
87
devplacepy/database/seo_meta.py
Normal file
87
devplacepy/database/seo_meta.py
Normal file
@ -0,0 +1,87 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import _in_clause, _now_iso, db, get_table
|
||||
|
||||
|
||||
SEO_META_TYPES = ("post", "project", "gist", "news", "issue")
|
||||
|
||||
|
||||
def get_seo_metadata(target_type: str, target_uid: str) -> dict | None:
|
||||
if not target_type or not target_uid or "seo_metadata" not in db.tables:
|
||||
return None
|
||||
row = get_table("seo_metadata").find_one(
|
||||
target_type=target_type,
|
||||
target_uid=str(target_uid),
|
||||
status="ready",
|
||||
deleted_at=None,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_seo_metadata_batch(target_type: str, uids: list) -> dict:
|
||||
uids = [str(uid) for uid in (uids or []) if uid]
|
||||
if not uids or "seo_metadata" not in db.tables:
|
||||
return {}
|
||||
placeholders, params = _in_clause(uids)
|
||||
params["tt"] = target_type
|
||||
rows = db.query(
|
||||
"SELECT * FROM seo_metadata WHERE target_type = :tt AND status = 'ready' "
|
||||
f"AND deleted_at IS NULL AND target_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
return {row["target_uid"]: dict(row) for row in rows}
|
||||
|
||||
|
||||
def has_fresh_seo_metadata(target_type: str, target_uid: str) -> bool:
|
||||
return get_seo_metadata(target_type, target_uid) is not None
|
||||
|
||||
|
||||
def upsert_seo_metadata(
|
||||
target_type: str,
|
||||
target_uid: str,
|
||||
seo_title: str,
|
||||
seo_description: str,
|
||||
seo_keywords: str,
|
||||
status: str,
|
||||
source: str,
|
||||
) -> None:
|
||||
if not target_type or not target_uid:
|
||||
return
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
table = get_table("seo_metadata")
|
||||
now = _now_iso()
|
||||
generated_at = now if status == "ready" else ""
|
||||
existing = table.find_one(target_type=target_type, target_uid=str(target_uid))
|
||||
payload = {
|
||||
"target_type": target_type,
|
||||
"target_uid": str(target_uid),
|
||||
"seo_title": seo_title,
|
||||
"seo_description": seo_description,
|
||||
"seo_keywords": seo_keywords,
|
||||
"status": status,
|
||||
"source": source,
|
||||
"generated_at": generated_at,
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
if existing:
|
||||
payload["id"] = existing["id"]
|
||||
table.update(payload, ["id"])
|
||||
else:
|
||||
payload["uid"] = generate_uid()
|
||||
payload["created_at"] = now
|
||||
table.insert(payload)
|
||||
|
||||
|
||||
def mark_seo_metadata_stale(target_type: str, target_uid: str) -> None:
|
||||
if not target_type or not target_uid or "seo_metadata" not in db.tables:
|
||||
return
|
||||
table = get_table("seo_metadata")
|
||||
existing = table.find_one(target_type=target_type, target_uid=str(target_uid))
|
||||
if existing:
|
||||
table.update(
|
||||
{"id": existing["id"], "status": "pending", "updated_at": _now_iso()},
|
||||
["id"],
|
||||
)
|
||||
48
devplacepy/database/settings.py
Normal file
48
devplacepy/database/settings.py
Normal file
@ -0,0 +1,48 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import TTLCache, bump_cache_version, db, get_table, sync_local_cache
|
||||
|
||||
|
||||
def internal_gateway_key() -> str:
|
||||
return get_setting("gateway_internal_key", "")
|
||||
|
||||
|
||||
_settings_cache = TTLCache(ttl=60, max_size=100)
|
||||
|
||||
|
||||
def get_setting(key: str, default: str = "") -> str:
|
||||
sync_local_cache("settings", _settings_cache)
|
||||
cached = _settings_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if "site_settings" not in db.tables:
|
||||
return default
|
||||
entry = db["site_settings"].find_one(key=key)
|
||||
if entry is None:
|
||||
return default
|
||||
_settings_cache.set(key, entry["value"])
|
||||
return entry["value"]
|
||||
|
||||
|
||||
def get_int_setting(key: str, default: int) -> int:
|
||||
raw = get_setting(key, str(default))
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def set_setting(key: str, value: str) -> None:
|
||||
settings = get_table("site_settings")
|
||||
existing = settings.find_one(key=key)
|
||||
if existing:
|
||||
settings.update({"id": existing["id"], "key": key, "value": value}, ["id"])
|
||||
else:
|
||||
settings.insert({"uid": f"setting_{key}", "key": key, "value": value})
|
||||
_settings_cache.set(key, value)
|
||||
bump_cache_version("settings")
|
||||
|
||||
|
||||
def clear_settings_cache() -> None:
|
||||
_settings_cache.clear()
|
||||
bump_cache_version("settings")
|
||||
186
devplacepy/database/soft_delete.py
Normal file
186
devplacepy/database/soft_delete.py
Normal file
@ -0,0 +1,186 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import _drop_index, _in_clause, _index, _now_iso, db
|
||||
from .pagination import build_pagination
|
||||
|
||||
|
||||
SOFT_DELETE_TABLES = [
|
||||
"posts",
|
||||
"comments",
|
||||
"gists",
|
||||
"projects",
|
||||
"news",
|
||||
"news_images",
|
||||
"project_files",
|
||||
"attachments",
|
||||
"votes",
|
||||
"reactions",
|
||||
"bookmarks",
|
||||
"follows",
|
||||
"poll_votes",
|
||||
"polls",
|
||||
"poll_options",
|
||||
"sessions",
|
||||
"instances",
|
||||
"instance_schedules",
|
||||
"backup_schedules",
|
||||
"devii_conversations",
|
||||
"devii_tasks",
|
||||
"devii_lessons",
|
||||
"devii_virtual_tools",
|
||||
"user_customizations",
|
||||
"project_forks",
|
||||
"issue_tickets",
|
||||
"issue_comment_authors",
|
||||
"notification_preferences",
|
||||
"deepsearch_sessions",
|
||||
"deepsearch_messages",
|
||||
"devrant_tokens",
|
||||
"access_tokens",
|
||||
"email_accounts",
|
||||
"user_relations",
|
||||
"seo_metadata",
|
||||
]
|
||||
|
||||
|
||||
def ensure_soft_delete_columns(table, *, db_handle=None):
|
||||
handle = db_handle or db
|
||||
if table not in handle.tables:
|
||||
return
|
||||
target = handle[table]
|
||||
if not target.has_column("deleted_at"):
|
||||
target.create_column_by_example("deleted_at", "")
|
||||
if not target.has_column("deleted_by"):
|
||||
target.create_column_by_example("deleted_by", "")
|
||||
_drop_index(handle, f"idx_{table}_deleted")
|
||||
_index(
|
||||
handle,
|
||||
table,
|
||||
f"idx_{table}_trash",
|
||||
["deleted_at"],
|
||||
where="deleted_at IS NOT NULL",
|
||||
)
|
||||
|
||||
|
||||
def soft_delete(table_name, deleted_by, *, stamp=None, **criteria):
|
||||
if table_name not in db.tables:
|
||||
return 0
|
||||
table = db[table_name]
|
||||
if not table.has_column("deleted_at"):
|
||||
return 0
|
||||
rows = list(table.find(deleted_at=None, **criteria))
|
||||
if not rows:
|
||||
return 0
|
||||
stamp = stamp or _now_iso()
|
||||
for row in rows:
|
||||
table.update(
|
||||
{"id": row["id"], "deleted_at": stamp, "deleted_by": deleted_by}, ["id"]
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def soft_delete_in(table_name, column, uids, deleted_by, *, stamp=None, **extra):
|
||||
uids = [uid for uid in (uids or []) if uid]
|
||||
if not uids or table_name not in db.tables:
|
||||
return 0
|
||||
if not db[table_name].has_column("deleted_at"):
|
||||
return 0
|
||||
placeholders, params = _in_clause(uids)
|
||||
params["dat"] = stamp or _now_iso()
|
||||
params["dby"] = deleted_by
|
||||
extra_sql = ""
|
||||
for index, (key, value) in enumerate(extra.items()):
|
||||
params[f"x{index}"] = value
|
||||
extra_sql += f" AND {key} = :x{index}"
|
||||
db.query(
|
||||
f"UPDATE {table_name} SET deleted_at = :dat, deleted_by = :dby "
|
||||
f"WHERE {column} IN ({placeholders}) AND deleted_at IS NULL{extra_sql}",
|
||||
**params,
|
||||
)
|
||||
return len(uids)
|
||||
|
||||
|
||||
def restore(table_name, **criteria):
|
||||
if table_name not in db.tables:
|
||||
return 0
|
||||
table = db[table_name]
|
||||
if not table.has_column("deleted_at"):
|
||||
return 0
|
||||
rows = [row for row in table.find(**criteria) if row.get("deleted_at")]
|
||||
for row in rows:
|
||||
table.update({"id": row["id"], "deleted_at": None, "deleted_by": None}, ["id"])
|
||||
return len(rows)
|
||||
|
||||
|
||||
def purge(table_name, **criteria):
|
||||
if table_name not in db.tables:
|
||||
return 0
|
||||
table = db[table_name]
|
||||
count = table.count(**criteria)
|
||||
table.delete(**criteria)
|
||||
return int(count)
|
||||
|
||||
|
||||
def list_deleted(table_name, page=1, per_page=25):
|
||||
if table_name not in db.tables or not db[table_name].has_column("deleted_at"):
|
||||
return [], build_pagination(page, 0, per_page)
|
||||
table = db[table_name]
|
||||
column = table.table.columns.deleted_at
|
||||
total = table.count(column.isnot(None))
|
||||
pagination = build_pagination(page, total, per_page)
|
||||
offset = (pagination["page"] - 1) * pagination["per_page"]
|
||||
rows = list(
|
||||
table.find(
|
||||
column.isnot(None),
|
||||
order_by=["-deleted_at"],
|
||||
_limit=pagination["per_page"],
|
||||
_offset=offset,
|
||||
)
|
||||
)
|
||||
return rows, pagination
|
||||
|
||||
|
||||
def count_deleted(table_name):
|
||||
if table_name not in db.tables or not db[table_name].has_column("deleted_at"):
|
||||
return 0
|
||||
table = db[table_name]
|
||||
return int(table.count(table.table.columns.deleted_at.isnot(None)))
|
||||
|
||||
|
||||
def restore_event(stamp):
|
||||
if not stamp:
|
||||
return 0
|
||||
restored = 0
|
||||
for table_name in SOFT_DELETE_TABLES:
|
||||
if table_name in db.tables and db[table_name].has_column("deleted_at"):
|
||||
restored += int(
|
||||
db.query(
|
||||
f"SELECT COUNT(*) AS n FROM {table_name} WHERE deleted_at = :s",
|
||||
s=stamp,
|
||||
).__next__()["n"]
|
||||
)
|
||||
db.query(
|
||||
f"UPDATE {table_name} SET deleted_at = NULL, deleted_by = NULL "
|
||||
f"WHERE deleted_at = :s",
|
||||
s=stamp,
|
||||
)
|
||||
return restored
|
||||
|
||||
|
||||
def purge_event(stamp):
|
||||
if not stamp:
|
||||
return []
|
||||
purged = []
|
||||
for table_name in SOFT_DELETE_TABLES:
|
||||
if table_name in db.tables and db[table_name].has_column("deleted_at"):
|
||||
rows = list(
|
||||
db.query(
|
||||
f"SELECT * FROM {table_name} WHERE deleted_at = :s", s=stamp
|
||||
)
|
||||
)
|
||||
if rows:
|
||||
purged.append((table_name, rows))
|
||||
db.query(
|
||||
f"DELETE FROM {table_name} WHERE deleted_at = :s", s=stamp
|
||||
)
|
||||
return purged
|
||||
151
devplacepy/database/stats.py
Normal file
151
devplacepy/database/stats.py
Normal file
@ -0,0 +1,151 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import TTLCache, datetime, db, timedelta, timezone
|
||||
from .ranking import get_top_authors
|
||||
|
||||
|
||||
_stats_cache = TTLCache(ttl=30, max_size=50)
|
||||
|
||||
|
||||
def get_site_stats() -> dict:
|
||||
cached = _stats_cache.get("site")
|
||||
if cached is not None:
|
||||
return cached
|
||||
today_start = (
|
||||
datetime.now(timezone.utc)
|
||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
.isoformat()
|
||||
)
|
||||
stats = {
|
||||
"total_members": db["users"].count() if "users" in db.tables else 0,
|
||||
"posts_today": db["posts"].count(
|
||||
created_at={">=": today_start}, deleted_at=None
|
||||
)
|
||||
if "posts" in db.tables
|
||||
else 0,
|
||||
"total_projects": db["projects"].count(deleted_at=None)
|
||||
if "projects" in db.tables
|
||||
else 0,
|
||||
"total_gists": db["gists"].count(deleted_at=None)
|
||||
if "gists" in db.tables
|
||||
else 0,
|
||||
}
|
||||
_stats_cache.set("site", stats)
|
||||
return stats
|
||||
|
||||
|
||||
_analytics_cache = TTLCache(ttl=300, max_size=50)
|
||||
|
||||
|
||||
def get_platform_analytics(top_n: int = 10) -> dict:
|
||||
top_n = max(1, min(int(top_n or 10), 50))
|
||||
cache_key = f"analytics:{top_n}"
|
||||
cached = _analytics_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
d1 = (now - timedelta(days=1)).isoformat()
|
||||
d7 = (now - timedelta(days=7)).isoformat()
|
||||
d30 = (now - timedelta(days=30)).isoformat()
|
||||
|
||||
active_24h = active_7d = active_30d = 0
|
||||
sources = [
|
||||
table
|
||||
for table in ("posts", "comments", "gists", "projects")
|
||||
if table in db.tables
|
||||
]
|
||||
if sources:
|
||||
union = " UNION ALL ".join(
|
||||
f"SELECT user_uid, created_at FROM {table} WHERE deleted_at IS NULL"
|
||||
for table in sources
|
||||
)
|
||||
rows = db.query(
|
||||
"SELECT "
|
||||
"COUNT(DISTINCT CASE WHEN created_at >= :d1 THEN user_uid END) AS a1, "
|
||||
"COUNT(DISTINCT CASE WHEN created_at >= :d7 THEN user_uid END) AS a7, "
|
||||
"COUNT(DISTINCT CASE WHEN created_at >= :d30 THEN user_uid END) AS a30 "
|
||||
f"FROM ({union})",
|
||||
d1=d1,
|
||||
d7=d7,
|
||||
d30=d30,
|
||||
)
|
||||
for row in rows:
|
||||
active_24h = row["a1"] or 0
|
||||
active_7d = row["a7"] or 0
|
||||
active_30d = row["a30"] or 0
|
||||
|
||||
signed_in_now = 0
|
||||
if "sessions" in db.tables:
|
||||
for row in db.query(
|
||||
"SELECT COUNT(DISTINCT user_uid) AS n FROM sessions WHERE expires_at > :now AND deleted_at IS NULL",
|
||||
now=now.isoformat(),
|
||||
):
|
||||
signed_in_now = row["n"] or 0
|
||||
|
||||
def _users_created_since(cutoff: str) -> int:
|
||||
return (
|
||||
db["users"].count(created_at={">=": cutoff}) if "users" in db.tables else 0
|
||||
)
|
||||
|
||||
site = get_site_stats()
|
||||
totals = {
|
||||
"posts": db["posts"].count(deleted_at=None) if "posts" in db.tables else 0,
|
||||
"comments": db["comments"].count(deleted_at=None)
|
||||
if "comments" in db.tables
|
||||
else 0,
|
||||
"gists": site["total_gists"],
|
||||
"projects": site["total_projects"],
|
||||
"news": db["news"].count(deleted_at=None) if "news" in db.tables else 0,
|
||||
}
|
||||
top_authors = [
|
||||
{"username": author.get("username", ""), "stars": author.get("stars", 0)}
|
||||
for author in get_top_authors(top_n)
|
||||
]
|
||||
|
||||
result = {
|
||||
"total_members": site["total_members"],
|
||||
"active_24h": active_24h,
|
||||
"active_7d": active_7d,
|
||||
"active_30d": active_30d,
|
||||
"signed_in_now": signed_in_now,
|
||||
"new_24h": _users_created_since(d1),
|
||||
"new_7d": _users_created_since(d7),
|
||||
"new_30d": _users_created_since(d30),
|
||||
"posts_today": site["posts_today"],
|
||||
"totals": totals,
|
||||
"top_authors": top_authors,
|
||||
"active_definition": (
|
||||
"Active = created a post, comment, gist, or project within the window. "
|
||||
"Automated/bot accounts are not separately flagged."
|
||||
),
|
||||
"signed_in_definition": (
|
||||
"signed_in_now = distinct members holding an unexpired session cookie, i.e. who "
|
||||
"logged in within the session lifetime (default 7 days, up to 30 with remember-me) "
|
||||
"and have not logged out. It is NOT a real-time presence/online count - the platform "
|
||||
"does not track per-request last-seen activity - so it is normally a large fraction "
|
||||
"of recently active members. Do not describe it as 'currently online' or 'logged in "
|
||||
"right now'."
|
||||
),
|
||||
}
|
||||
_analytics_cache.set(cache_key, result)
|
||||
return result
|
||||
|
||||
|
||||
_gist_languages_cache = TTLCache(ttl=60, max_size=200)
|
||||
|
||||
|
||||
def get_gist_languages() -> set[str]:
|
||||
cached = _gist_languages_cache.get("codes")
|
||||
if cached is not None:
|
||||
return cached
|
||||
codes: set[str] = set()
|
||||
if "gists" in db.tables:
|
||||
for row in db.query(
|
||||
"SELECT DISTINCT language FROM gists WHERE deleted_at IS NULL"
|
||||
):
|
||||
language = row.get("language")
|
||||
if language:
|
||||
codes.add(language)
|
||||
_gist_languages_cache.set("codes", codes)
|
||||
return codes
|
||||
128
devplacepy/database/usage.py
Normal file
128
devplacepy/database/usage.py
Normal file
@ -0,0 +1,128 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import datetime, db, get_table, timezone
|
||||
|
||||
|
||||
def _add_usage(usage_table: str, user_uid: str, totals: dict) -> None:
|
||||
if not user_uid or int(totals.get("calls") or 0) <= 0 or usage_table not in db.tables:
|
||||
return
|
||||
with db:
|
||||
db.query(
|
||||
f"INSERT INTO {usage_table} "
|
||||
"(user_uid, calls, prompt_tokens, completion_tokens, total_tokens, cost_usd, "
|
||||
"upstream_latency_ms, total_latency_ms, updated_at) "
|
||||
"VALUES (:user_uid, :calls, :prompt, :completion, :total, :cost, :ulat, :tlat, :now) "
|
||||
"ON CONFLICT(user_uid) DO UPDATE SET "
|
||||
"calls = calls + excluded.calls, "
|
||||
"prompt_tokens = prompt_tokens + excluded.prompt_tokens, "
|
||||
"completion_tokens = completion_tokens + excluded.completion_tokens, "
|
||||
"total_tokens = total_tokens + excluded.total_tokens, "
|
||||
"cost_usd = cost_usd + excluded.cost_usd, "
|
||||
"upstream_latency_ms = upstream_latency_ms + excluded.upstream_latency_ms, "
|
||||
"total_latency_ms = total_latency_ms + excluded.total_latency_ms, "
|
||||
"updated_at = excluded.updated_at",
|
||||
user_uid=user_uid,
|
||||
calls=int(totals.get("calls") or 0),
|
||||
prompt=int(totals.get("prompt_tokens") or 0),
|
||||
completion=int(totals.get("completion_tokens") or 0),
|
||||
total=int(totals.get("total_tokens") or 0),
|
||||
cost=float(totals.get("cost_usd") or 0.0),
|
||||
ulat=float(totals.get("upstream_latency_ms") or 0.0),
|
||||
tlat=float(totals.get("total_latency_ms") or 0.0),
|
||||
now=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _get_usage(usage_table: str, user_uid: str) -> dict:
|
||||
empty = {
|
||||
"calls": 0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"upstream_latency_ms": 0.0,
|
||||
"total_latency_ms": 0.0,
|
||||
"avg_tokens": 0.0,
|
||||
"avg_upstream_latency_ms": 0.0,
|
||||
"avg_total_latency_ms": 0.0,
|
||||
"avg_tokens_per_second": 0.0,
|
||||
"avg_cost_usd": 0.0,
|
||||
"updated_at": None,
|
||||
}
|
||||
if not user_uid or usage_table not in db.tables:
|
||||
return empty
|
||||
row = get_table(usage_table).find_one(user_uid=user_uid)
|
||||
if not row:
|
||||
return empty
|
||||
calls = int(row.get("calls") or 0)
|
||||
completion = int(row.get("completion_tokens") or 0)
|
||||
total_tokens = int(row.get("total_tokens") or 0)
|
||||
cost = float(row.get("cost_usd") or 0.0)
|
||||
upstream_ms = float(row.get("upstream_latency_ms") or 0.0)
|
||||
total_ms = float(row.get("total_latency_ms") or 0.0)
|
||||
return {
|
||||
"calls": calls,
|
||||
"prompt_tokens": int(row.get("prompt_tokens") or 0),
|
||||
"completion_tokens": completion,
|
||||
"total_tokens": total_tokens,
|
||||
"cost_usd": cost,
|
||||
"upstream_latency_ms": upstream_ms,
|
||||
"total_latency_ms": total_ms,
|
||||
"avg_tokens": round(total_tokens / calls, 1) if calls else 0.0,
|
||||
"avg_upstream_latency_ms": round(upstream_ms / calls, 1) if calls else 0.0,
|
||||
"avg_total_latency_ms": round(total_ms / calls, 1) if calls else 0.0,
|
||||
"avg_tokens_per_second": round(completion / (upstream_ms / 1000.0), 1)
|
||||
if upstream_ms > 0
|
||||
else 0.0,
|
||||
"avg_cost_usd": (cost / calls) if calls else 0.0,
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def add_correction_usage(user_uid: str, totals: dict) -> None:
|
||||
_add_usage("correction_usage", user_uid, totals)
|
||||
|
||||
|
||||
def get_correction_usage(user_uid: str) -> dict:
|
||||
return _get_usage("correction_usage", user_uid)
|
||||
|
||||
|
||||
def add_modifier_usage(user_uid: str, totals: dict) -> None:
|
||||
_add_usage("modifier_usage", user_uid, totals)
|
||||
|
||||
|
||||
def get_modifier_usage(user_uid: str) -> dict:
|
||||
return _get_usage("modifier_usage", user_uid)
|
||||
|
||||
|
||||
NEWS_USAGE_KEY = "news"
|
||||
|
||||
|
||||
def add_news_usage(totals: dict) -> None:
|
||||
_add_usage("news_usage", NEWS_USAGE_KEY, totals)
|
||||
|
||||
|
||||
def get_news_usage() -> dict:
|
||||
return _get_usage("news_usage", NEWS_USAGE_KEY)
|
||||
|
||||
|
||||
ISSUE_USAGE_KEY = "issues"
|
||||
|
||||
|
||||
def add_issue_usage(totals: dict) -> None:
|
||||
_add_usage("issue_usage", ISSUE_USAGE_KEY, totals)
|
||||
|
||||
|
||||
def get_issue_usage() -> dict:
|
||||
return _get_usage("issue_usage", ISSUE_USAGE_KEY)
|
||||
|
||||
|
||||
SEO_USAGE_KEY = "seo_meta"
|
||||
|
||||
|
||||
def add_seo_usage(totals: dict) -> None:
|
||||
_add_usage("seo_usage", SEO_USAGE_KEY, totals)
|
||||
|
||||
|
||||
def get_seo_usage() -> dict:
|
||||
return _get_usage("seo_usage", SEO_USAGE_KEY)
|
||||
84
devplacepy/database/users.py
Normal file
84
devplacepy/database/users.py
Normal file
@ -0,0 +1,84 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .core import TTLCache, bump_cache_version, db, sync_local_cache
|
||||
|
||||
|
||||
def get_users_by_uids(uids):
|
||||
if not uids or "users" not in db.tables:
|
||||
return {}
|
||||
users = db["users"]
|
||||
if "uid" not in users.columns:
|
||||
return {}
|
||||
seen = set()
|
||||
unique = [u for u in uids if u not in seen and not seen.add(u)]
|
||||
return {u["uid"]: u for u in users.find(users.table.columns.uid.in_(unique))}
|
||||
|
||||
|
||||
_admins_cache = TTLCache(ttl=300, max_size=4)
|
||||
|
||||
|
||||
def invalidate_admins_cache() -> None:
|
||||
_admins_cache.clear()
|
||||
bump_cache_version("admins")
|
||||
|
||||
|
||||
def get_admin_uids():
|
||||
sync_local_cache("admins", _admins_cache)
|
||||
cached = _admins_cache.get("uids")
|
||||
if cached is not None:
|
||||
return list(cached)
|
||||
if "users" not in db.tables:
|
||||
return []
|
||||
rows = db.query("SELECT uid FROM users WHERE role = 'Admin'")
|
||||
uids = [row["uid"] for row in rows]
|
||||
_admins_cache.set("uids", uids)
|
||||
return list(uids)
|
||||
|
||||
|
||||
def set_user_timezone(user_uid: str, tz_name: str) -> None:
|
||||
if "users" not in db.tables or not user_uid or not tz_name:
|
||||
return
|
||||
users = db["users"]
|
||||
if not users.has_column("timezone"):
|
||||
users.create_column_by_example("timezone", "")
|
||||
current = users.find_one(uid=user_uid)
|
||||
if current and current.get("timezone") == tz_name:
|
||||
return
|
||||
users.update({"uid": user_uid, "timezone": tz_name}, ["uid"])
|
||||
|
||||
|
||||
def get_primary_admin_uid():
|
||||
sync_local_cache("admins", _admins_cache)
|
||||
cached = _admins_cache.get("primary")
|
||||
if cached is not None:
|
||||
return cached or None
|
||||
if "users" not in db.tables:
|
||||
return None
|
||||
rows = list(
|
||||
db.query(
|
||||
"SELECT uid FROM users WHERE role = 'Admin' "
|
||||
"ORDER BY created_at ASC, id ASC LIMIT 1"
|
||||
)
|
||||
)
|
||||
primary = rows[0]["uid"] if rows else None
|
||||
_admins_cache.set("primary", primary or "")
|
||||
return primary
|
||||
|
||||
|
||||
def search_users_by_username(q, *, exclude_uid=None, limit=10):
|
||||
if not q or "users" not in db.tables:
|
||||
return []
|
||||
if exclude_uid is not None:
|
||||
rows = db.query(
|
||||
"SELECT uid, username FROM users WHERE username LIKE :q AND uid != :me LIMIT :limit",
|
||||
q=f"%{q}%",
|
||||
me=exclude_uid,
|
||||
limit=limit,
|
||||
)
|
||||
else:
|
||||
rows = db.query(
|
||||
"SELECT uid, username FROM users WHERE username LIKE :q LIMIT :limit",
|
||||
q=f"%{q}%",
|
||||
limit=limit,
|
||||
)
|
||||
return [{"uid": r["uid"], "username": r["username"]} for r in rows]
|
||||
File diff suppressed because it is too large
Load Diff
41
devplacepy/docs_api/__init__.py
Normal file
41
devplacepy/docs_api/__init__.py
Normal file
@ -0,0 +1,41 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ._shared import (
|
||||
BOOKMARK_TARGETS,
|
||||
COMMENT_TARGETS,
|
||||
GIST_LANGUAGES,
|
||||
NON_BODY_ENDPOINTS,
|
||||
PROJECT_TYPES,
|
||||
REACTION_TARGETS,
|
||||
ROLE_LABELS,
|
||||
SERVICE_ACTIONS,
|
||||
VOTE_TARGETS,
|
||||
endpoint,
|
||||
field,
|
||||
)
|
||||
from .groups import ORDERED_GROUPS as API_GROUPS
|
||||
from .negotiation import _apply_negotiated_responses
|
||||
from .services_group import build_services_group
|
||||
from .render import api_doc_pages, get_group, render_group, _substitute
|
||||
|
||||
_apply_negotiated_responses(API_GROUPS)
|
||||
|
||||
__all__ = [
|
||||
"API_GROUPS",
|
||||
"build_services_group",
|
||||
"get_group",
|
||||
"api_doc_pages",
|
||||
"render_group",
|
||||
"_substitute",
|
||||
"endpoint",
|
||||
"field",
|
||||
"ROLE_LABELS",
|
||||
"NON_BODY_ENDPOINTS",
|
||||
"VOTE_TARGETS",
|
||||
"REACTION_TARGETS",
|
||||
"BOOKMARK_TARGETS",
|
||||
"COMMENT_TARGETS",
|
||||
"PROJECT_TYPES",
|
||||
"GIST_LANGUAGES",
|
||||
"SERVICE_ACTIONS",
|
||||
]
|
||||
98
devplacepy/docs_api/_shared.py
Normal file
98
devplacepy/docs_api/_shared.py
Normal file
@ -0,0 +1,98 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
VOTE_TARGETS = ["post", "comment", "gist", "project"]
|
||||
REACTION_TARGETS = ["post", "comment", "gist", "project"]
|
||||
BOOKMARK_TARGETS = ["post", "gist", "project", "news"]
|
||||
COMMENT_TARGETS = ["post", "project", "news", "issue", "gist"]
|
||||
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
|
||||
GIST_LANGUAGES = [
|
||||
"python",
|
||||
"javascript",
|
||||
"typescript",
|
||||
"html",
|
||||
"css",
|
||||
"c",
|
||||
"cpp",
|
||||
"java",
|
||||
"go",
|
||||
"rust",
|
||||
"sql",
|
||||
"bash",
|
||||
"json",
|
||||
"markdown",
|
||||
"plaintext",
|
||||
]
|
||||
SERVICE_ACTIONS = [
|
||||
("start", "Start the service"),
|
||||
("stop", "Stop the service"),
|
||||
("run", "Trigger a single run now"),
|
||||
("clear-logs", "Clear the in-memory log buffer"),
|
||||
]
|
||||
|
||||
|
||||
def field(
|
||||
name,
|
||||
location,
|
||||
type="string",
|
||||
required=False,
|
||||
example="",
|
||||
description="",
|
||||
options=None,
|
||||
):
|
||||
spec = {
|
||||
"name": name,
|
||||
"location": location,
|
||||
"type": type,
|
||||
"required": required,
|
||||
"example": example,
|
||||
"description": description,
|
||||
}
|
||||
if options:
|
||||
spec["options"] = list(options)
|
||||
return spec
|
||||
|
||||
|
||||
ROLE_LABELS = {"public": "Public", "user": "Member", "admin": "Admin"}
|
||||
|
||||
|
||||
def endpoint(
|
||||
id,
|
||||
method,
|
||||
path,
|
||||
title,
|
||||
summary,
|
||||
auth="user",
|
||||
ajax=False,
|
||||
encoding="none",
|
||||
interactive=True,
|
||||
destructive=False,
|
||||
params=None,
|
||||
notes=None,
|
||||
sample_response=None,
|
||||
negotiation=None,
|
||||
):
|
||||
return {
|
||||
"id": id,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"auth": auth,
|
||||
"min_role": ROLE_LABELS.get(auth, auth.title()),
|
||||
"ajax": ajax,
|
||||
"encoding": encoding,
|
||||
"interactive": interactive,
|
||||
"destructive": destructive,
|
||||
"params": params or [],
|
||||
"notes": notes or [],
|
||||
"sample_response": sample_response,
|
||||
"negotiation": negotiation,
|
||||
}
|
||||
|
||||
|
||||
NON_BODY_ENDPOINTS = {
|
||||
"avatar",
|
||||
"gateway-passthrough",
|
||||
"notifications-open",
|
||||
"admin-bots-frame",
|
||||
}
|
||||
43
devplacepy/docs_api/groups/__init__.py
Normal file
43
devplacepy/docs_api/groups/__init__.py
Normal file
@ -0,0 +1,43 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from . import (
|
||||
conventions,
|
||||
auth,
|
||||
lookups,
|
||||
social_actions,
|
||||
content,
|
||||
profiles,
|
||||
messaging,
|
||||
notifications,
|
||||
uploads,
|
||||
project_files,
|
||||
containers,
|
||||
tools,
|
||||
push,
|
||||
issues,
|
||||
gateway,
|
||||
services,
|
||||
admin,
|
||||
game,
|
||||
)
|
||||
|
||||
ORDERED_GROUPS = [
|
||||
conventions.GROUP,
|
||||
auth.GROUP,
|
||||
lookups.GROUP,
|
||||
social_actions.GROUP,
|
||||
content.GROUP,
|
||||
profiles.GROUP,
|
||||
messaging.GROUP,
|
||||
notifications.GROUP,
|
||||
uploads.GROUP,
|
||||
project_files.GROUP,
|
||||
containers.GROUP,
|
||||
tools.GROUP,
|
||||
push.GROUP,
|
||||
issues.GROUP,
|
||||
gateway.GROUP,
|
||||
services.GROUP,
|
||||
admin.GROUP,
|
||||
game.GROUP,
|
||||
]
|
||||
740
devplacepy/docs_api/groups/admin.py
Normal file
740
devplacepy/docs_api/groups/admin.py
Normal file
@ -0,0 +1,740 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "admin",
|
||||
"title": "Admin API",
|
||||
"admin": True,
|
||||
"intro": """
|
||||
# Admin API
|
||||
|
||||
Site administration endpoints for users, news curation, and settings. Every call requires an
|
||||
**admin** account. Background service management lives on the
|
||||
[Background Services](/docs/services.html) page.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
four ways to sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="admin-users",
|
||||
method="GET",
|
||||
path="/admin/users",
|
||||
title="List users",
|
||||
summary="Paginated user management page. Returns HTML.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[field("page", "query", "int", False, "1", "Page number.")],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-media",
|
||||
method="GET",
|
||||
path="/admin/media",
|
||||
title="Media trash",
|
||||
summary=(
|
||||
"Moderation view of deleted media. When a member or admin deletes an "
|
||||
"attachment it is soft-deleted: hidden from the gallery and from its parent "
|
||||
"object, but the row and file are kept and the relation to the parent is "
|
||||
"preserved. This page lists every soft-deleted attachment, newest first, with "
|
||||
"its uploader, so an admin can restore or permanently purge it. Returns HTML."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[field("page", "query", "int", False, "1", "Page number.")],
|
||||
),
|
||||
endpoint(
|
||||
id="media-restore",
|
||||
method="POST",
|
||||
path="/media/{uid}/restore",
|
||||
title="Restore media",
|
||||
summary=(
|
||||
"Restore a soft-deleted attachment. Because the parent relation is never "
|
||||
"cleared, it reappears in the owner's Media tab and on its original post, "
|
||||
"project, or gist immediately."
|
||||
),
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "", "Soft-deleted attachment uid."
|
||||
),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/media"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-media-purge",
|
||||
method="POST",
|
||||
path="/admin/media/{uid}/purge",
|
||||
title="Purge media",
|
||||
summary=(
|
||||
"Permanently delete a soft-deleted attachment: removes the database row and "
|
||||
"deletes the file from disk. This cannot be undone and is the only way media "
|
||||
"is hard-deleted from the UI."
|
||||
),
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "", "Attachment uid to purge."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/media"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-audit-log",
|
||||
method="GET",
|
||||
path="/admin/audit-log",
|
||||
title="Audit log",
|
||||
summary=(
|
||||
"Paginated, filterable audit event list (newest first, 25 per page). "
|
||||
"Negotiates HTML or JSON. Every state-changing action on the platform "
|
||||
"is recorded here with actor, origin, target, change, and result."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("page", "query", "int", False, "1", "Page number."),
|
||||
field("event_key", "query", "string", False, "post.create", "Filter by exact event key."),
|
||||
field("category", "query", "string", False, "admin", "Filter by category (auth, content, admin, container, ...)."),
|
||||
field("actor_role", "query", "string", False, "admin", "Filter by actor role at action time."),
|
||||
field("actor_uid", "query", "string", False, "", "Filter by acting user uid."),
|
||||
field("origin", "query", "string", False, "web", "Filter by origin (web, api, devii, cli, service, scheduler)."),
|
||||
field("result", "query", "string", False, "denied", "Filter by result (success, failure, denied)."),
|
||||
field("q", "query", "string", False, "", "Free-text search over summary, event key, and target."),
|
||||
field("date_from", "query", "string", False, "", "ISO date lower bound (inclusive)."),
|
||||
field("date_to", "query", "string", False, "", "ISO date upper bound (inclusive)."),
|
||||
],
|
||||
sample_response={
|
||||
"entries": [
|
||||
{
|
||||
"uid": "AUDIT_UID",
|
||||
"created_at": "2026-06-11T20:00:00+00:00",
|
||||
"event_key": "admin.setting.update",
|
||||
"category": "admin",
|
||||
"actor_username": "ADMIN",
|
||||
"actor_role": "admin",
|
||||
"origin": "web",
|
||||
"via_agent": 0,
|
||||
"target_type": "setting",
|
||||
"old_value": "0",
|
||||
"new_value": "1",
|
||||
"result": "success",
|
||||
}
|
||||
],
|
||||
"pagination": {"page": 1, "total": 1, "total_pages": 1},
|
||||
"filters": {},
|
||||
"options": {"category": ["admin", "auth", "content"]},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-audit-event",
|
||||
method="GET",
|
||||
path="/admin/audit-log/{uid}",
|
||||
title="Audit event",
|
||||
summary="A single audit event with its full row and every related-object link.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[field("uid", "path", "string", True, "AUDIT_UID", "Audit event uid.")],
|
||||
sample_response={
|
||||
"event": {"uid": "AUDIT_UID", "event_key": "container.instance.start", "result": "success"},
|
||||
"links": [
|
||||
{"relation": "actor", "object_type": "user", "object_uid": "USER_UID"},
|
||||
{"relation": "instance", "object_type": "instance", "object_uid": "INSTANCE_UID"},
|
||||
],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-analytics",
|
||||
method="GET",
|
||||
path="/admin/analytics",
|
||||
title="Site analytics",
|
||||
summary=(
|
||||
"One-call aggregate analytics, returned as JSON: total members, active users "
|
||||
"in the last 24h/7d/30d, signed_in_now, new signups (24h/7d/30d), content "
|
||||
"totals (posts, comments, gists, projects, news), and top authors. Use this "
|
||||
"instead of paging the user list to count or measure activity."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"top_n",
|
||||
"query",
|
||||
"int",
|
||||
False,
|
||||
"10",
|
||||
"How many top authors to include (1-50).",
|
||||
)
|
||||
],
|
||||
notes=[
|
||||
"`signed_in_now` counts members holding an unexpired session (logged in within the session lifetime, default 7-30 days), not a real-time online/presence count; the response carries a `signed_in_definition` explaining this. `active_*` means created content in the window (`active_definition`).",
|
||||
"This is the endpoint the Devii assistant calls as `site_analytics`; see [Devii internals](/docs/devii-internals.html).",
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-ai-usage",
|
||||
method="GET",
|
||||
path="/admin/ai-usage/data",
|
||||
title="AI gateway usage analytics",
|
||||
summary=(
|
||||
"One-call AI gateway metrics, returned as JSON for a bounded time window: request "
|
||||
"volume and throughput, token usage with averages and percentiles (p50/p90/p95/p99), "
|
||||
"latency (upstream, gateway overhead, queue wait, connection establishment), error "
|
||||
"rates by category, cost in USD (per model, per caller, input vs output, projected "
|
||||
"monthly burn, caching savings), caller behavior, and an hourly breakdown. Cost is "
|
||||
"taken from the upstream native cost when present (OpenRouter) and computed from the "
|
||||
"configured per-million pricing otherwise (DeepSeek)."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"hours",
|
||||
"query",
|
||||
"int",
|
||||
False,
|
||||
"48",
|
||||
"Lookback window in hours (1-168).",
|
||||
),
|
||||
field(
|
||||
"top_n",
|
||||
"query",
|
||||
"int",
|
||||
False,
|
||||
"10",
|
||||
"How many rows in each top-N breakdown.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"TTFT and inter-token latency are not reported: the gateway forwards non-streaming to the upstream."
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-user-ai-usage",
|
||||
method="GET",
|
||||
path="/admin/users/{uid}/ai-usage",
|
||||
title="Per-user AI usage",
|
||||
summary=(
|
||||
"One user's AI gateway usage over the last 24 hours, returned as JSON: request "
|
||||
"volume, success and error rates, token totals, cost (window, per hour, per request, "
|
||||
"and a 30-day projection from the full 24h spend), average latency and throughput, a "
|
||||
"per-model breakdown, and an hourly cost series. Because Devii operates a signed-in "
|
||||
"user's account with that user's own API key, this is the user's complete gateway "
|
||||
"spend, whether driven through Devii or direct API calls. Shown admin-only on the "
|
||||
"user's profile page."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "USER_UID", "Target user UID."
|
||||
),
|
||||
field(
|
||||
"hours",
|
||||
"query",
|
||||
"int",
|
||||
False,
|
||||
"24",
|
||||
"Lookback window in hours (1-168).",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"owner_id": "USER_UID",
|
||||
"window_hours": 24,
|
||||
"requests": 42,
|
||||
"success": 41,
|
||||
"failed": 1,
|
||||
"success_pct": 97.6,
|
||||
"error_pct": 2.4,
|
||||
"tokens": {"prompt": 120000, "completion": 38000, "total": 158000},
|
||||
"cost": {
|
||||
"window_usd": 0.214,
|
||||
"per_hour_usd": 0.0089,
|
||||
"per_request_usd": 0.0051,
|
||||
"projected_30d_usd": 6.42,
|
||||
},
|
||||
"latency": {"avg_ms": 1830.0, "avg_tps": 41.2},
|
||||
"by_model": [
|
||||
{
|
||||
"key": "molodetz",
|
||||
"requests": 42,
|
||||
"total_tokens": 158000,
|
||||
"cost_usd": 0.214,
|
||||
}
|
||||
],
|
||||
"hourly": [
|
||||
{
|
||||
"hour": "2026-06-08T16",
|
||||
"requests": 6,
|
||||
"cost_usd": 0.031,
|
||||
"total_tokens": 22000,
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-user-role",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/role",
|
||||
title="Set a user role",
|
||||
summary="Promote or demote a user. You cannot change your own role.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "USER_UID", "Target user UID."
|
||||
),
|
||||
field(
|
||||
"role",
|
||||
"form",
|
||||
"enum",
|
||||
True,
|
||||
"member",
|
||||
"New role.",
|
||||
["member", "admin"],
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-user-password",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/password",
|
||||
title="Reset a user password",
|
||||
summary="Set a new password for a user.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "USER_UID", "Target user UID."
|
||||
),
|
||||
field(
|
||||
"password",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"newpassword",
|
||||
"New password, 6+ characters.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-user-toggle",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/toggle",
|
||||
title="Enable or disable a user",
|
||||
summary="Toggle a user's active state. You cannot disable yourself.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "USER_UID", "Target user UID.")
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-news-list",
|
||||
method="GET",
|
||||
path="/admin/news",
|
||||
title="List news articles",
|
||||
summary="Paginated news management page. Returns HTML.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[field("page", "query", "int", False, "1", "Page number.")],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-news-toggle",
|
||||
method="POST",
|
||||
path="/admin/news/{uid}/toggle",
|
||||
title="Toggle featured",
|
||||
summary="Toggle an article's featured flag and lock it from the news service's auto-rotation.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-news-publish",
|
||||
method="POST",
|
||||
path="/admin/news/{uid}/publish",
|
||||
title="Toggle published",
|
||||
summary="Switch an article between draft and published.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-news-landing",
|
||||
method="POST",
|
||||
path="/admin/news/{uid}/landing",
|
||||
title="Toggle landing",
|
||||
summary="Toggle whether an article shows on the landing page and lock it from the news service's auto-rotation.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-news-delete",
|
||||
method="POST",
|
||||
path="/admin/news/{uid}/delete",
|
||||
title="Delete a news article",
|
||||
summary="Delete an article and its images.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-settings-get",
|
||||
method="GET",
|
||||
path="/admin/settings",
|
||||
title="Read site settings",
|
||||
summary="Return the current site and operational settings. Negotiates HTML or JSON.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-settings",
|
||||
method="POST",
|
||||
path="/admin/settings",
|
||||
title="Save site settings",
|
||||
summary="Update operational and site settings. Empty fields are skipped.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"site_name", "form", "string", False, "DevPlace", "Site name."
|
||||
),
|
||||
field(
|
||||
"rate_limit_per_minute",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"60",
|
||||
"Requests per window.",
|
||||
),
|
||||
field(
|
||||
"registration_open",
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
"1",
|
||||
"Allow signups.",
|
||||
["1", "0"],
|
||||
),
|
||||
field(
|
||||
"maintenance_mode",
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
"0",
|
||||
"Maintenance gate.",
|
||||
["1", "0"],
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"Accepts every field on the admin settings form; only non-empty values are written."
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-notification-default",
|
||||
method="POST",
|
||||
path="/admin/notifications",
|
||||
title="Set a notification default",
|
||||
summary="Set the platform-wide default for one notification type on one channel. Applies to users who have not customized that notification; explicit user choices are unaffected.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"notification_type",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"push",
|
||||
"One of in_app, push or telegram (telegram is off by default and requires a paired Telegram account).",
|
||||
),
|
||||
field(
|
||||
"value",
|
||||
"form",
|
||||
"boolean",
|
||||
False,
|
||||
"1",
|
||||
"1 to enable this notification by default, 0 to disable.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/admin/notifications",
|
||||
"data": {
|
||||
"notification_type": "vote",
|
||||
"channel": "push",
|
||||
"value": False,
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-user-reset-ai-quota",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/reset-ai-quota",
|
||||
title="Reset user AI quota",
|
||||
summary="Delete a specific user's AI gateway ledger rows, resetting their quota.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "USER_UID", "Target user UID."
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-ai-quota-reset-guests",
|
||||
method="POST",
|
||||
path="/admin/ai-quota/reset-guests",
|
||||
title="Reset guest AI quotas",
|
||||
summary="Reset AI quota for all anonymous guest users.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-ai-quota-reset-all",
|
||||
method="POST",
|
||||
path="/admin/ai-quota/reset-all",
|
||||
title="Reset all AI quotas",
|
||||
summary="Reset AI quota for every user (members and guests).",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-bots-monitor",
|
||||
method="GET",
|
||||
path="/admin/bots",
|
||||
title="Bot monitor page",
|
||||
summary="Live low-quality screenshot grid of every running bot persona, one frame per bot.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-bots-data",
|
||||
method="GET",
|
||||
path="/admin/bots/data",
|
||||
title="Bot monitor data",
|
||||
summary="JSON of every running bot slot with its label, persona, current action/status, and latest frame URL for polling.",
|
||||
auth="admin",
|
||||
sample_response={
|
||||
"enabled": True,
|
||||
"service_status": "running",
|
||||
"frames": [
|
||||
{
|
||||
"slot": 0,
|
||||
"username": "bytewren",
|
||||
"persona": "grumpy_senior",
|
||||
"action": "POST: rant [3]",
|
||||
"status": "page load",
|
||||
"url": "https://example.com/feed",
|
||||
"label": "bytewren",
|
||||
"captured_at": 1718366400,
|
||||
"age_seconds": 2,
|
||||
"has_image": True,
|
||||
"active": True,
|
||||
"frame_url": "/admin/bots/0/frame.jpg?t=1718366400",
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-bots-frame",
|
||||
method="GET",
|
||||
path="/admin/bots/{slot}/frame.jpg",
|
||||
title="Bot frame image",
|
||||
summary="The latest low-quality JPEG screenshot for one bot slot (no-store).",
|
||||
auth="admin",
|
||||
encoding="none",
|
||||
interactive=False,
|
||||
params=[
|
||||
field(
|
||||
"slot",
|
||||
"path",
|
||||
"integer",
|
||||
True,
|
||||
"0",
|
||||
"Bot fleet slot index.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups",
|
||||
method="GET",
|
||||
path="/admin/backups",
|
||||
title="Backups dashboard",
|
||||
summary=(
|
||||
"Storage usage, every backup archive, and every backup schedule. Returns HTML "
|
||||
"(or JSON with Accept: application/json)."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups-data",
|
||||
method="GET",
|
||||
path="/admin/backups/data",
|
||||
title="Backups data",
|
||||
summary=(
|
||||
"JSON dashboard payload: per-path storage usage, total data and backup size, "
|
||||
"disk usage, the backup list, and the schedule list."
|
||||
),
|
||||
auth="admin",
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups-run",
|
||||
method="POST",
|
||||
path="/admin/backups/run",
|
||||
title="Create backup",
|
||||
summary=(
|
||||
"Enqueue an async backup job for a target (database, uploads, keys, full). "
|
||||
"Returns the job uid and a status_url."
|
||||
),
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"target",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"full",
|
||||
"One of database, uploads, keys, full.",
|
||||
)
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"uid": "JOB_UID",
|
||||
"backup_uid": "BACKUP_UID",
|
||||
"status_url": "/admin/backups/jobs/JOB_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups-job",
|
||||
method="GET",
|
||||
path="/admin/backups/jobs/{uid}",
|
||||
title="Backup job status",
|
||||
summary="Status of one backup job (pending, running, done, failed) with archive stats.",
|
||||
auth="admin",
|
||||
params=[field("uid", "path", "string", True, "", "Backup job uid.")],
|
||||
sample_response={
|
||||
"uid": "JOB_UID",
|
||||
"kind": "backup",
|
||||
"status": "done",
|
||||
"target": "full",
|
||||
"backup_uid": "BACKUP_UID",
|
||||
"download_url": "/admin/backups/BACKUP_UID/download",
|
||||
"bytes_out": 10485760,
|
||||
"file_count": 1240,
|
||||
"sha256": "…",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups-download",
|
||||
method="GET",
|
||||
path="/admin/backups/{uid}/download",
|
||||
title="Download backup",
|
||||
summary="Stream a completed backup archive as a tar.gz file. Restricted to the primary administrator (the first user created with the Admin role); every other administrator receives 403 Forbidden.",
|
||||
auth="admin",
|
||||
encoding="none",
|
||||
params=[field("uid", "path", "string", True, "", "Backup uid.")],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups-delete",
|
||||
method="POST",
|
||||
path="/admin/backups/{uid}/delete",
|
||||
title="Delete backup",
|
||||
summary="Permanently delete a backup archive and reclaim its disk space.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[field("uid", "path", "string", True, "", "Backup uid.")],
|
||||
sample_response={"ok": True, "redirect": "/admin/backups"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups-schedule-create",
|
||||
method="POST",
|
||||
path="/admin/backups/schedules/create",
|
||||
title="Create backup schedule",
|
||||
summary=(
|
||||
"Create a recurring backup. kind=interval uses every_seconds; kind=cron uses a "
|
||||
"5-field cron expression. keep_last rotates older backups of the schedule."
|
||||
),
|
||||
auth="admin",
|
||||
params=[
|
||||
field("name", "form", "string", True, "Nightly full", "Schedule name."),
|
||||
field("target", "form", "string", True, "full", "Backup target."),
|
||||
field("kind", "form", "string", True, "interval", "interval or cron."),
|
||||
field("every_seconds", "form", "int", False, "86400", "Seconds between runs (kind=interval)."),
|
||||
field("cron", "form", "string", False, "0 3 * * *", "Cron expression (kind=cron)."),
|
||||
field("keep_last", "form", "int", False, "7", "Keep only the newest N (0 = all)."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/backups"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups-schedule-edit",
|
||||
method="POST",
|
||||
path="/admin/backups/schedules/{uid}/edit",
|
||||
title="Edit backup schedule",
|
||||
summary="Update a backup schedule's target, trigger, and retention.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "", "Schedule uid."),
|
||||
field("name", "form", "string", True, "Nightly full", "Schedule name."),
|
||||
field("target", "form", "string", True, "full", "Backup target."),
|
||||
field("kind", "form", "string", True, "interval", "interval or cron."),
|
||||
field("every_seconds", "form", "int", False, "86400", "Seconds between runs (kind=interval)."),
|
||||
field("cron", "form", "string", False, "0 3 * * *", "Cron expression (kind=cron)."),
|
||||
field("keep_last", "form", "int", False, "7", "Keep only the newest N (0 = all)."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/backups"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups-schedule-toggle",
|
||||
method="POST",
|
||||
path="/admin/backups/schedules/{uid}/toggle",
|
||||
title="Toggle backup schedule",
|
||||
summary="Enable or disable a backup schedule.",
|
||||
auth="admin",
|
||||
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
|
||||
sample_response={"ok": True, "redirect": "/admin/backups"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups-schedule-run",
|
||||
method="POST",
|
||||
path="/admin/backups/schedules/{uid}/run",
|
||||
title="Run backup schedule now",
|
||||
summary="Immediately enqueue a backup for a schedule without waiting for its next run.",
|
||||
auth="admin",
|
||||
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
|
||||
sample_response={"ok": True, "redirect": "/admin/backups"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-backups-schedule-delete",
|
||||
method="POST",
|
||||
path="/admin/backups/schedules/{uid}/delete",
|
||||
title="Delete backup schedule",
|
||||
summary="Delete a backup schedule. Existing archives are kept.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
|
||||
sample_response={"ok": True, "redirect": "/admin/backups"},
|
||||
),
|
||||
],
|
||||
}
|
||||
137
devplacepy/docs_api/groups/auth.py
Normal file
137
devplacepy/docs_api/groups/auth.py
Normal file
@ -0,0 +1,137 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "auth",
|
||||
"title": "Authentication",
|
||||
"intro": """
|
||||
# Authentication
|
||||
|
||||
Create an account, sign in, recover your password, and log out. These are the only endpoints
|
||||
that set or clear the `session` cookie; every other request authenticates with the methods
|
||||
described in [Authentication](/docs/authentication.html). The shared rules (content
|
||||
negotiation, pagination, status codes) live in [Conventions and Errors](/docs/conventions.html).
|
||||
|
||||
## Page vs. action
|
||||
|
||||
The GET endpoints render HTML sign-up, login, and password-reset forms; they also return the
|
||||
page data as JSON when requested with `Accept: application/json` (including `page` to
|
||||
distinguish the form type).
|
||||
|
||||
The POST endpoints are **actions**: they accept form fields, set or clear the `session` cookie,
|
||||
and return a `302` redirect (or the JSON envelope for JSON callers).
|
||||
|
||||
**Sign-up requires a valid `g-recaptcha-response`** when reCAPTCHA is enabled. Use the JSON
|
||||
envelope to see validation errors as `{ "error": "validation", "fields": {...} }`.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="auth-signup",
|
||||
method="GET",
|
||||
path="/auth/signup",
|
||||
title="Sign up page",
|
||||
summary="Render the registration form. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="auth-signup-post",
|
||||
method="POST",
|
||||
path="/auth/signup",
|
||||
title="Sign up",
|
||||
summary="Create a new account. Sets the session cookie on success.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
destructive=False,
|
||||
params=[
|
||||
field("username", "form", "string", True, "alice", "Username, 3-20 characters."),
|
||||
field("password", "form", "string", True, "mysecret", "Password, 6+ characters."),
|
||||
field("confirm_password", "form", "string", True, "mysecret", "Must match password."),
|
||||
field("g-recaptcha-response", "form", "string", False, "", "reCAPTCHA token when enabled."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="auth-login",
|
||||
method="GET",
|
||||
path="/auth/login",
|
||||
title="Log in page",
|
||||
summary="Render the login form. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("next", "query", "string", False, "", "Redirect target after login."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="auth-login-post",
|
||||
method="POST",
|
||||
path="/auth/login",
|
||||
title="Log in",
|
||||
summary="Authenticate with username and password. Sets the session cookie.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("username", "form", "string", True, "alice", "Your username."),
|
||||
field("password", "form", "string", True, "mysecret", "Your password."),
|
||||
field("next", "form", "string", False, "", "Redirect target after login."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="auth-forgot-password",
|
||||
method="GET",
|
||||
path="/auth/forgot-password",
|
||||
title="Forgot password page",
|
||||
summary="Render the forgot-password form. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="auth-forgot-password-post",
|
||||
method="POST",
|
||||
path="/auth/forgot-password",
|
||||
title="Request password reset",
|
||||
summary="Send a password-reset email with a one-time link.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("email", "form", "string", True, "alice@example.com", "Your registered email."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="auth-reset-password",
|
||||
method="GET",
|
||||
path="/auth/reset-password/{token}",
|
||||
title="Reset password page",
|
||||
summary="Render the password-reset form (only valid with a one-time token). Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("token", "path", "string", True, "RESET_TOKEN", "The one-time reset token from the email."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="auth-reset-password-post",
|
||||
method="POST",
|
||||
path="/auth/reset-password/{token}",
|
||||
title="Reset password",
|
||||
summary="Set a new password using a one-time reset token.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("token", "path", "string", True, "RESET_TOKEN", "The one-time reset token from the email."),
|
||||
field("password", "form", "string", True, "newpass", "New password, 6+ characters."),
|
||||
field("confirm_password", "form", "string", True, "newpass", "Must match password."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="auth-logout",
|
||||
method="GET",
|
||||
path="/auth/logout",
|
||||
title="Log out",
|
||||
summary="Clear the session cookie and redirect to the landing page.",
|
||||
auth="public",
|
||||
interactive=False,
|
||||
),
|
||||
],
|
||||
}
|
||||
579
devplacepy/docs_api/groups/containers.py
Normal file
579
devplacepy/docs_api/groups/containers.py
Normal file
@ -0,0 +1,579 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "containers",
|
||||
"title": "Container Manager",
|
||||
"admin": True,
|
||||
"intro": """
|
||||
# Container Manager
|
||||
|
||||
Build versioned Docker images for a project and run supervised container instances. Every endpoint is
|
||||
**administrator only** (running arbitrary Dockerfiles with docker socket access is root-equivalent).
|
||||
Mutations flip desired state; a single reconciler converges containers to it.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="containers-page",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers",
|
||||
title="Container manager page",
|
||||
summary="The admin per-project container manager UI (instance creation and lifecycle). Returns 404 for an administrator who is not the owner of an administrator-hidden project.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-index",
|
||||
method="GET",
|
||||
path="/admin/containers",
|
||||
title="Admin containers list",
|
||||
summary="The admin Containers section: every instance across all projects, each linking to its detail page. Instances attached to another administrator's hidden project are excluded, and per-instance actions return 404 for a non-owner administrator.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-data",
|
||||
method="GET",
|
||||
path="/admin/containers/data",
|
||||
title="Admin containers list data",
|
||||
summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
|
||||
auth="admin",
|
||||
sample_response={
|
||||
"instances": [
|
||||
{
|
||||
"uid": "INSTANCE_UID",
|
||||
"name": "staging",
|
||||
"status": "running",
|
||||
"project_slug": "PROJECT_SLUG",
|
||||
"project_title": "My Project",
|
||||
"ingress_slug": "my-service",
|
||||
"restart_policy": "always",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-instance",
|
||||
method="GET",
|
||||
path="/admin/containers/{uid}",
|
||||
title="Instance detail page",
|
||||
summary="The dedicated detail page for one instance (lifecycle, logs, metrics, terminal, schedules, ingress, sync).",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-edit-page",
|
||||
method="GET",
|
||||
path="/admin/containers/{uid}/edit",
|
||||
title="Edit instance page",
|
||||
summary="The edit page for one instance (run-as user, boot language/script/command, restart policy, start-on-boot, limits).",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-create-instance",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances",
|
||||
title="Create an instance",
|
||||
summary="Create and (by default) start an instance; it runs the shared ppy image with the project workspace mounted at /app.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field("name", "form", "string", True, "staging", "Instance name."),
|
||||
field(
|
||||
"boot_command",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"python app.py",
|
||||
"Optional boot command.",
|
||||
),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field(
|
||||
"env",
|
||||
"form",
|
||||
"textarea",
|
||||
False,
|
||||
"KEY=VALUE",
|
||||
"Env vars, one KEY=VALUE per line.",
|
||||
),
|
||||
field(
|
||||
"ports",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"80",
|
||||
"Port maps. Bare container port auto-assigns a unique host port above 20000; host:container pins one.",
|
||||
),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field(
|
||||
"mem_limit", "form", "string", False, "512m", "Memory limit."
|
||||
),
|
||||
field(
|
||||
"restart_policy",
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
"never",
|
||||
"Restart policy.",
|
||||
["never", "always", "on-failure", "unless-stopped"],
|
||||
),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
|
||||
field(
|
||||
"ingress_slug",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"my-service",
|
||||
"Publish at /p/<slug> (optional).",
|
||||
),
|
||||
field(
|
||||
"ingress_port",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"8899",
|
||||
"Container port to publish (must be a mapped port).",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-ingress",
|
||||
method="GET",
|
||||
path="/p/{slug}",
|
||||
title="Container ingress proxy",
|
||||
summary="Public reverse proxy (HTTP and WebSocket) to a running instance published via ingress_slug. The /p/<slug> prefix is stripped before forwarding.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"my-service",
|
||||
"The instance's ingress_slug.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-action",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/{action}",
|
||||
title="Instance lifecycle",
|
||||
summary="start, stop, restart, pause, or resume an instance (flips desired state).",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"action",
|
||||
"path",
|
||||
"enum",
|
||||
True,
|
||||
"start",
|
||||
"Lifecycle action.",
|
||||
["start", "stop", "restart", "pause", "resume"],
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-logs",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/logs",
|
||||
title="Instance logs",
|
||||
summary="Recent docker logs of a running instance.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field("tail", "query", "integer", False, "200", "Number of lines."),
|
||||
],
|
||||
sample_response={"logs": "..."},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-sync",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/sync",
|
||||
title="Sync workspace",
|
||||
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"exported": 3, "imported": 1},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-delete",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/delete",
|
||||
title="Delete instance",
|
||||
summary="Remove a container instance and mark its container for removal.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-exec",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/exec",
|
||||
title="Exec a command",
|
||||
summary="Run a one-shot command inside a running instance and return its output.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"command",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"ls -la /app",
|
||||
"Shell command to run (via /bin/sh -c).",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-data",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}",
|
||||
title="Instance detail data",
|
||||
summary="Return the full instance row plus runtime info as JSON.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"uid": "INSTANCE_UID", "name": "staging", "status": "running"},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-metrics",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/metrics",
|
||||
title="Instance metrics",
|
||||
summary="Return recent metrics ring-buffer and aggregated stats for a running instance.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"metrics": [], "stats": {}},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-schedules",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/schedules",
|
||||
title="Create a schedule",
|
||||
summary="Attach a cron, one-time, interval, or delay schedule to an instance.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"action",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"start",
|
||||
"Lifecycle action to run on schedule (start, stop, restart).",
|
||||
),
|
||||
field(
|
||||
"kind",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"cron",
|
||||
"Schedule kind: cron, once, interval, or delay.",
|
||||
),
|
||||
field(
|
||||
"cron",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"0 * * * *",
|
||||
"Cron expression (when kind is cron).",
|
||||
),
|
||||
field(
|
||||
"run_at",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"2026-01-01T00:00:00",
|
||||
"ISO timestamp for a one-time run (when kind is once).",
|
||||
),
|
||||
field(
|
||||
"delay_seconds",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"60",
|
||||
"Seconds to wait before a single run (when kind is delay).",
|
||||
),
|
||||
field(
|
||||
"every_seconds",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"300",
|
||||
"Interval in seconds between runs (when kind is interval).",
|
||||
),
|
||||
field(
|
||||
"max_runs",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"10",
|
||||
"Optional cap on the number of runs.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-schedule-delete",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/schedules/{sid}/delete",
|
||||
title="Delete a schedule",
|
||||
summary="Remove a schedule from an instance.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"sid", "path", "string", True, "SCHEDULE_UID", "Schedule uid."
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-create",
|
||||
method="POST",
|
||||
path="/admin/containers/create",
|
||||
title="Admin create instance",
|
||||
summary="Create an instance from the admin Containers page: project search-select, run-as user, boot language/script, restart policy, start-on-boot, plus the usual options.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("project_slug", "form", "string", True, "PROJECT_SLUG", "Project that becomes the /app root."),
|
||||
field("name", "form", "string", True, "staging", "Instance name."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
|
||||
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
|
||||
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
|
||||
field("ports", "form", "string", False, "80", "Port maps; bare container port auto-assigns a host port above 20000."),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
|
||||
field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
|
||||
field("ingress_port", "form", "integer", False, "8899", "Container port to publish."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-edit",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/edit",
|
||||
title="Admin edit instance",
|
||||
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "Run-as user uid (identity + API key only)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code."),
|
||||
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command."),
|
||||
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running on container-service boot."),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-action",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/{action}",
|
||||
title="Admin instance lifecycle",
|
||||
summary="start, stop, restart, pause, or resume an instance from the admin Containers page (flips desired state).",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
field("action", "path", "enum", True, "start", "Lifecycle action.", ["start", "stop", "restart", "pause", "resume"]),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-sync",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/sync",
|
||||
title="Admin bidirectional sync",
|
||||
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
],
|
||||
sample_response={"exported": 3, "imported": 1},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-delete",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/delete",
|
||||
title="Admin delete instance",
|
||||
summary="Soft-delete an instance and mark its container for removal.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-project-search",
|
||||
method="GET",
|
||||
path="/admin/containers/projects/search",
|
||||
title="Admin project search",
|
||||
summary="Search projects by title for the admin create form (returns uid, slug, title).",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("q", "query", "string", False, "api", "Title fragment."),
|
||||
],
|
||||
sample_response={"results": [{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "title": "My Project"}]},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-user-search",
|
||||
method="GET",
|
||||
path="/admin/containers/users/search",
|
||||
title="Admin run-as user search",
|
||||
summary="Search users by username for the run-as-user select (returns uid, username).",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("q", "query", "string", False, "alice", "Username fragment."),
|
||||
],
|
||||
sample_response={"results": [{"uid": "USER_UID", "username": "alice"}]},
|
||||
),
|
||||
],
|
||||
}
|
||||
946
devplacepy/docs_api/groups/content.py
Normal file
946
devplacepy/docs_api/groups/content.py
Normal file
@ -0,0 +1,946 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import COMMENT_TARGETS, GIST_LANGUAGES, PROJECT_TYPES, endpoint, field
|
||||
from devplacepy.constants import TOPICS
|
||||
|
||||
GROUP = {
|
||||
"slug": "content",
|
||||
"title": "Posts, Comments, Projects, Gists & News",
|
||||
"intro": """
|
||||
# Posts, Comments, Projects, Gists & News
|
||||
|
||||
The core content types. Read endpoints render HTML pages; write endpoints accept form fields
|
||||
and redirect to the new or updated resource. List fields such as `attachment_uids` are
|
||||
repeated form keys - upload files first via [Uploads](/docs/uploads.html) and pass the returned
|
||||
uids here. Engage with this content through [Votes, Reactions, Bookmarks & Polls](/docs/social-actions.html).
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
four ways to sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="home",
|
||||
method="GET",
|
||||
path="/",
|
||||
title="Home",
|
||||
summary=(
|
||||
"The home page. Guests see the marketing splash; authenticated users see a "
|
||||
"personalized home (welcome, feed shortcut, latest posts, news). It no longer "
|
||||
"redirects to /feed. The latest-posts section interleaves authors so no two consecutive posts share an author."
|
||||
),
|
||||
auth="public",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="feed-list",
|
||||
method="GET",
|
||||
path="/feed",
|
||||
title="Browse the feed",
|
||||
summary="The main post feed. Returns an HTML page. Each page interleaves authors so no two consecutive posts share an author.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"tab",
|
||||
"query",
|
||||
"enum",
|
||||
False,
|
||||
"all",
|
||||
"Feed selector.",
|
||||
["all", "trending", "following"],
|
||||
),
|
||||
field(
|
||||
"topic", "query", "enum", False, "", "Filter by topic.", TOPICS
|
||||
),
|
||||
field(
|
||||
"search",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Search post title, content, and author username.",
|
||||
),
|
||||
field("before", "query", "string", False, "", "Pagination cursor."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="posts-create",
|
||||
method="POST",
|
||||
path="/posts/create",
|
||||
title="Create a post",
|
||||
summary="Publish a post, optionally with a poll. Redirects to the new post.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"content",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"Posted from a script.",
|
||||
"Body, 10-125000 characters.",
|
||||
),
|
||||
field(
|
||||
"title",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"Hello",
|
||||
"Optional title, up to 500 characters.",
|
||||
),
|
||||
field(
|
||||
"topic", "form", "enum", False, "random", "Post topic.", TOPICS
|
||||
),
|
||||
field(
|
||||
"project_uid",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Attach to a project.",
|
||||
),
|
||||
field(
|
||||
"poll_question",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Optional poll question.",
|
||||
),
|
||||
field(
|
||||
"poll_options",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options).",
|
||||
),
|
||||
],
|
||||
notes=["Returns a `302` redirect to `/posts/{slug}` on success."],
|
||||
),
|
||||
endpoint(
|
||||
id="posts-detail",
|
||||
method="GET",
|
||||
path="/posts/{post_slug}",
|
||||
title="View a post",
|
||||
summary="Render a post with comments. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"post_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"POST_SLUG",
|
||||
"Slug or UID of the post.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="posts-edit",
|
||||
method="POST",
|
||||
path="/posts/edit/{post_slug}",
|
||||
title="Edit a post",
|
||||
summary="Update a post you own, optionally adding a poll if it has none.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"post_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"POST_SLUG",
|
||||
"Slug or UID of the post.",
|
||||
),
|
||||
field(
|
||||
"content",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"Updated body.",
|
||||
"Body, 10-125000 characters.",
|
||||
),
|
||||
field(
|
||||
"title",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"Updated title",
|
||||
"Optional title.",
|
||||
),
|
||||
field(
|
||||
"topic", "form", "enum", False, "random", "Post topic.", TOPICS
|
||||
),
|
||||
field(
|
||||
"poll_question",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Optional poll question. Adds a poll only when the post has none.",
|
||||
),
|
||||
field(
|
||||
"poll_options",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options).",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="posts-delete",
|
||||
method="POST",
|
||||
path="/posts/delete/{post_slug}",
|
||||
title="Delete a post",
|
||||
summary="Delete a post you own; administrators may delete any user's post. Soft-deleted (hidden everywhere but restorable from admin trash) and cascades its comments and votes.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"post_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"POST_SLUG",
|
||||
"Slug or UID of the post.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="comments-create",
|
||||
method="POST",
|
||||
path="/comments/create",
|
||||
title="Create a comment",
|
||||
summary="Comment on any commentable target. Supports nested replies.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"content",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"Nice work.",
|
||||
"Body, 3-1000 characters.",
|
||||
),
|
||||
field(
|
||||
"target_uid",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"POST_UID",
|
||||
"UID of the target (or use post_uid).",
|
||||
),
|
||||
field(
|
||||
"post_uid",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Convenience alias for a post target.",
|
||||
),
|
||||
field(
|
||||
"target_type",
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
"post",
|
||||
"Type of the target.",
|
||||
COMMENT_TARGETS,
|
||||
),
|
||||
field(
|
||||
"parent_uid",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Parent comment UID for a reply.",
|
||||
),
|
||||
],
|
||||
notes=["Either `target_uid` or `post_uid` is required."],
|
||||
),
|
||||
endpoint(
|
||||
id="comments-edit",
|
||||
method="POST",
|
||||
path="/comments/edit/{comment_uid}",
|
||||
title="Edit a comment",
|
||||
summary="Edit the body of a comment you own. Returns the updated comment.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field(
|
||||
"comment_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"COMMENT_UID",
|
||||
"UID of the comment.",
|
||||
),
|
||||
field(
|
||||
"content",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"Edited body.",
|
||||
"New body, 3-1000 characters.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "COMMENT_UID",
|
||||
"content": "Edited body.",
|
||||
"url": "/posts/POST_SLUG#comment-COMMENT_UID",
|
||||
"updated_at": "2026-06-15T12:00:00+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="comments-delete",
|
||||
method="POST",
|
||||
path="/comments/delete/{comment_uid}",
|
||||
title="Delete a comment",
|
||||
summary="Delete a comment you own; administrators may delete any user's comment. Soft-deleted (hidden everywhere but restorable from admin trash).",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"comment_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"COMMENT_UID",
|
||||
"UID of the comment.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="projects-list",
|
||||
method="GET",
|
||||
path="/projects",
|
||||
title="Browse projects",
|
||||
summary="List projects. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"tab",
|
||||
"query",
|
||||
"enum",
|
||||
False,
|
||||
"recent",
|
||||
"Sort selector.",
|
||||
["recent", "popular", "released"],
|
||||
),
|
||||
field(
|
||||
"search",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Search project title, description, and author username.",
|
||||
),
|
||||
field(
|
||||
"project_type",
|
||||
"query",
|
||||
"enum",
|
||||
False,
|
||||
"",
|
||||
"Filter by type.",
|
||||
PROJECT_TYPES,
|
||||
),
|
||||
field("before", "query", "string", False, "", "Pagination cursor."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="projects-detail",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}",
|
||||
title="View a project",
|
||||
summary="Render a project with comments. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Slug or UID of the project.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="projects-create",
|
||||
method="POST",
|
||||
path="/projects/create",
|
||||
title="Create a project",
|
||||
summary="Publish a project. Redirects to the new project.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"title",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"My Project",
|
||||
"Title, 1-200 characters.",
|
||||
),
|
||||
field(
|
||||
"description",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"What it does.",
|
||||
"Description, 1-5000 characters.",
|
||||
),
|
||||
field(
|
||||
"project_type",
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
"software",
|
||||
"Project type.",
|
||||
PROJECT_TYPES,
|
||||
),
|
||||
field(
|
||||
"status",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"In Development",
|
||||
"Free-form status label.",
|
||||
),
|
||||
field(
|
||||
"platforms",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"Linux, Web",
|
||||
"Comma-separated platforms.",
|
||||
),
|
||||
field(
|
||||
"release_date",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"31/12/2026",
|
||||
"Optional release date in DD/MM/YYYY format.",
|
||||
),
|
||||
field(
|
||||
"demo_date",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"31/12/2026",
|
||||
"Optional demo date in DD/MM/YYYY format.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="projects-edit",
|
||||
method="POST",
|
||||
path="/projects/edit/{project_slug}",
|
||||
title="Edit a project",
|
||||
summary="Update an owned project. Redirects to the project.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"my-project-1a2b3c4d",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"title",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"My Project",
|
||||
"Title, 1-200 characters.",
|
||||
),
|
||||
field(
|
||||
"description",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"What it does.",
|
||||
"Description, 1-5000 characters.",
|
||||
),
|
||||
field(
|
||||
"project_type",
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
"software",
|
||||
"Project type.",
|
||||
PROJECT_TYPES,
|
||||
),
|
||||
field(
|
||||
"status",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"In Development",
|
||||
"Free-form status label.",
|
||||
),
|
||||
field(
|
||||
"platforms",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"Linux, Web",
|
||||
"Comma-separated platforms.",
|
||||
),
|
||||
field(
|
||||
"release_date",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"31/12/2026",
|
||||
"Optional release date in DD/MM/YYYY format.",
|
||||
),
|
||||
field(
|
||||
"demo_date",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"31/12/2026",
|
||||
"Optional demo date in DD/MM/YYYY format.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="projects-delete",
|
||||
method="POST",
|
||||
path="/projects/delete/{project_slug}",
|
||||
title="Delete a project",
|
||||
summary="Delete a project you own; administrators may delete any user's project. Soft-deleted with all of its files (hidden everywhere but restorable from admin trash).",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Slug or UID of the project.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="projects-private",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/private",
|
||||
title="Set project visibility",
|
||||
summary="Mark a project you own private or public. Send value=1 for private, value=0 for public. A project you hide as a member stays visible to administrators; a project you hide as an administrator is visible only to you, not to other administrators (this also hides its files and any attached containers).",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Slug or UID of the project.",
|
||||
),
|
||||
field(
|
||||
"value",
|
||||
"form",
|
||||
"boolean",
|
||||
True,
|
||||
"1",
|
||||
"1 to make the project private, 0 to make it public.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="projects-readonly",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/readonly",
|
||||
title="Set project read-only",
|
||||
summary="Mark a project you own read-only so all of its files become immutable (no writes, edits, moves, deletes, or uploads succeed), or writable again. Send value=1 for read-only, value=0 for writable.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Slug or UID of the project.",
|
||||
),
|
||||
field(
|
||||
"value",
|
||||
"form",
|
||||
"boolean",
|
||||
True,
|
||||
"1",
|
||||
"1 to make the project read-only, 0 to make it writable.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="projects-zip",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/zip",
|
||||
title="Queue a project zip",
|
||||
summary="Start a background job that archives the whole project. Returns the job uid and status URL to poll.",
|
||||
auth="public",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Slug or UID of the project.",
|
||||
)
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ZIP_JOB_UID",
|
||||
"status_url": "/zips/ZIP_JOB_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="zips-status",
|
||||
method="GET",
|
||||
path="/zips/{uid}",
|
||||
title="Zip job status",
|
||||
summary="Poll a zip job. While pending or running download_url is null; once done it points at the archive.",
|
||||
auth="public",
|
||||
params=[
|
||||
field(
|
||||
"uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"ZIP_JOB_UID",
|
||||
"Zip job uid returned when the job was queued.",
|
||||
)
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ZIP_JOB_UID",
|
||||
"kind": "zip",
|
||||
"status": "done",
|
||||
"preferred_name": "my-project",
|
||||
"download_url": "/zips/ZIP_JOB_UID/download",
|
||||
"error": None,
|
||||
"bytes_in": 20480,
|
||||
"bytes_out": 8192,
|
||||
"item_count": 12,
|
||||
"file_count": 10,
|
||||
"dir_count": 2,
|
||||
"created_at": "2026-06-09T10:00:00+00:00",
|
||||
"completed_at": "2026-06-09T10:00:03+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="zips-download",
|
||||
method="GET",
|
||||
path="/zips/{uid}/download",
|
||||
title="Download a zip archive",
|
||||
summary="Stream the finished archive as application/zip. Each access extends the retention window.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"ZIP_JOB_UID",
|
||||
"Zip job uid of a finished job.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="projects-fork",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/fork",
|
||||
title="Queue a project fork",
|
||||
summary="Start a background job that copies the whole project into a new project owned by you. Returns the job uid and status URL to poll.",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Slug or UID of the project to fork.",
|
||||
),
|
||||
field(
|
||||
"title",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"My Fork",
|
||||
"Title for the new forked project.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "FORK_JOB_UID",
|
||||
"status_url": "/forks/FORK_JOB_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="forks-status",
|
||||
method="GET",
|
||||
path="/forks/{uid}",
|
||||
title="Fork job status",
|
||||
summary="Poll a fork job. While pending or running project_url is null; once done it points at the new project.",
|
||||
auth="public",
|
||||
params=[
|
||||
field(
|
||||
"uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"FORK_JOB_UID",
|
||||
"Fork job uid returned when the job was queued.",
|
||||
)
|
||||
],
|
||||
sample_response={
|
||||
"uid": "FORK_JOB_UID",
|
||||
"kind": "fork",
|
||||
"status": "done",
|
||||
"preferred_name": "My Fork",
|
||||
"project_uid": "NEW_PROJECT_UID",
|
||||
"project_url": "/projects/new-project-slug",
|
||||
"source_project_uid": "SOURCE_PROJECT_UID",
|
||||
"error": None,
|
||||
"item_count": 12,
|
||||
"created_at": "2026-06-09T10:00:00+00:00",
|
||||
"completed_at": "2026-06-09T10:00:05+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="gists-list",
|
||||
method="GET",
|
||||
path="/gists",
|
||||
title="Browse gists",
|
||||
summary="List code gists. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"language",
|
||||
"query",
|
||||
"enum",
|
||||
False,
|
||||
"",
|
||||
"Filter by language.",
|
||||
GIST_LANGUAGES,
|
||||
),
|
||||
field(
|
||||
"user_uid",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Filter by author UID.",
|
||||
),
|
||||
field(
|
||||
"search",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Search gist title, description, and author username.",
|
||||
),
|
||||
field("before", "query", "string", False, "", "Pagination cursor."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="gists-detail",
|
||||
method="GET",
|
||||
path="/gists/{gist_slug}",
|
||||
title="View a gist",
|
||||
summary="Render a gist with comments. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"gist_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"GIST_SLUG",
|
||||
"Slug or UID of the gist.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="gists-create",
|
||||
method="POST",
|
||||
path="/gists/create",
|
||||
title="Create a gist",
|
||||
summary="Publish a code snippet. Redirects to the new gist.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"title",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"Quick sort",
|
||||
"Title, 1-200 characters.",
|
||||
),
|
||||
field(
|
||||
"source_code",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"print('hello')",
|
||||
"Source, 1-400000 characters.",
|
||||
),
|
||||
field(
|
||||
"language",
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
"python",
|
||||
"Syntax language.",
|
||||
GIST_LANGUAGES,
|
||||
),
|
||||
field(
|
||||
"description",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Optional description.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="gists-edit",
|
||||
method="POST",
|
||||
path="/gists/edit/{gist_slug}",
|
||||
title="Edit a gist",
|
||||
summary="Update a gist you own.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"gist_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"GIST_SLUG",
|
||||
"Slug or UID of the gist.",
|
||||
),
|
||||
field(
|
||||
"title",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"Quick sort",
|
||||
"Title, 1-200 characters.",
|
||||
),
|
||||
field(
|
||||
"source_code",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"print('hi')",
|
||||
"Source, 1-400000 characters.",
|
||||
),
|
||||
field(
|
||||
"language",
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
"python",
|
||||
"Syntax language.",
|
||||
GIST_LANGUAGES,
|
||||
),
|
||||
field(
|
||||
"description",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Optional description.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="gists-delete",
|
||||
method="POST",
|
||||
path="/gists/delete/{gist_slug}",
|
||||
title="Delete a gist",
|
||||
summary="Delete a gist you own; administrators may delete any user's gist. Soft-deleted (hidden everywhere but restorable from admin trash).",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"gist_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"GIST_SLUG",
|
||||
"Slug or UID of the gist.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="news-list",
|
||||
method="GET",
|
||||
path="/news",
|
||||
title="Browse news",
|
||||
summary="Curated developer news. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"before",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Pagination cursor (synced_at).",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="news-detail",
|
||||
method="GET",
|
||||
path="/news/{news_slug}",
|
||||
title="View a news article",
|
||||
summary="Render a news article with comments. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"news_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"NEWS_SLUG",
|
||||
"Slug or UID of the article.",
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
152
devplacepy/docs_api/groups/conventions.py
Normal file
152
devplacepy/docs_api/groups/conventions.py
Normal file
@ -0,0 +1,152 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
GROUP = {
|
||||
"slug": "conventions",
|
||||
"title": "Conventions & Errors",
|
||||
"intro": """
|
||||
# Conventions & Errors
|
||||
|
||||
Shared rules that apply to every endpoint in this reference.
|
||||
|
||||
## Base URL
|
||||
|
||||
Every example uses your current host:
|
||||
|
||||
```
|
||||
{{ base }}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
Each endpoint is tagged **public**, **user**, or **admin**. Authenticate user and admin
|
||||
endpoints with any of the four methods in [Authentication](/docs/authentication.html): the
|
||||
`session` cookie, an `X-API-KEY` header, a `Bearer` token, or HTTP Basic credentials. The
|
||||
interactive panels on this site pre-fill your own API key, so you can run user-level calls
|
||||
immediately.
|
||||
|
||||
## Request bodies
|
||||
|
||||
POST endpoints accept `application/x-www-form-urlencoded` form fields (the same fields the
|
||||
website submits). File uploads use `multipart/form-data`. A small number of endpoints accept
|
||||
a JSON body; those are noted explicitly.
|
||||
|
||||
## HTML or JSON (content negotiation)
|
||||
|
||||
**Every** endpoint that renders a page or returns a redirect can also answer in JSON - the
|
||||
website keeps working exactly as before, and automation gets structured data from the same
|
||||
URLs. A request is served JSON when it sends either of:
|
||||
|
||||
- `Accept: application/json`
|
||||
- `Content-Type: application/json`
|
||||
|
||||
A normal browser navigation (`Accept: text/html`) always receives HTML, so nothing existing
|
||||
changes. Responses are defined by Pydantic models, so each page returns the same data the
|
||||
template renders.
|
||||
|
||||
**Page reads** (GET) return the page payload as a JSON object (lists include a `next_cursor`
|
||||
for pagination; detail pages embed author, comments, reactions, poll, and attachments).
|
||||
|
||||
**Actions** (the form POSTs: create / edit / delete / follow / send / mark-read …) return a
|
||||
uniform envelope instead of a `302` redirect:
|
||||
|
||||
```json
|
||||
{ "ok": true, "redirect": "/posts/abc-my-post", "data": { "uid": "…", "slug": "…", "url": "…" } }
|
||||
```
|
||||
|
||||
`data` carries the created/affected resource where applicable, or `null`. Cookies (e.g. the
|
||||
session set on login/signup) are still set on JSON responses.
|
||||
|
||||
**Errors** are JSON too when JSON is requested:
|
||||
|
||||
```json
|
||||
{ "error": { "status": 404, "message": "Not found" } }
|
||||
```
|
||||
|
||||
Validation failures return `422` with `{ "error": "validation", "fields": { "field": ["msg"] } }`.
|
||||
Unauthenticated JSON requests to a protected endpoint return `401` (browsers are redirected to
|
||||
the login page instead); non-admins calling an admin endpoint get `403`.
|
||||
|
||||
## Trying it here
|
||||
|
||||
Every endpoint below has a live panel. Pick the response format (**JSON** by default, or
|
||||
**HTML** where the endpoint negotiates) and the panel sets the matching `Accept` header on the
|
||||
request and the generated cURL/JavaScript/Python snippets. The **Expected** tab always shows the
|
||||
modeled response shape; the **Live response** tab shows the real result after you press
|
||||
**Send request**.
|
||||
|
||||
## AJAX responses (legacy shape)
|
||||
|
||||
The [Votes, Reactions, Bookmarks & Polls](/docs/social-actions.html) endpoints predate the
|
||||
envelope and keep their original flat JSON shapes (e.g. `{ "saved": true }`). They return JSON
|
||||
when the request carries an `X-Requested-With: fetch` header (a subset of the rule above);
|
||||
without it they `302` redirect, mirroring the browser flow. The interactive panels send the
|
||||
header for you.
|
||||
|
||||
## Pagination
|
||||
|
||||
Most list endpoints page with an opaque cursor. Pass the `before` query parameter set to the
|
||||
`created_at` (or `synced_at`) value of the last item you received to fetch the next page; the
|
||||
JSON payload returns a `next_cursor` to use as the next `before`. This covers the feed, the
|
||||
post/project/gist/news lists, notifications, and saved bookmarks.
|
||||
|
||||
The follower and following lists are the exception: `GET /profile/{username}/followers` and
|
||||
`GET /profile/{username}/following` use classic page-based pagination via the `page` query
|
||||
parameter (25 per page), not a cursor.
|
||||
|
||||
## Identifiers
|
||||
|
||||
Posts, projects, gists, and news articles accept either their slug or their bare UUID in the
|
||||
path. Slugs embed the first eight characters of the UUID.
|
||||
|
||||
## Dates
|
||||
|
||||
All dates rendered to users are `DD/MM/YYYY`. Timestamps in stored records are ISO-8601 UTC.
|
||||
|
||||
## Status codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `200` | Success (JSON or HTML) |
|
||||
| `201` | Resource created (uploads) |
|
||||
| `302` | Redirect (browser-style success for form posts) |
|
||||
| `400` | Invalid request body or parameters |
|
||||
| `401` | Credentials supplied but invalid |
|
||||
| `403` | Authenticated but not allowed |
|
||||
| `404` | Resource not found |
|
||||
| `413` | Upload exceeds the configured size limit (see [Uploads](/docs/uploads.html)) |
|
||||
| `415` | Upload file type not allowed (see [Uploads](/docs/uploads.html)) |
|
||||
| `422` | Form/body validation failed (JSON clients) |
|
||||
| `429` | Rate limit exceeded (see Rate limiting) |
|
||||
| `503` | Maintenance mode |
|
||||
|
||||
## Rate limiting
|
||||
|
||||
Mutating requests (`POST`/`PUT`/`DELETE`/`PATCH`) are rate limited per client IP over a
|
||||
rolling window; reads are not limited. The limit and window are configurable by an
|
||||
administrator (defaults: 60 requests per 60 seconds). When you exceed the limit you receive a
|
||||
`429` whose `Retry-After` header gives the number of seconds to wait before retrying. The
|
||||
OpenAI gateway (`/openai/...`) is exempt.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**I get HTML back instead of JSON.** Send `Accept: application/json` (or
|
||||
`Content-Type: application/json` on a body). A request is only served JSON when it asks for it
|
||||
and does not also accept `text/html`; a normal browser navigation always gets HTML.
|
||||
`X-Requested-With: fetch` is **not** a general JSON switch - it only applies to the legacy
|
||||
engagement actions (votes, reactions, bookmarks, polls).
|
||||
|
||||
**An action returns `302` instead of the JSON envelope.** Same cause: the request did not ask
|
||||
for JSON. Add the `Accept: application/json` header and the action returns
|
||||
`{ "ok": true, "redirect": "...", "data": {...} }` instead of redirecting.
|
||||
|
||||
**A protected endpoint redirects me to the login page.** Browser-style (HTML) requests to a
|
||||
`user`/`admin` endpoint without valid credentials are redirected to login; the same request
|
||||
with `Accept: application/json` returns `401` instead. Non-admins calling an `admin` endpoint
|
||||
get `403` (JSON) or a redirect to the feed (HTML).
|
||||
|
||||
**An upload is rejected with `413` or `415`.** `413` means the file exceeds the configured
|
||||
size limit; `415` means the file type is not in the allowed list. Both limits are set by an
|
||||
administrator.
|
||||
""",
|
||||
"endpoints": [],
|
||||
}
|
||||
195
devplacepy/docs_api/groups/game.py
Normal file
195
devplacepy/docs_api/groups/game.py
Normal file
@ -0,0 +1,195 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "game",
|
||||
"title": "Code Farm",
|
||||
"intro": """
|
||||
# Code Farm
|
||||
|
||||
The Code Farm is a cooperative idle game. Each member owns a farm of plots, plants software
|
||||
projects that build over real time, harvests them for coins and XP, upgrades their CI tier for
|
||||
faster builds, and waters other members' growing builds to speed them up and earn coins.
|
||||
|
||||
All endpoints negotiate HTML or JSON. The action endpoints return the full farm state so a
|
||||
client can refresh without a second request.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="game-home",
|
||||
method="GET",
|
||||
path="/game",
|
||||
title="Code Farm page",
|
||||
summary="The player's own farm: HUD, plot grid, shop, and leaderboard.",
|
||||
auth="user",
|
||||
negotiation=True,
|
||||
sample_response={"ok": True, "farm": {"coins": 50, "level": 1, "plots": []}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-state",
|
||||
method="GET",
|
||||
path="/game/state",
|
||||
title="Farm state",
|
||||
summary="The signed-in player's full farm state as JSON.",
|
||||
auth="user",
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"farm": {
|
||||
"coins": 50,
|
||||
"level": 1,
|
||||
"ci_tier": 1,
|
||||
"plot_count": 4,
|
||||
"plots": [{"slot": 0, "state": "empty"}],
|
||||
"crops": [{"key": "python", "name": "Python Script", "cost": 15}],
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="game-leaderboard",
|
||||
method="GET",
|
||||
path="/game/leaderboard",
|
||||
title="Farm leaderboard",
|
||||
summary="Top farmers ranked by level, XP, and harvests.",
|
||||
auth="public",
|
||||
sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4}]},
|
||||
),
|
||||
endpoint(
|
||||
id="game-view-farm",
|
||||
method="GET",
|
||||
path="/game/farm/{username}",
|
||||
title="View a farm",
|
||||
summary="Another player's farm, with water controls on growing builds.",
|
||||
auth="public",
|
||||
negotiation=True,
|
||||
params=[field("username", "path", "string", True, "alice", "Farm owner's username.")],
|
||||
sample_response={"farm": {"owner_username": "alice", "is_owner": False, "plots": []}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-plant",
|
||||
method="POST",
|
||||
path="/game/plant",
|
||||
title="Plant a crop",
|
||||
summary="Plant a crop in an empty plot. Costs the crop's coin price.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index."),
|
||||
field("crop", "form", "string", True, "python", "Crop key."),
|
||||
],
|
||||
sample_response={"ok": True, "farm": {"coins": 35}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-harvest",
|
||||
method="POST",
|
||||
path="/game/harvest",
|
||||
title="Harvest a build",
|
||||
summary="Harvest a finished build for coins and XP.",
|
||||
auth="user",
|
||||
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
|
||||
sample_response={"ok": True, "farm": {"coins": 86}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-buy-plot",
|
||||
method="POST",
|
||||
path="/game/buy-plot",
|
||||
title="Buy a plot",
|
||||
summary="Unlock a new plot. Cost doubles per extra plot.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"plot_count": 5}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-upgrade",
|
||||
method="POST",
|
||||
path="/game/upgrade",
|
||||
title="Upgrade CI",
|
||||
summary="Upgrade the farm CI tier for faster builds.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"ci_tier": 2}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-water",
|
||||
method="POST",
|
||||
path="/game/farm/{username}/water",
|
||||
title="Water a build",
|
||||
summary="Water another player's growing build to speed it up and earn coins.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("username", "path", "string", True, "alice", "Farm owner's username."),
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index."),
|
||||
],
|
||||
sample_response={"farm": {"owner_username": "alice"}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-steal",
|
||||
method="POST",
|
||||
path="/game/farm/{username}/steal",
|
||||
title="Steal a build",
|
||||
summary="Steal another player's ready build once its protection window has passed; you receive half the build's coin value. Limited to once per hour per neighbour.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("username", "path", "string", True, "alice", "Farm owner's username."),
|
||||
field("slot", "form", "integer", True, "0", "Plot slot index."),
|
||||
],
|
||||
sample_response={"farm": {"owner_username": "alice"}, "stole_coins": 18},
|
||||
),
|
||||
endpoint(
|
||||
id="game-fertilize",
|
||||
method="POST",
|
||||
path="/game/fertilize",
|
||||
title="Fertilize a build",
|
||||
summary="Spend coins to halve a growing build's remaining time. The cost scales with the build's realized harvest value, so fertilizing is a pure time-skip and never a profit at any prestige.",
|
||||
auth="user",
|
||||
params=[field("slot", "form", "integer", True, "0", "Plot slot index.")],
|
||||
sample_response={"ok": True, "farm": {"coins": 12}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-daily",
|
||||
method="POST",
|
||||
path="/game/daily",
|
||||
title="Claim daily bonus",
|
||||
summary="Claim the once-per-day coin bonus; consecutive days grow a streak.",
|
||||
auth="user",
|
||||
sample_response={"ok": True, "farm": {"streak": 3, "coins": 94}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-perk",
|
||||
method="POST",
|
||||
path="/game/perk",
|
||||
title="Upgrade a perk",
|
||||
summary="Upgrade a permanent perk: yield, growth, discount, or xp.",
|
||||
auth="user",
|
||||
params=[field("perk", "form", "string", True, "growth", "Perk key.")],
|
||||
sample_response={"ok": True, "farm": {"coins": 0}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-quests-claim",
|
||||
method="POST",
|
||||
path="/game/quests/claim",
|
||||
title="Claim a quest",
|
||||
summary="Claim a completed daily quest reward by its kind.",
|
||||
auth="user",
|
||||
params=[field("quest", "form", "string", True, "harvest", "Quest kind.")],
|
||||
sample_response={"ok": True, "farm": {"coins": 130}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-prestige",
|
||||
method="POST",
|
||||
path="/game/prestige",
|
||||
title="Refactor (prestige)",
|
||||
summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
sample_response={"ok": True, "farm": {"prestige": 1}},
|
||||
),
|
||||
endpoint(
|
||||
id="game-legacy",
|
||||
method="POST",
|
||||
path="/game/legacy",
|
||||
title="Buy a Legacy upgrade",
|
||||
summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest, multiplier, speed, plots, or defense.",
|
||||
auth="user",
|
||||
params=[field("key", "form", "string", True, "multiplier", "Legacy upgrade key.")],
|
||||
sample_response={"ok": True, "farm": {"stars": 1}},
|
||||
),
|
||||
],
|
||||
}
|
||||
195
devplacepy/docs_api/groups/gateway.py
Normal file
195
devplacepy/docs_api/groups/gateway.py
Normal file
@ -0,0 +1,195 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "gateway",
|
||||
"title": "OpenAI Gateway",
|
||||
"admin": True,
|
||||
"intro": """
|
||||
# OpenAI Gateway
|
||||
|
||||
An OpenAI-compatible proxy mounted at `/openai/v1`. It forwards requests to a configured
|
||||
upstream using the gateway's own credentials, so no DevPlace key is required - but an
|
||||
administrator must enable the `openai` service first. Point any OpenAI-compatible client at
|
||||
`{{ base }}/openai/v1`.
|
||||
|
||||
This gateway is the **single point of truth for AI** on the platform. Every other DevPlace
|
||||
service (news, bots, Devii) calls it by default instead of an external provider, sends the
|
||||
generic model name `molodetz`, and authenticates with an internal key that is auto-generated
|
||||
on first boot. The real provider URLs, models, and keys (DeepSeek, OpenRouter) live only here,
|
||||
so an operator switches providers or backends in one place. `DEEPSEEK_API_KEY` and
|
||||
`OPENROUTER_API_KEY` are migrated into the editable settings on boot and the value in use is
|
||||
shown. Because `Force model` is on by default, the upstream always receives the configured
|
||||
model regardless of what a client (or `molodetz`) requests.
|
||||
|
||||
The gateway also performs **vision** augmentation: when a request includes an image, the gateway
|
||||
describes the image with a configured vision model and rewrites it to text, so a vision-less
|
||||
upstream still works. The vision model, URL, and key are configured alongside the other gateway
|
||||
settings.
|
||||
|
||||
The gateway additionally serves **text embeddings** at `/openai/v1/embeddings`. Clients request the
|
||||
generic model `molodetz~embed`, which the gateway maps to the configured embedding model (OpenRouter's
|
||||
Qwen3 8B embedding model by default). Usage and cost are tracked per call exactly like chat and vision.
|
||||
|
||||
## Model routing and providers
|
||||
|
||||
On top of the single default upstream above, an administrator can register additional named
|
||||
**providers** and map any number of requested **model names** onto them, so one gateway can front
|
||||
many models across many backends. A model route binds a source model name (what a client sends) to a
|
||||
target provider and upstream model, and carries:
|
||||
|
||||
- its **own pricing economy** (input, output, and cache-hit / cache-miss prices per million tokens),
|
||||
used to compute that call's cost when the upstream returns no native cost;
|
||||
- an optional **vision model**, which turns on the image-to-text merge for that route (so a text-only
|
||||
model can answer about images);
|
||||
- an optional **context window** used for the context-utilization header.
|
||||
|
||||
Resolution is transparent to clients: when the requested `model` matches an active route, the gateway
|
||||
forwards to that route's provider and target model and meters the call against the route's economy.
|
||||
When it matches no route, the request falls through to the default upstream unchanged (so `molodetz`,
|
||||
`molodetz~embed`, and any existing client keep working exactly as before). Providers and routes are
|
||||
managed by administrators on the **Gateway** page (`/admin/gateway`).
|
||||
|
||||
## Per-call cost and usage headers
|
||||
|
||||
Every gateway response - chat, embeddings, and passthrough, on both success and error - carries
|
||||
`X-Gateway-*` response headers describing that single call, so a client can read its own token usage
|
||||
and dollar cost directly from the response with no extra request:
|
||||
|
||||
| Header | Meaning |
|
||||
|--------|---------|
|
||||
| `X-Gateway-Model` | Upstream model actually used for the call |
|
||||
| `X-Gateway-Backend` | Backend that served it: `chat`, `embed`, or passthrough |
|
||||
| `X-Gateway-Prompt-Tokens` | Input (prompt) tokens |
|
||||
| `X-Gateway-Completion-Tokens` | Output (completion) tokens |
|
||||
| `X-Gateway-Total-Tokens` | Total tokens (prompt + completion) |
|
||||
| `X-Gateway-Cache-Hit-Tokens` | Prompt tokens served from the upstream prompt cache |
|
||||
| `X-Gateway-Cache-Miss-Tokens` | Prompt tokens not served from cache |
|
||||
| `X-Gateway-Reasoning-Tokens` | Reasoning tokens, when the model reports them |
|
||||
| `X-Gateway-Cost-USD` | Total cost of the call in US dollars |
|
||||
| `X-Gateway-Input-Cost-USD` | Input portion of the cost in US dollars |
|
||||
| `X-Gateway-Output-Cost-USD` | Output portion of the cost in US dollars |
|
||||
| `X-Gateway-Cost-Native` | `1` if the dollar cost is the upstream's own reported cost, `0` if computed from the configured per-million pricing |
|
||||
| `X-Gateway-Tokens-Per-Second` | Output tokens per second for the call |
|
||||
| `X-Gateway-Upstream-Latency-Ms` | Upstream round-trip latency in milliseconds |
|
||||
| `X-Gateway-Total-Latency-Ms` | Full end-to-end gateway time for the call in milliseconds |
|
||||
| `X-Gateway-Gateway-Overhead-Ms` | Gateway processing time minus the upstream and queue wait, in milliseconds |
|
||||
| `X-Gateway-Queue-Wait-Ms` | Time spent waiting on the concurrency semaphore before dispatch, in milliseconds |
|
||||
| `X-Gateway-Connect-Ms` | Upstream connection establishment time in milliseconds |
|
||||
| `X-Gateway-Context-Window` | The model's context window in tokens, when known |
|
||||
| `X-Gateway-Context-Utilization` | Total tokens as a fraction of the context window, when known |
|
||||
|
||||
Dollar costs use the upstream's native `cost` field when it returns one
|
||||
(`X-Gateway-Cost-Native: 1`); otherwise they are computed from the per-million prices of the matched
|
||||
model route, falling back to the prices configured on the `openai` service when no route matches. The
|
||||
one denied path that makes no upstream call (embeddings disabled) returns no usage headers.
|
||||
|
||||
Administrators enable and configure this gateway under [Background Services](/docs/services.html)
|
||||
(the `openai` service).
|
||||
|
||||
The gateway is exempt from rate limiting, but every other endpoint follows the shared
|
||||
[Conventions & Errors](/docs/conventions.html); see [Authentication](/docs/authentication.html)
|
||||
for signing DevPlace's own requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="gateway-chat",
|
||||
method="POST",
|
||||
path="/openai/v1/chat/completions",
|
||||
title="Chat completions",
|
||||
summary="OpenAI-compatible chat completion. Supports streaming.",
|
||||
auth="user",
|
||||
encoding="json",
|
||||
params=[
|
||||
field(
|
||||
"model",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"gpt-4o-mini",
|
||||
"Model id. When it matches a configured model route the gateway forwards to that route's provider and upstream model; otherwise it uses the default upstream model.",
|
||||
),
|
||||
field(
|
||||
"messages",
|
||||
"json",
|
||||
"string",
|
||||
True,
|
||||
'[{"role":"user","content":"Hello"}]',
|
||||
"Chat messages array.",
|
||||
),
|
||||
field(
|
||||
"stream",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"false",
|
||||
"Set true for a streamed SSE response.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"Returns `503` when the gateway service is not running.",
|
||||
"Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above), including the streamed SSE response.",
|
||||
"If `model` matches a configured model route it is forwarded to that route's provider, upstream model, and per-model pricing (with an optional vision model); otherwise it falls through to the default upstream (see Model routing and providers above).",
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="gateway-embeddings",
|
||||
method="POST",
|
||||
path="/openai/v1/embeddings",
|
||||
title="Embeddings",
|
||||
summary="OpenAI-compatible text embeddings. Request model molodetz~embed.",
|
||||
auth="user",
|
||||
encoding="json",
|
||||
params=[
|
||||
field(
|
||||
"model",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"molodetz~embed",
|
||||
"Embedding model id; the gateway maps molodetz~embed to the configured model, or to a matching embed model route's provider and target model.",
|
||||
),
|
||||
field(
|
||||
"input",
|
||||
"json",
|
||||
"string",
|
||||
True,
|
||||
'"some text to embed"',
|
||||
"String or array of strings to embed.",
|
||||
),
|
||||
field(
|
||||
"dimensions",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"4096",
|
||||
"Optional output vector size (Matryoshka, 32-4096).",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"Returns `503` when the gateway service is not running or embeddings are disabled.",
|
||||
"Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above).",
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="gateway-passthrough",
|
||||
method="POST",
|
||||
path="/openai/v1/{path}",
|
||||
title="Passthrough",
|
||||
summary="Any other /v1 path is forwarded to the upstream as-is.",
|
||||
auth="user",
|
||||
interactive=False,
|
||||
params=[
|
||||
field(
|
||||
"path",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"models",
|
||||
"Upstream API path after /v1/.",
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
298
devplacepy/docs_api/groups/issues.py
Normal file
298
devplacepy/docs_api/groups/issues.py
Normal file
@ -0,0 +1,298 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "issues",
|
||||
"title": "Issue Reports",
|
||||
"intro": """
|
||||
# Issue Reports
|
||||
|
||||
The issue tracker is a full integration with a Gitea repository. The listing and detail views read
|
||||
issues straight from Gitea with their live status, and a report you file is first rewritten by the
|
||||
internal AI service into a consistent ticket, then posted to Gitea as an issue. The original
|
||||
reporter is notified when a developer replies or the status changes, and a comment posted here is
|
||||
pushed to Gitea and attributed to your account.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
four ways to sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="issues-list",
|
||||
method="GET",
|
||||
path="/issues",
|
||||
title="List issue tickets",
|
||||
summary="Render the issue board from Gitea, paginated and filterable by state.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"state",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"open",
|
||||
"Filter: open (default), closed, or all.",
|
||||
),
|
||||
field("page", "query", "integer", False, "1", "1-based page."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-create",
|
||||
method="POST",
|
||||
path="/issues/create",
|
||||
title="Report an issue",
|
||||
summary="Enqueue an issue report. It is enhanced by AI and filed on the tracker.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
ajax=True,
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"title",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"Login button misaligned",
|
||||
"Title, 1-200 characters.",
|
||||
),
|
||||
field(
|
||||
"description",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"Steps to reproduce...",
|
||||
"Description, 1-5000 characters.",
|
||||
),
|
||||
],
|
||||
notes=["Returns a job uid and status_url. Poll the status_url until status is done to get the issue number."],
|
||||
sample_response={
|
||||
"uid": "JOB_UID",
|
||||
"status_url": "/issues/jobs/JOB_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="issues-job",
|
||||
method="GET",
|
||||
path="/issues/jobs/{uid}",
|
||||
title="Issue filing job status",
|
||||
summary="Poll the filing job; the result carries the new issue number and url.",
|
||||
auth="public",
|
||||
ajax=True,
|
||||
params=[field("uid", "path", "string", True, "JOB_UID", "Job uid.")],
|
||||
sample_response={
|
||||
"uid": "JOB_UID",
|
||||
"kind": "issue_create",
|
||||
"status": "done",
|
||||
"number": 42,
|
||||
"issue_url": "/issues/42",
|
||||
"enhanced": True,
|
||||
"error": None,
|
||||
"created_at": "2026-06-12T09:00:00+00:00",
|
||||
"completed_at": "2026-06-12T09:00:03+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="issues-planning-queue",
|
||||
method="POST",
|
||||
path="/issues/planning",
|
||||
title="Generate a tickets planning report",
|
||||
summary="Enqueue a phased markdown implementation document for open tickets, with each ticket's full description reproduced verbatim (inline plus a Source Tickets appendix) alongside implementation steps and acceptance criteria. Admin only.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
ajax=True,
|
||||
params=[field("numbers", "form", "string", False, "1,4,7", "Comma-separated issue numbers to include. Omit to plan every open ticket.")],
|
||||
notes=["Returns a job uid and status_url. Poll the status_url until status is done to read the markdown and download it.", "Provide 'numbers' to plan only a chosen subset of open tickets; omitting it plans all open tickets."],
|
||||
sample_response={
|
||||
"uid": "PLANNING_JOB_UID",
|
||||
"status_url": "/issues/planning/PLANNING_JOB_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="issues-planning-status",
|
||||
method="GET",
|
||||
path="/issues/planning/{uid}",
|
||||
title="Planning report job status",
|
||||
summary="Poll the planning job; the result carries the rendered markdown and the download URL. Admin only.",
|
||||
auth="admin",
|
||||
ajax=True,
|
||||
params=[field("uid", "path", "string", True, "PLANNING_JOB_UID", "Planning job uid.")],
|
||||
sample_response={
|
||||
"uid": "PLANNING_JOB_UID",
|
||||
"kind": "planning",
|
||||
"status": "done",
|
||||
"download_url": "/issues/planning/PLANNING_JOB_UID/download",
|
||||
"markdown": "# Open Tickets Implementation Plan\n\n...",
|
||||
"ai_used": True,
|
||||
"issue_count": 12,
|
||||
"bytes_out": 4096,
|
||||
"error": None,
|
||||
"created_at": "2026-06-15T09:00:00+00:00",
|
||||
"completed_at": "2026-06-15T09:00:05+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="issues-planning-download",
|
||||
method="GET",
|
||||
path="/issues/planning/{uid}/download",
|
||||
title="Download the planning report",
|
||||
summary="Download the generated planning report as a markdown file. Admin only.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[field("uid", "path", "string", True, "PLANNING_JOB_UID", "Planning job uid.")],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-detail",
|
||||
method="GET",
|
||||
path="/issues/{number}",
|
||||
title="View an issue ticket",
|
||||
summary="Render a Gitea issue and its comments. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Issue number.")
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-comment",
|
||||
method="POST",
|
||||
path="/issues/{number}/comment",
|
||||
title="Comment on an issue",
|
||||
summary="Post a comment to the Gitea issue, attributed to the current user.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
field(
|
||||
"body",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"I can reproduce this on mobile.",
|
||||
"Comment, 1-5000 characters.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-status",
|
||||
method="POST",
|
||||
path="/issues/{number}/status",
|
||||
title="Change an issue status",
|
||||
summary="Open or close the Gitea issue. Admin only.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
field(
|
||||
"status",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"closed",
|
||||
"New status: open or closed.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-attachments-list",
|
||||
method="GET",
|
||||
path="/issues/{number}/attachments",
|
||||
title="List issue attachments",
|
||||
summary="Return the files attached to an issue ticket.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-attachments-add",
|
||||
method="POST",
|
||||
path="/issues/{number}/attachments",
|
||||
title="Attach files to an issue",
|
||||
summary=(
|
||||
"Link already-uploaded files (from /uploads/upload) to an open issue. The "
|
||||
"files are also mirrored to the Gitea tracker. Allowed only while the issue is open."
|
||||
),
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
field(
|
||||
"attachment_uids",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"a1b2c3d4,e5f6g7h8",
|
||||
"Comma separated attachment uids returned by /uploads/upload.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"Only the open issue accepts changes; a closed issue returns 409.",
|
||||
"You can only link your own uploads unless you are an administrator.",
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-attachments-delete",
|
||||
method="DELETE",
|
||||
path="/issues/{number}/attachments/{uid}",
|
||||
title="Delete an issue attachment",
|
||||
summary=(
|
||||
"Soft-delete a file from an open issue (owner or administrator) and remove it "
|
||||
"from the tracker."
|
||||
),
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
field("uid", "path", "string", True, "a1b2c3d4", "Attachment uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-comment-attachments-add",
|
||||
method="POST",
|
||||
path="/issues/{number}/comments/{cid}/attachments",
|
||||
title="Attach files to an issue comment",
|
||||
summary=(
|
||||
"Link already-uploaded files to an issue comment (issue must be open). Mirrored "
|
||||
"to the Gitea comment."
|
||||
),
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
field("cid", "path", "integer", True, "1", "Gitea comment id."),
|
||||
field(
|
||||
"attachment_uids",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"a1b2c3d4",
|
||||
"Comma separated attachment uids returned by /uploads/upload.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-comment-attachments-delete",
|
||||
method="DELETE",
|
||||
path="/issues/{number}/comments/{cid}/attachments/{uid}",
|
||||
title="Delete an issue comment attachment",
|
||||
summary=(
|
||||
"Soft-delete a file from an issue comment (owner or administrator) and remove it "
|
||||
"from the tracker."
|
||||
),
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
field("cid", "path", "integer", True, "1", "Gitea comment id."),
|
||||
field("uid", "path", "string", True, "a1b2c3d4", "Attachment uid."),
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
61
devplacepy/docs_api/groups/lookups.py
Normal file
61
devplacepy/docs_api/groups/lookups.py
Normal file
@ -0,0 +1,61 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "lookups",
|
||||
"title": "Search & Lookups",
|
||||
"intro": """
|
||||
# Search & Lookups
|
||||
|
||||
Type-ahead lookups that power mentions and the message composer. Both return JSON and accept
|
||||
a single `q` query parameter. These feed [Messaging](/docs/messaging.html) (the recipient
|
||||
composer) and [Profiles & Social Graph](/docs/profiles.html) (mentions and user pages).
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
four ways to sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="search-users",
|
||||
method="GET",
|
||||
path="/profile/search",
|
||||
title="Search users",
|
||||
summary="Find up to ten users whose username matches a query.",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"q",
|
||||
"query",
|
||||
required=True,
|
||||
example="al",
|
||||
description="Partial username to match.",
|
||||
)
|
||||
],
|
||||
sample_response={
|
||||
"results": [{"uid": "8f14e45f-...", "username": "alice_test"}]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="search-message-recipients",
|
||||
method="GET",
|
||||
path="/messages/search",
|
||||
title="Search message recipients",
|
||||
summary="Like user search, but excludes yourself; used by the message composer.",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"q",
|
||||
"query",
|
||||
required=True,
|
||||
example="bo",
|
||||
description="Partial username to match.",
|
||||
)
|
||||
],
|
||||
sample_response={
|
||||
"results": [{"uid": "0cc175b9-...", "username": "bob_test"}]
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
75
devplacepy/docs_api/groups/messaging.py
Normal file
75
devplacepy/docs_api/groups/messaging.py
Normal file
@ -0,0 +1,75 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "messaging",
|
||||
"title": "Messaging",
|
||||
"intro": """
|
||||
# Messaging
|
||||
|
||||
Direct messages between users. The inbox renders HTML; sending uses form fields. Look up
|
||||
recipients with [Search & Lookups](/docs/lookups.html) and attach files via [Uploads](/docs/uploads.html).
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
four ways to sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="messages-inbox",
|
||||
method="GET",
|
||||
path="/messages",
|
||||
title="Open the inbox",
|
||||
summary="Render conversations. Returns an HTML page.",
|
||||
auth="user",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"with_uid",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Open a specific conversation by user UID.",
|
||||
),
|
||||
field(
|
||||
"search",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Jump to a conversation by username.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="messages-send",
|
||||
method="POST",
|
||||
path="/messages/send",
|
||||
title="Send a message",
|
||||
summary="Send a direct message to a user.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"content",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"Hello there.",
|
||||
"Body, 1-2000 characters.",
|
||||
),
|
||||
field(
|
||||
"receiver_uid",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"RECEIVER_UID",
|
||||
"Recipient user UID.",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
89
devplacepy/docs_api/groups/notifications.py
Normal file
89
devplacepy/docs_api/groups/notifications.py
Normal file
@ -0,0 +1,89 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "notifications",
|
||||
"title": "Notifications",
|
||||
"intro": """
|
||||
# Notifications
|
||||
|
||||
Read your notification feed and mark items read. The unread counts endpoint backs the badges
|
||||
in the navigation bar. Deliver these to the browser with [Web Push](/docs/push.html).
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
four ways to sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="notifications-list",
|
||||
method="GET",
|
||||
path="/notifications",
|
||||
title="View notifications",
|
||||
summary="Render your notifications. Returns an HTML page.",
|
||||
auth="user",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("before", "query", "string", False, "", "Pagination cursor.")
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="notifications-counts",
|
||||
method="GET",
|
||||
path="/notifications/counts",
|
||||
title="Unread counts",
|
||||
summary="Unread notification and message counts.",
|
||||
auth="public",
|
||||
notes=[
|
||||
'Guests receive `{ "notifications": 0, "messages": 0 }` instead of an error, so the navigation badge works before login.'
|
||||
],
|
||||
sample_response={"notifications": 2, "messages": 1},
|
||||
),
|
||||
endpoint(
|
||||
id="notifications-open",
|
||||
method="GET",
|
||||
path="/notifications/open/{notification_uid}",
|
||||
title="Open a notification",
|
||||
summary="Mark a notification read and redirect to its target.",
|
||||
auth="user",
|
||||
interactive=False,
|
||||
params=[
|
||||
field(
|
||||
"notification_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"NOTIFICATION_UID",
|
||||
"UID of the notification.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="notifications-mark-read",
|
||||
method="POST",
|
||||
path="/notifications/mark-read/{notification_uid}",
|
||||
title="Mark one read",
|
||||
summary="Mark a single notification as read.",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"notification_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"NOTIFICATION_UID",
|
||||
"UID of the notification.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="notifications-mark-all-read",
|
||||
method="POST",
|
||||
path="/notifications/mark-all-read",
|
||||
title="Mark all read",
|
||||
summary="Mark every notification as read.",
|
||||
auth="user",
|
||||
),
|
||||
],
|
||||
}
|
||||
683
devplacepy/docs_api/groups/profiles.py
Normal file
683
devplacepy/docs_api/groups/profiles.py
Normal file
@ -0,0 +1,683 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "profiles",
|
||||
"title": "Profiles & Social Graph",
|
||||
"intro": """
|
||||
# Profiles & Social Graph
|
||||
|
||||
Profile data, the follow graph, the leaderboard, and avatar generation. Find users with
|
||||
[Search & Lookups](/docs/lookups.html); follows generate entries in [Notifications](/docs/notifications.html).
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
four ways to sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="my-profile",
|
||||
method="GET",
|
||||
path="/profile",
|
||||
title="View your own profile",
|
||||
summary="Render the signed-in user's own profile, in the exact format of GET /profile/{username}. Requires authentication.",
|
||||
auth="user",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"tab",
|
||||
"query",
|
||||
"enum",
|
||||
False,
|
||||
"posts",
|
||||
"Profile tab.",
|
||||
["posts", "activity", "followers", "following", "media"],
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="profile-detail",
|
||||
method="GET",
|
||||
path="/profile/{username}",
|
||||
title="View a profile",
|
||||
summary="Render a user profile. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"{{ username }}",
|
||||
"Target username.",
|
||||
),
|
||||
field(
|
||||
"tab",
|
||||
"query",
|
||||
"enum",
|
||||
False,
|
||||
"posts",
|
||||
"Profile tab.",
|
||||
["posts", "activity", "followers", "following", "media"],
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="profile-update",
|
||||
method="POST",
|
||||
path="/profile/update",
|
||||
title="Update your profile",
|
||||
summary="Update your own bio and links.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"bio",
|
||||
"form",
|
||||
"textarea",
|
||||
False,
|
||||
"Building things.",
|
||||
"Bio, up to 500 characters.",
|
||||
),
|
||||
field(
|
||||
"location",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"Earth",
|
||||
"Location, up to 200 characters.",
|
||||
),
|
||||
field("git_link", "form", "string", False, "", "Git profile URL."),
|
||||
field(
|
||||
"website", "form", "string", False, "", "Personal website URL."
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="profile-ai-correction",
|
||||
method="POST",
|
||||
path="/profile/{username}/ai-correction",
|
||||
title="Configure AI content correction",
|
||||
summary="Enable or disable automatic AI rewriting of your prose and set the correction instruction. Opt-in, default off. Admins may target any user.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Profile owner. Must be yourself unless you are an admin.",
|
||||
),
|
||||
field(
|
||||
"enabled",
|
||||
"form",
|
||||
"boolean",
|
||||
False,
|
||||
"true",
|
||||
"true to enable background AI correction, false to disable it.",
|
||||
),
|
||||
field(
|
||||
"sync",
|
||||
"form",
|
||||
"boolean",
|
||||
False,
|
||||
"false",
|
||||
"true to apply the correction synchronously (the save waits), false for background.",
|
||||
),
|
||||
field(
|
||||
"prompt",
|
||||
"form",
|
||||
"textarea",
|
||||
False,
|
||||
"Leave literary as is, only do punctuation and casing",
|
||||
"Correction instruction, up to 2000 characters.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/profile/bob_test",
|
||||
"data": {
|
||||
"url": "/profile/bob_test",
|
||||
"enabled": True,
|
||||
"sync": False,
|
||||
"prompt": "Leave literary as is, only do punctuation and casing",
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-ai-modifier",
|
||||
method="POST",
|
||||
path="/profile/{username}/ai-modifier",
|
||||
title="Configure the AI modifier",
|
||||
summary="Enable or disable the inline '@ai <instruction>' modifier on your prose and set its prompt. Enabled by default, applied synchronously by default. Admins may target any user.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Profile owner. Must be yourself unless you are an admin.",
|
||||
),
|
||||
field(
|
||||
"enabled",
|
||||
"form",
|
||||
"boolean",
|
||||
False,
|
||||
"true",
|
||||
"true to enable the AI modifier, false to disable it.",
|
||||
),
|
||||
field(
|
||||
"sync",
|
||||
"form",
|
||||
"boolean",
|
||||
False,
|
||||
"true",
|
||||
"true to apply the modification synchronously (the save waits), false for background.",
|
||||
),
|
||||
field(
|
||||
"prompt",
|
||||
"form",
|
||||
"textarea",
|
||||
False,
|
||||
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`",
|
||||
"Modifier instruction, up to 2000 characters.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/profile/bob_test",
|
||||
"data": {
|
||||
"url": "/profile/bob_test",
|
||||
"enabled": True,
|
||||
"sync": True,
|
||||
"prompt": "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`",
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-telegram",
|
||||
method="POST",
|
||||
path="/profile/{username}/telegram",
|
||||
title="Pair or unpair Telegram",
|
||||
summary="Request a single-use Telegram pairing code, or unpair the connected account. Send action=request (default) to receive a code valid for a few minutes, or action=unpair to disconnect. Admins may target any user.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Profile owner. Must be yourself unless you are an admin.",
|
||||
),
|
||||
field(
|
||||
"action",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"request",
|
||||
"Either request to issue a pairing code, or unpair to disconnect Telegram.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"paired": False,
|
||||
"code": "1234",
|
||||
"expires_at": "2026-06-18T12:05:00+00:00",
|
||||
"ttl_minutes": 5,
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-regenerate-key",
|
||||
method="POST",
|
||||
path="/profile/regenerate-api-key",
|
||||
title="Regenerate your API key",
|
||||
summary="Issue a new API key and invalidate the current one.",
|
||||
auth="user",
|
||||
interactive=False,
|
||||
destructive=True,
|
||||
notes=[
|
||||
"> Running this invalidates the key these documentation panels use. Do it from your "
|
||||
"[profile page](/profile/{{ username }}) instead, then reload these docs.",
|
||||
],
|
||||
sample_response={"api_key": "NEW_UUID"},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-regenerate-avatar",
|
||||
method="POST",
|
||||
path="/profile/{username}/regenerate-avatar",
|
||||
title="Regenerate a user avatar",
|
||||
summary="Replace the user's avatar with a freshly generated random one.",
|
||||
auth="user",
|
||||
interactive=False,
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
required=True,
|
||||
description="Profile owner. Allowed for the owner or any admin.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"> Irreversible: the previous avatar is gone for good and cannot be brought back.",
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"data": {
|
||||
"url": "/profile/{{ username }}",
|
||||
"avatar_seed": "NEW_UUID",
|
||||
"avatar_url": "/avatar/multiavatar/NEW_UUID?size=80",
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-customization-global",
|
||||
method="POST",
|
||||
path="/profile/{username}/customization/global",
|
||||
title="Toggle site-wide customizations",
|
||||
summary="Show or suppress your site-wide custom CSS and JS without deleting it. Admins may target any user.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Profile owner. Must be yourself unless you are an admin.",
|
||||
),
|
||||
field(
|
||||
"value",
|
||||
"form",
|
||||
"boolean",
|
||||
False,
|
||||
"0",
|
||||
"1 to show your site-wide customizations, 0 to suppress them.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="profile-customization-pagetype",
|
||||
method="POST",
|
||||
path="/profile/{username}/customization/pagetype",
|
||||
title="Toggle per-page customizations",
|
||||
summary="Show or suppress your per-page custom CSS and JS without deleting it. Admins may target any user.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Profile owner. Must be yourself unless you are an admin.",
|
||||
),
|
||||
field(
|
||||
"value",
|
||||
"form",
|
||||
"boolean",
|
||||
False,
|
||||
"0",
|
||||
"1 to show your per-page customizations, 0 to suppress them.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="profile-notification-toggle",
|
||||
method="POST",
|
||||
path="/profile/{username}/notifications",
|
||||
title="Toggle a notification preference",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Profile owner. Must be yourself unless you are an admin.",
|
||||
),
|
||||
field(
|
||||
"notification_type",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"push",
|
||||
"One of in_app, push or telegram (telegram is off by default and requires a paired Telegram account).",
|
||||
),
|
||||
field(
|
||||
"value",
|
||||
"form",
|
||||
"boolean",
|
||||
False,
|
||||
"1",
|
||||
"1 to deliver this notification on this channel, 0 to suppress it.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/profile/{{ username }}?tab=notifications",
|
||||
"data": {
|
||||
"notification_type": "vote",
|
||||
"channel": "push",
|
||||
"value": False,
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-notification-reset",
|
||||
method="POST",
|
||||
path="/profile/{username}/notifications/reset",
|
||||
title="Reset notification preferences",
|
||||
summary="Clear all of a user's notification overrides so every type falls back to the platform default. Admins may target any user.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Profile owner. Must be yourself unless you are an admin.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/profile/{{ username }}?tab=notifications",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="media-delete",
|
||||
method="POST",
|
||||
path="/media/{uid}/delete",
|
||||
title="Delete media",
|
||||
summary="Remove one of your uploaded media attachments. It disappears from your profile Media tab and from any post, project, gist, or other place it was attached. You can delete media you uploaded; administrators may remove any user's media.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"",
|
||||
"Attachment uid, taken from the Media tab response.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/profile/{{ username }}?tab=media",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="follow-user",
|
||||
method="POST",
|
||||
path="/follow/{username}",
|
||||
title="Follow a user",
|
||||
summary="Follow another user. Idempotent.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Username to follow.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="unfollow-user",
|
||||
method="POST",
|
||||
path="/follow/unfollow/{username}",
|
||||
title="Unfollow a user",
|
||||
summary="Stop following a user.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Username to unfollow.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="block-user",
|
||||
method="POST",
|
||||
path="/block/{username}",
|
||||
title="Block a user",
|
||||
summary="Block a user. Their posts, comments and messages are hidden from you everywhere except their own profile, and they can no longer create notifications for you. Idempotent.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Username to block.",
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/profile/bob_test"},
|
||||
),
|
||||
endpoint(
|
||||
id="unblock-user",
|
||||
method="POST",
|
||||
path="/block/unblock/{username}",
|
||||
title="Unblock a user",
|
||||
summary="Reverse a block. Their content becomes visible again.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Username to unblock.",
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/profile/bob_test"},
|
||||
),
|
||||
endpoint(
|
||||
id="mute-user",
|
||||
method="POST",
|
||||
path="/mute/{username}",
|
||||
title="Mute a user",
|
||||
summary="Mute a user so they can no longer create notifications for you. Their content stays visible. Idempotent.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Username to mute.",
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/profile/bob_test"},
|
||||
),
|
||||
endpoint(
|
||||
id="unmute-user",
|
||||
method="POST",
|
||||
path="/mute/unmute/{username}",
|
||||
title="Unmute a user",
|
||||
summary="Reverse a mute. They can create notifications for you again.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Username to unmute.",
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/profile/bob_test"},
|
||||
),
|
||||
endpoint(
|
||||
id="list-followers",
|
||||
method="GET",
|
||||
path="/profile/{username}/followers",
|
||||
title="List followers",
|
||||
summary="List the users who follow a profile, 25 per page. Returns JSON.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"{{ username }}",
|
||||
"Target username.",
|
||||
),
|
||||
field(
|
||||
"page",
|
||||
"query",
|
||||
"integer",
|
||||
False,
|
||||
"1",
|
||||
"Page number, 25 per page.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"username": "{{ username }}",
|
||||
"mode": "followers",
|
||||
"count": 2,
|
||||
"page": 1,
|
||||
"total_pages": 1,
|
||||
"followers": [
|
||||
{
|
||||
"uid": "UUID",
|
||||
"username": "bob_test",
|
||||
"bio": "Building things.",
|
||||
"is_following": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="list-following",
|
||||
method="GET",
|
||||
path="/profile/{username}/following",
|
||||
title="List following",
|
||||
summary="List the users a profile follows, 25 per page. Returns JSON.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"{{ username }}",
|
||||
"Target username.",
|
||||
),
|
||||
field(
|
||||
"page",
|
||||
"query",
|
||||
"integer",
|
||||
False,
|
||||
"1",
|
||||
"Page number, 25 per page.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"username": "{{ username }}",
|
||||
"mode": "following",
|
||||
"count": 1,
|
||||
"page": 1,
|
||||
"total_pages": 1,
|
||||
"following": [
|
||||
{
|
||||
"uid": "UUID",
|
||||
"username": "alice_test",
|
||||
"bio": "",
|
||||
"is_following": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="leaderboard",
|
||||
method="GET",
|
||||
path="/leaderboard",
|
||||
title="View the leaderboard",
|
||||
summary="Top contributors by stars. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="avatar",
|
||||
method="GET",
|
||||
path="/avatar/{style}/{seed}",
|
||||
title="Generate an avatar",
|
||||
summary="Deterministic SVG avatar for a seed. Returns an image.",
|
||||
auth="public",
|
||||
interactive=False,
|
||||
params=[
|
||||
field(
|
||||
"style",
|
||||
"path",
|
||||
"enum",
|
||||
True,
|
||||
"multiavatar",
|
||||
"Avatar style.",
|
||||
["multiavatar"],
|
||||
),
|
||||
field(
|
||||
"seed",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"{{ username }}",
|
||||
"Seed string, usually a username.",
|
||||
),
|
||||
field("size", "query", "int", False, "128", "Pixel size."),
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
550
devplacepy/docs_api/groups/project_files.py
Normal file
550
devplacepy/docs_api/groups/project_files.py
Normal file
@ -0,0 +1,550 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "project-files",
|
||||
"title": "Project Filesystem",
|
||||
"intro": """
|
||||
# Project Filesystem
|
||||
|
||||
Each project carries a full virtual filesystem - directories and files - so a project can hold a
|
||||
complete software project. Reading is public (anyone can browse a project's tree); creating,
|
||||
editing, uploading, moving and deleting require the project owner. Text files are editable inline;
|
||||
binary files are uploaded and served from `/static/uploads/project_files/...`.
|
||||
|
||||
Paths are relative POSIX paths inside the project (for example `src/main.py`). Parent directories
|
||||
are created automatically on write, upload and mkdir. Paths containing `..`, null bytes or empty
|
||||
segments are rejected.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, status codes); see [Authentication](/docs/authentication.html) for the four ways to
|
||||
sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="project-files-list",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/files",
|
||||
title="List a project's files",
|
||||
summary="Return the flat list of files and directories in a project.",
|
||||
auth="public",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
)
|
||||
],
|
||||
sample_response={
|
||||
"project": {"uid": "PROJECT_UID", "slug": "PROJECT_SLUG"},
|
||||
"files": [
|
||||
{
|
||||
"path": "src/main.py",
|
||||
"name": "main.py",
|
||||
"type": "file",
|
||||
"is_binary": False,
|
||||
"size": 42,
|
||||
}
|
||||
],
|
||||
"is_owner": False,
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-raw",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/files/raw",
|
||||
title="Read a project file",
|
||||
summary="Return one file's metadata and (for text files) its content.",
|
||||
auth="public",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"path",
|
||||
"query",
|
||||
"string",
|
||||
True,
|
||||
"src/main.py",
|
||||
"Relative file path inside the project.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"path": "src/main.py",
|
||||
"name": "main.py",
|
||||
"type": "file",
|
||||
"is_binary": False,
|
||||
"mime_type": "text/plain",
|
||||
"size": 42,
|
||||
"url": None,
|
||||
"content": "print('hello')\n",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-write",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/write",
|
||||
title="Write a text file",
|
||||
summary="Create or overwrite a text file; parent directories are created automatically.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"path",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"src/main.py",
|
||||
"Relative file path.",
|
||||
),
|
||||
field(
|
||||
"content",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"print('hello')",
|
||||
"Full file content (max 400000 chars).",
|
||||
),
|
||||
],
|
||||
notes=["Owner only; non-owners get `403`. Invalid paths return `400`."],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/projects/PROJECT_SLUG/files",
|
||||
"data": {"path": "src/main.py", "type": "file"},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-lines",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/files/lines",
|
||||
title="Read a line range",
|
||||
summary="Read a 1-indexed inclusive line range of a text file. Returns lines plus total_lines for targeting edits.",
|
||||
auth="public",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"path",
|
||||
"query",
|
||||
"string",
|
||||
True,
|
||||
"src/main.py",
|
||||
"Relative file path.",
|
||||
),
|
||||
field(
|
||||
"start",
|
||||
"query",
|
||||
"integer",
|
||||
False,
|
||||
"1",
|
||||
"First line, 1-indexed (default 1).",
|
||||
),
|
||||
field(
|
||||
"end",
|
||||
"query",
|
||||
"integer",
|
||||
False,
|
||||
"50",
|
||||
"Last line inclusive; omit or -1 for end of file.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"Text files only; binary, directory, or missing paths return `404`."
|
||||
],
|
||||
sample_response={
|
||||
"path": "src/main.py",
|
||||
"start": 1,
|
||||
"end": 2,
|
||||
"total_lines": 2,
|
||||
"lines": ["import os", "print(os.getcwd())"],
|
||||
"content": "import os\nprint(os.getcwd())",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-replace-lines",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/replace-lines",
|
||||
title="Replace a line range",
|
||||
summary="Replace lines start..end (inclusive) with new content; empty content deletes the range. Leaves the rest of the file untouched.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"path",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"src/main.py",
|
||||
"Relative file path.",
|
||||
),
|
||||
field(
|
||||
"start",
|
||||
"form",
|
||||
"integer",
|
||||
True,
|
||||
"10",
|
||||
"First line to replace (1-indexed).",
|
||||
),
|
||||
field(
|
||||
"end",
|
||||
"form",
|
||||
"integer",
|
||||
True,
|
||||
"12",
|
||||
"Last line to replace (inclusive).",
|
||||
),
|
||||
field(
|
||||
"content",
|
||||
"form",
|
||||
"textarea",
|
||||
False,
|
||||
"new code",
|
||||
"Replacement text (empty deletes the range).",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"Owner only. The preferred way to edit a large file; avoids rewriting the whole file."
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/projects/PROJECT_SLUG/files",
|
||||
"data": {"path": "src/main.py", "type": "file"},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-insert-lines",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/insert-lines",
|
||||
title="Insert lines",
|
||||
summary="Insert content before a 1-indexed line. Use at=1 to prepend and at=total_lines+1 to append.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"path",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"src/main.py",
|
||||
"Relative file path.",
|
||||
),
|
||||
field(
|
||||
"at",
|
||||
"form",
|
||||
"integer",
|
||||
True,
|
||||
"1",
|
||||
"Insert before this 1-indexed line.",
|
||||
),
|
||||
field(
|
||||
"content",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"# header",
|
||||
"Text to insert.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/projects/PROJECT_SLUG/files",
|
||||
"data": {"path": "src/main.py", "type": "file"},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-delete-lines",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/delete-lines",
|
||||
title="Delete a line range",
|
||||
summary="Delete lines start..end (inclusive) from a text file.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"path",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"src/main.py",
|
||||
"Relative file path.",
|
||||
),
|
||||
field(
|
||||
"start",
|
||||
"form",
|
||||
"integer",
|
||||
True,
|
||||
"5",
|
||||
"First line to delete (1-indexed).",
|
||||
),
|
||||
field(
|
||||
"end",
|
||||
"form",
|
||||
"integer",
|
||||
True,
|
||||
"7",
|
||||
"Last line to delete (inclusive).",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/projects/PROJECT_SLUG/files",
|
||||
"data": {"path": "src/main.py", "type": "file"},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-append",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/append",
|
||||
title="Append to a file",
|
||||
summary="Append content as new lines at the end of a text file; grow a large file across calls without resending it.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"path", "form", "string", True, "log.txt", "Relative file path."
|
||||
),
|
||||
field(
|
||||
"content",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"next chunk",
|
||||
"Text to append.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/projects/PROJECT_SLUG/files",
|
||||
"data": {"path": "log.txt", "type": "file"},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-upload",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/upload",
|
||||
title="Upload a file into a project",
|
||||
summary="Upload a file into a directory (parents created); text decodes to an editable file, otherwise stored as binary.",
|
||||
auth="user",
|
||||
encoding="multipart",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field("file", "form", "file", True, "", "The file to upload."),
|
||||
field(
|
||||
"path",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"assets",
|
||||
"Target directory, empty for the root.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/projects/PROJECT_SLUG/files",
|
||||
"data": {
|
||||
"path": "assets/logo.png",
|
||||
"type": "file",
|
||||
"is_binary": True,
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-mkdir",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/mkdir",
|
||||
title="Create a directory",
|
||||
summary="Create a directory and any missing parents.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"path",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"src/components",
|
||||
"Relative directory path.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/projects/PROJECT_SLUG/files",
|
||||
"data": {"path": "src/components", "type": "dir"},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-move",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/move",
|
||||
title="Move or rename",
|
||||
summary="Move or rename a file or directory (and its descendants).",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"from_path",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"src/old.py",
|
||||
"Existing path.",
|
||||
),
|
||||
field("to_path", "form", "string", True, "src/new.py", "New path."),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/projects/PROJECT_SLUG/files",
|
||||
"data": {"path": "src/new.py"},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-delete",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/delete",
|
||||
title="Delete a file or directory",
|
||||
summary="Delete a file, or a directory and everything under it. Project owner or an administrator; soft-deleted (restorable from admin trash). Blocked while the project is read-only.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"path",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"src/old.py",
|
||||
"Relative path to delete.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/projects/PROJECT_SLUG/files",
|
||||
"data": {"path": "src/old.py"},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-zip",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/zip",
|
||||
title="Queue a zip of files",
|
||||
summary="Archive the whole tree, or a subtree via the path query. Returns the job uid and status URL to poll with /zips/{uid}.",
|
||||
auth="public",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"path",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"src",
|
||||
"Relative file or directory to archive; empty for the whole project.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ZIP_JOB_UID",
|
||||
"status_url": "/zips/ZIP_JOB_UID",
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
66
devplacepy/docs_api/groups/push.py
Normal file
66
devplacepy/docs_api/groups/push.py
Normal file
@ -0,0 +1,66 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "push",
|
||||
"title": "Web Push",
|
||||
"intro": """
|
||||
# Web Push
|
||||
|
||||
Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then
|
||||
register a `PushSubscription` obtained from the browser's `PushManager`.
|
||||
|
||||
There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
|
||||
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
|
||||
to a subscription once its push endpoint reports it as gone. These mirror the in-app
|
||||
[Notifications](/docs/notifications.html) feed.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
four ways to sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="push-key",
|
||||
method="GET",
|
||||
path="/push.json",
|
||||
title="Get the public key",
|
||||
summary="Return the VAPID public key for subscribing.",
|
||||
auth="public",
|
||||
sample_response={"publicKey": "BASE64_VAPID_KEY"},
|
||||
),
|
||||
endpoint(
|
||||
id="push-register",
|
||||
method="POST",
|
||||
path="/push.json",
|
||||
title="Register a subscription",
|
||||
summary="Register a browser push subscription. Sends a welcome notification.",
|
||||
auth="user",
|
||||
encoding="json",
|
||||
interactive=False,
|
||||
params=[
|
||||
field(
|
||||
"endpoint",
|
||||
"json",
|
||||
"string",
|
||||
True,
|
||||
"https://fcm.googleapis.com/...",
|
||||
"Subscription endpoint URL.",
|
||||
),
|
||||
field(
|
||||
"keys",
|
||||
"json",
|
||||
"string",
|
||||
True,
|
||||
'{"p256dh":"...","auth":"..."}',
|
||||
"Subscription keys object.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
'The body must be JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.'
|
||||
],
|
||||
sample_response={"registered": True},
|
||||
),
|
||||
],
|
||||
}
|
||||
10
devplacepy/docs_api/groups/services.py
Normal file
10
devplacepy/docs_api/groups/services.py
Normal file
@ -0,0 +1,10 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
GROUP = {
|
||||
"slug": "services",
|
||||
"title": "Background Services",
|
||||
"admin": True,
|
||||
"dynamic": True,
|
||||
"intro": "",
|
||||
"endpoints": [],
|
||||
}
|
||||
195
devplacepy/docs_api/groups/social_actions.py
Normal file
195
devplacepy/docs_api/groups/social_actions.py
Normal file
@ -0,0 +1,195 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import BOOKMARK_TARGETS, REACTION_TARGETS, VOTE_TARGETS, endpoint, field
|
||||
from devplacepy.constants import REACTION_EMOJI
|
||||
|
||||
GROUP = {
|
||||
"slug": "social-actions",
|
||||
"title": "Votes, Reactions, Bookmarks & Polls",
|
||||
"intro": """
|
||||
# Votes, Reactions, Bookmarks & Polls
|
||||
|
||||
Lightweight engagement actions. The POST endpoints here are **toggles** - sending the same
|
||||
action again removes it. They return JSON when called with `X-Requested-With: fetch` (sent
|
||||
automatically by the panels below); the [Conventions & Errors](/docs/conventions.html) page
|
||||
explains that header rule and the response envelope.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
four ways to sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="votes-cast",
|
||||
method="POST",
|
||||
path="/votes/{target_type}/{target_uid}",
|
||||
title="Cast or toggle a vote",
|
||||
summary="Upvote or downvote a target. Re-sending the same value removes the vote.",
|
||||
auth="user",
|
||||
ajax=True,
|
||||
encoding="form",
|
||||
params=[
|
||||
field(
|
||||
"target_type",
|
||||
"path",
|
||||
"enum",
|
||||
True,
|
||||
"post",
|
||||
"Type of content being voted on.",
|
||||
VOTE_TARGETS,
|
||||
),
|
||||
field(
|
||||
"target_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"POST_UID",
|
||||
"UID of the target.",
|
||||
),
|
||||
field(
|
||||
"value",
|
||||
"form",
|
||||
"enum",
|
||||
True,
|
||||
"1",
|
||||
"1 to upvote, -1 to downvote.",
|
||||
["1", "-1"],
|
||||
),
|
||||
],
|
||||
sample_response={"net": 3, "up": 4, "down": 1, "value": 1},
|
||||
),
|
||||
endpoint(
|
||||
id="reactions-toggle",
|
||||
method="POST",
|
||||
path="/reactions/{target_type}/{target_uid}",
|
||||
title="Toggle an emoji reaction",
|
||||
summary="Add or remove an emoji reaction on a target.",
|
||||
auth="user",
|
||||
ajax=True,
|
||||
encoding="form",
|
||||
params=[
|
||||
field(
|
||||
"target_type",
|
||||
"path",
|
||||
"enum",
|
||||
True,
|
||||
"post",
|
||||
"Type of content being reacted to.",
|
||||
REACTION_TARGETS,
|
||||
),
|
||||
field(
|
||||
"target_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"POST_UID",
|
||||
"UID of the target.",
|
||||
),
|
||||
field(
|
||||
"emoji",
|
||||
"form",
|
||||
"enum",
|
||||
True,
|
||||
REACTION_EMOJI[0],
|
||||
"One of the allowed reaction emoji.",
|
||||
REACTION_EMOJI,
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"counts": {REACTION_EMOJI[0]: 2},
|
||||
"mine": [REACTION_EMOJI[0]],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="bookmarks-toggle",
|
||||
method="POST",
|
||||
path="/bookmarks/{target_type}/{target_uid}",
|
||||
title="Toggle a bookmark",
|
||||
summary="Save or unsave a target to your bookmarks.",
|
||||
auth="user",
|
||||
ajax=True,
|
||||
encoding="none",
|
||||
params=[
|
||||
field(
|
||||
"target_type",
|
||||
"path",
|
||||
"enum",
|
||||
True,
|
||||
"post",
|
||||
"Type of content to bookmark.",
|
||||
BOOKMARK_TARGETS,
|
||||
),
|
||||
field(
|
||||
"target_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"POST_UID",
|
||||
"UID of the target.",
|
||||
),
|
||||
],
|
||||
sample_response={"saved": True},
|
||||
),
|
||||
endpoint(
|
||||
id="bookmarks-saved",
|
||||
method="GET",
|
||||
path="/bookmarks/saved",
|
||||
title="View saved bookmarks",
|
||||
summary="Render your saved content. Returns an HTML page.",
|
||||
auth="user",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"before",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"",
|
||||
"Pagination cursor (created_at of the last item).",
|
||||
)
|
||||
],
|
||||
notes=[
|
||||
"Bookmarks target posts, projects, gists, and news; see [Posts, Comments, Projects, Gists & News](/docs/content.html)."
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="polls-vote",
|
||||
method="POST",
|
||||
path="/polls/{poll_uid}/vote",
|
||||
title="Vote in a poll",
|
||||
summary="Cast, change, or clear your vote on a poll option.",
|
||||
auth="user",
|
||||
ajax=True,
|
||||
encoding="form",
|
||||
params=[
|
||||
field(
|
||||
"poll_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"POLL_UID",
|
||||
"UID of the poll.",
|
||||
),
|
||||
field(
|
||||
"option_uid",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"OPTION_UID",
|
||||
"UID of the chosen option.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"You hold at most one vote per poll, and only your latest vote counts. "
|
||||
"Voting a different option replaces your previous choice; voting your current "
|
||||
"option again removes the vote.",
|
||||
],
|
||||
sample_response={
|
||||
"question": "Best editor?",
|
||||
"options": [{"uid": "OPTION_UID", "label": "Vim", "votes": 5}],
|
||||
"total": 5,
|
||||
"voted": "OPTION_UID",
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
233
devplacepy/docs_api/groups/tools.py
Normal file
233
devplacepy/docs_api/groups/tools.py
Normal file
@ -0,0 +1,233 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "tools",
|
||||
"title": "Tools (SEO & DeepSearch)",
|
||||
"intro": """
|
||||
# Tools: SEO Diagnostics & DeepSearch
|
||||
|
||||
Two public developer tools that run as background jobs.
|
||||
|
||||
**SEO Diagnostics** audits a URL or sitemap with a headless browser and runs a broad battery of
|
||||
technical, on-page, structured-data, Core Web Vitals, accessibility and AI-readiness checks.
|
||||
|
||||
**DeepSearch** is a multi-agent deep web researcher that crawls and indexes sources, then
|
||||
synthesises a cited report with confidence scoring and gap analysis, plus a grounded chat over
|
||||
the results.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html). These are
|
||||
**capability URLs**: the job `uid` is an unguessable identifier, so anyone holding it can read the
|
||||
status and report.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="tools-seo-run",
|
||||
method="POST",
|
||||
path="/tools/seo/run",
|
||||
title="Queue an SEO audit",
|
||||
summary="Start a background SEO audit of a URL or sitemap. Returns the job uid plus status and websocket URLs.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("url", "form", "string", True, "https://example.com", "Page URL or sitemap.xml URL to audit."),
|
||||
field("mode", "form", "enum", False, "url", "'url' (single page) or 'sitemap' (crawl).", ["url", "sitemap"]),
|
||||
field("max_pages", "form", "integer", False, "10", "Max pages to crawl in sitemap mode (1-50)."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "SEO_JOB_UID",
|
||||
"status_url": "/tools/seo/SEO_JOB_UID",
|
||||
"ws_url": "/tools/seo/SEO_JOB_UID/ws",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-seo-status",
|
||||
method="GET",
|
||||
path="/tools/seo/{uid}",
|
||||
title="SEO audit status",
|
||||
summary="Poll an SEO audit. Once done, score, grade and report_url are populated.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid returned when the audit was queued."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "SEO_JOB_UID",
|
||||
"kind": "seo",
|
||||
"status": "done",
|
||||
"target": "https://example.com",
|
||||
"mode": "url",
|
||||
"ws_url": "/tools/seo/SEO_JOB_UID/ws",
|
||||
"report_url": "/tools/seo/SEO_JOB_UID/report",
|
||||
"score": 82,
|
||||
"grade": "B",
|
||||
"page_count": 1,
|
||||
"error": None,
|
||||
"created_at": "2026-06-14T10:00:00+00:00",
|
||||
"completed_at": "2026-06-14T10:00:18+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-seo-report",
|
||||
method="GET",
|
||||
path="/tools/seo/{uid}/report",
|
||||
title="SEO audit report",
|
||||
summary="Full categorised report: overall score, per-category subscores, and every check with its recommendation. Negotiates HTML or JSON.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid of a finished audit."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "SEO_JOB_UID",
|
||||
"status": "done",
|
||||
"target": "https://example.com",
|
||||
"score": 82,
|
||||
"grade": "B",
|
||||
"page_count": 1,
|
||||
"counts": {"pass": 40, "warn": 8, "fail": 3, "info": 5, "skip": 0},
|
||||
"categories": {"crawlability": {"score": 90, "pass": 9, "warn": 1, "fail": 0}},
|
||||
"pages": [{"url": "https://example.com", "status": 200, "score": 82, "grade": "B"}],
|
||||
"checks": [
|
||||
{
|
||||
"id": "meta.title_present",
|
||||
"category": "meta",
|
||||
"title": "Title tag",
|
||||
"status": "pass",
|
||||
"severity": "high",
|
||||
"value": "Example Domain",
|
||||
"recommendation": "",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
],
|
||||
"site": {"robots": {"status": 200}, "sitemap": {"status": 200, "url_count": 12}},
|
||||
"generated_at": "2026-06-14T10:00:18+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-seo-screenshot",
|
||||
method="GET",
|
||||
path="/tools/seo/{uid}/screenshot/{index}",
|
||||
title="SEO audit page screenshot",
|
||||
summary="Stream the rendered screenshot (image/png) captured for the audited page at the given zero-based index.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid of a finished audit."),
|
||||
field("index", "path", "integer", True, "0", "Zero-based index of the audited page."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="tools-seo-meta-status",
|
||||
method="GET",
|
||||
path="/tools/seo-meta/{target_type}/{target_uid}",
|
||||
title="Generated SEO metadata for a content item",
|
||||
summary="Read the clean, AI-generated SEO title, description and keywords for a published post, project, gist, news article or issue. Returns a plain-content default with status 'pending' until the AI value is ready.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("target_type", "path", "enum", True, "post", "Content type.", ["post", "project", "gist", "news", "issue"]),
|
||||
field("target_uid", "path", "string", True, "CONTENT_UID", "The content uid (or issue number)."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "SEO_META_UID",
|
||||
"target_type": "post",
|
||||
"target_uid": "CONTENT_UID",
|
||||
"seo_title": "Building a fast SQLite social network",
|
||||
"seo_description": "How DevPlace keeps SQLite synchronous and still serves a developer social network fast, with WAL, mmap and batch query helpers.",
|
||||
"seo_keywords": "sqlite, fastapi, social network, performance, wal",
|
||||
"status": "ready",
|
||||
"source": "ai",
|
||||
"generated_at": "2026-06-14T10:00:18+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-deepsearch-run",
|
||||
method="POST",
|
||||
path="/tools/deepsearch/run",
|
||||
title="Queue a DeepSearch research job",
|
||||
summary="Start a multi-agent deep web research job. Returns the job uid plus status and websocket URLs. Connect ws_url for live progress frames.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("query", "form", "string", True, "history of the transistor", "The research question to investigate."),
|
||||
field("depth", "form", "integer", False, "2", "Research depth (1-4)."),
|
||||
field("max_pages", "form", "integer", False, "12", "Maximum sources to crawl (1-30)."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "DEEPSEARCH_JOB_UID",
|
||||
"status_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID",
|
||||
"ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/ws",
|
||||
"progress_frames_note": (
|
||||
"The ws_url stream emits newline-delimited JSON frames; the set is append-only "
|
||||
"and the first frame carries version:1. Each frame has a type: phase "
|
||||
"(phase, index, total, label), stage, substep (planning angles), queries, "
|
||||
"candidates, rsearch, progress (done, total, url), page_loaded (source, render, "
|
||||
"elapsed_ms, done, total), page_cached, page_skipped (reason), page_duplicate, "
|
||||
"embed_batch (batch, total_batches, backend, done, total), embed_done (backend, "
|
||||
"chunk_count), agent (agent, status start|done, elapsed_ms, tokens_in, tokens_out), "
|
||||
"report_ready, done (session_url), failed (message)."
|
||||
),
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-deepsearch-status",
|
||||
method="GET",
|
||||
path="/tools/deepsearch/{uid}",
|
||||
title="DeepSearch status",
|
||||
summary="Poll a DeepSearch job. Once done, score, confidence and session_url are populated.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "DEEPSEARCH_JOB_UID", "DeepSearch job uid returned when the run was queued."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "DEEPSEARCH_JOB_UID",
|
||||
"kind": "deepsearch",
|
||||
"status": "done",
|
||||
"query": "history of the transistor",
|
||||
"depth": 2,
|
||||
"max_pages": 12,
|
||||
"ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/ws",
|
||||
"chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
|
||||
"session_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/session",
|
||||
"score": 78,
|
||||
"confidence": 0.72,
|
||||
"source_diversity": 0.64,
|
||||
"page_count": 11,
|
||||
"chunk_count": 240,
|
||||
"error": None,
|
||||
"created_at": "2026-06-14T10:00:00+00:00",
|
||||
"completed_at": "2026-06-14T10:01:40+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-deepsearch-session",
|
||||
method="GET",
|
||||
path="/tools/deepsearch/{uid}/session",
|
||||
title="DeepSearch report",
|
||||
summary="Full cited research report: summary, findings, gaps, sources and metrics. Negotiates HTML or JSON.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "DEEPSEARCH_JOB_UID", "DeepSearch job uid of a finished run."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "DEEPSEARCH_JOB_UID",
|
||||
"status": "done",
|
||||
"query": "history of the transistor",
|
||||
"score": 78,
|
||||
"confidence": 0.72,
|
||||
"source_diversity": 0.64,
|
||||
"page_count": 11,
|
||||
"chunk_count": 240,
|
||||
"summary": "The transistor was invented at Bell Labs in 1947...",
|
||||
"findings": [
|
||||
{"title": "Invention", "detail": "...", "confidence": 0.8, "citations": [1]}
|
||||
],
|
||||
"gaps": ["Limited coverage of later MOSFET developments."],
|
||||
"sources": [{"url": "https://example.com", "title": "Example", "source": "httpx"}],
|
||||
"chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
|
||||
"export_md_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.md",
|
||||
"export_json_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.json",
|
||||
"export_pdf_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.pdf",
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
106
devplacepy/docs_api/groups/uploads.py
Normal file
106
devplacepy/docs_api/groups/uploads.py
Normal file
@ -0,0 +1,106 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "uploads",
|
||||
"title": "Uploads",
|
||||
"intro": """
|
||||
# Uploads
|
||||
|
||||
Attachment storage. Upload a file - or hand the server a public URL to fetch - to receive an
|
||||
attachment record, then reference its `uid` in an `attachment_uids` field when creating a post,
|
||||
comment, project, gist, message, or issue - see
|
||||
[Posts, Comments, Projects, Gists & News](/docs/content.html). Images and videos embed and
|
||||
play inline once posted; other types render as download links. The record's `is_image` and
|
||||
`is_video` flags indicate how the file is displayed.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
four ways to sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="uploads-upload",
|
||||
method="POST",
|
||||
path="/uploads/upload",
|
||||
title="Upload a file",
|
||||
summary="Store a file and return its attachment record.",
|
||||
auth="user",
|
||||
encoding="multipart",
|
||||
params=[field("file", "form", "file", True, "", "The file to upload.")],
|
||||
notes=[
|
||||
"Allowed file types and the size limit are configured by administrators. Images and common video formats (mp4, webm, ogv, mov, m4v) are accepted by default.",
|
||||
"Returns `201` on success, `413` if too large, `415` if the type is not allowed.",
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ATTACHMENT_UID",
|
||||
"filename": "clip.mp4",
|
||||
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.mp4",
|
||||
"size": 20480,
|
||||
"is_image": False,
|
||||
"is_video": True,
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="uploads-upload-url",
|
||||
method="POST",
|
||||
path="/uploads/upload-url",
|
||||
title="Attach a file from a URL",
|
||||
summary="Download a public URL on the server and store it as an attachment.",
|
||||
auth="user",
|
||||
params=[
|
||||
field(
|
||||
"url",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"https://example.com/photo.png",
|
||||
"Public http(s) URL of the file to download and attach.",
|
||||
),
|
||||
field(
|
||||
"filename",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"photo.png",
|
||||
"Optional filename with an allowed extension, used when the URL has no clear name.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
"The server fetches the URL (SSRF-guarded, size-capped) and stores the bytes through the same pipeline as a direct upload; the response is identical to Upload a file.",
|
||||
"The file type is taken from the URL path or the response Content-Type. Returns `201` on success, `413` if too large, `415` if the type cannot be resolved to an allowed type, `400` for an unreachable or private address.",
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ATTACHMENT_UID",
|
||||
"filename": "photo.png",
|
||||
"url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
|
||||
"size": 20480,
|
||||
"is_image": True,
|
||||
"is_video": False,
|
||||
"mime_type": "image/png",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="uploads-delete",
|
||||
method="DELETE",
|
||||
path="/uploads/delete/{attachment_uid}",
|
||||
title="Delete an attachment",
|
||||
summary="Delete an attachment you own; administrators may delete any user's attachment. Soft-deleted (hidden everywhere but restorable; garbage-collected later).",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"attachment_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"ATTACHMENT_UID",
|
||||
"UID of the attachment.",
|
||||
)
|
||||
],
|
||||
sample_response={"status": "deleted"},
|
||||
),
|
||||
],
|
||||
}
|
||||
123
devplacepy/docs_api/negotiation.py
Normal file
123
devplacepy/docs_api/negotiation.py
Normal file
@ -0,0 +1,123 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy import schemas
|
||||
from devplacepy.docs_examples import schema_example, action_example
|
||||
from ._shared import NON_BODY_ENDPOINTS
|
||||
|
||||
|
||||
def _classify(ep):
|
||||
if ep["id"] in _PAGE_RESPONSES or ep["id"] in _ACTION_RESPONSES:
|
||||
return "negotiable"
|
||||
if ep.get("ajax"):
|
||||
return "ajax"
|
||||
if ep["id"] in NON_BODY_ENDPOINTS:
|
||||
return "none"
|
||||
return "json"
|
||||
|
||||
|
||||
_PAGE_RESPONSES = {
|
||||
"auth-signup": schemas.AuthPageOut,
|
||||
"auth-login": schemas.AuthPageOut,
|
||||
"auth-forgot-password": schemas.AuthPageOut,
|
||||
"auth-reset-password": schemas.AuthPageOut,
|
||||
"home": schemas.LandingOut,
|
||||
"feed-list": schemas.FeedOut,
|
||||
"posts-detail": schemas.PostDetailOut,
|
||||
"projects-list": schemas.ProjectsOut,
|
||||
"projects-detail": schemas.ProjectDetailOut,
|
||||
"gists-list": schemas.GistsOut,
|
||||
"gists-detail": schemas.GistDetailOut,
|
||||
"news-list": schemas.NewsListOut,
|
||||
"news-detail": schemas.NewsDetailOut,
|
||||
"profile-detail": schemas.ProfileOut,
|
||||
"leaderboard": schemas.LeaderboardOut,
|
||||
"messages-inbox": schemas.MessagesOut,
|
||||
"notifications-list": schemas.NotificationsOut,
|
||||
"issues-list": schemas.IssuesOut,
|
||||
"issues-detail": schemas.IssueDetailOut,
|
||||
"issues-attachments-list": schemas.IssueAttachmentsOut,
|
||||
"bookmarks-saved": schemas.SavedOut,
|
||||
"admin-users": schemas.AdminUsersOut,
|
||||
"admin-news-list": schemas.AdminNewsOut,
|
||||
"admin-settings-get": schemas.AdminSettingsOut,
|
||||
"admin-audit-log": schemas.AuditLogOut,
|
||||
"admin-audit-event": schemas.AuditEventOut,
|
||||
"admin-bots-monitor": schemas.AdminBotsOut,
|
||||
"containers-admin-edit-page": schemas.AdminContainerEditOut,
|
||||
}
|
||||
|
||||
_ACTION_RESPONSES = {
|
||||
"auth-signup-post": ("/feed", {"username": "alice"}),
|
||||
"auth-login-post": ("/feed", None),
|
||||
"auth-forgot-password-post": ("/auth/forgot-password?sent=1", None),
|
||||
"auth-reset-password-post": ("/auth/login", None),
|
||||
"auth-logout": ("/", None),
|
||||
"posts-create": (
|
||||
"/posts/POST_SLUG",
|
||||
{"uid": "POST_UID", "slug": "POST_SLUG", "url": "/posts/POST_SLUG"},
|
||||
),
|
||||
"posts-edit": (
|
||||
"/posts/POST_SLUG",
|
||||
{"uid": "POST_UID", "slug": "POST_SLUG", "url": "/posts/POST_SLUG"},
|
||||
),
|
||||
"posts-delete": ("/feed", None),
|
||||
"comments-create": (
|
||||
"/posts/POST_SLUG#comment-COMMENT_UID",
|
||||
{"uid": "COMMENT_UID", "url": "/posts/POST_SLUG#comment-COMMENT_UID"},
|
||||
),
|
||||
"comments-delete": (
|
||||
"/posts/POST_SLUG",
|
||||
{"deleted_uid": "COMMENT_UID", "target_type": "post", "target_uid": "TARGET_UID"},
|
||||
),
|
||||
"projects-create": (
|
||||
"/projects/PROJECT_SLUG",
|
||||
{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "url": "/projects/PROJECT_SLUG"},
|
||||
),
|
||||
"projects-delete": ("/projects", None),
|
||||
"gists-create": (
|
||||
"/gists/GIST_SLUG",
|
||||
{"uid": "GIST_UID", "slug": "GIST_SLUG", "url": "/gists/GIST_SLUG"},
|
||||
),
|
||||
"gists-edit": (
|
||||
"/gists/GIST_SLUG",
|
||||
{"uid": "GIST_UID", "slug": "GIST_SLUG", "url": "/gists/GIST_SLUG"},
|
||||
),
|
||||
"gists-delete": ("/gists", None),
|
||||
"profile-update": ("/profile/YOUR_USERNAME", None),
|
||||
"profile-customization-global": (
|
||||
"/profile/YOUR_USERNAME",
|
||||
{"url": "/profile/YOUR_USERNAME", "cust_disable_global": True},
|
||||
),
|
||||
"profile-customization-pagetype": (
|
||||
"/profile/YOUR_USERNAME",
|
||||
{"url": "/profile/YOUR_USERNAME", "cust_disable_pagetype": True},
|
||||
),
|
||||
"follow-user": ("/profile/bob_test", None),
|
||||
"unfollow-user": ("/profile/bob_test", None),
|
||||
"messages-send": ("/messages?with_uid=RECEIVER_UID", {"uid": "MESSAGE_UID"}),
|
||||
"notifications-open": ("/posts/POST_SLUG#comment-COMMENT_UID", None),
|
||||
"notifications-mark-read": ("/notifications", None),
|
||||
"notifications-mark-all-read": ("/notifications", None),
|
||||
"issues-comment": ("/issues/12", {"comment_id": 1}),
|
||||
"issues-status": ("/issues/12", {"state": "closed"}),
|
||||
"admin-user-role": ("/admin/users", None),
|
||||
"admin-user-password": ("/admin/users", None),
|
||||
"admin-user-toggle": ("/admin/users", None),
|
||||
"admin-news-toggle": ("/admin/news", None),
|
||||
"admin-news-publish": ("/admin/news", None),
|
||||
"admin-news-landing": ("/admin/news", None),
|
||||
"admin-news-delete": ("/admin/news", None),
|
||||
"admin-settings": ("/admin/settings", None),
|
||||
}
|
||||
|
||||
|
||||
def _apply_negotiated_responses(groups):
|
||||
for group in groups:
|
||||
for ep in group.get("endpoints", []) or []:
|
||||
if ep.get("sample_response") is None:
|
||||
if ep["id"] in _PAGE_RESPONSES:
|
||||
ep["sample_response"] = schema_example(_PAGE_RESPONSES[ep["id"]])
|
||||
elif ep["id"] in _ACTION_RESPONSES:
|
||||
redirect, data = _ACTION_RESPONSES[ep["id"]]
|
||||
ep["sample_response"] = action_example(redirect, data)
|
||||
ep["negotiation"] = _classify(ep)
|
||||
47
devplacepy/docs_api/render.py
Normal file
47
devplacepy/docs_api/render.py
Normal file
@ -0,0 +1,47 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .groups import ORDERED_GROUPS as API_GROUPS
|
||||
|
||||
|
||||
_GROUPS_BY_SLUG = {group["slug"]: group for group in API_GROUPS}
|
||||
|
||||
|
||||
def get_group(slug):
|
||||
return _GROUPS_BY_SLUG.get(slug)
|
||||
|
||||
|
||||
def api_doc_pages():
|
||||
return [
|
||||
{
|
||||
"slug": group["slug"],
|
||||
"title": group["title"],
|
||||
"kind": "api",
|
||||
"admin": group.get("admin", False),
|
||||
"dynamic": group.get("dynamic", False),
|
||||
}
|
||||
for group in API_GROUPS
|
||||
]
|
||||
|
||||
|
||||
def _substitute(value, replacements):
|
||||
if isinstance(value, str):
|
||||
for token, replacement in replacements.items():
|
||||
value = value.replace(token, replacement)
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return [_substitute(item, replacements) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _substitute(item, replacements) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def render_group(slug, base, username, api_key):
|
||||
group = get_group(slug)
|
||||
if not group:
|
||||
return None
|
||||
replacements = {
|
||||
"{{ base }}": base,
|
||||
"{{ username }}": username or "YOUR_USERNAME",
|
||||
"{{ api_key }}": api_key or "YOUR_API_KEY",
|
||||
}
|
||||
return _substitute(group, replacements)
|
||||
154
devplacepy/docs_api/services_group.py
Normal file
154
devplacepy/docs_api/services_group.py
Normal file
@ -0,0 +1,154 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ._shared import SERVICE_ACTIONS, endpoint, field
|
||||
from .negotiation import _classify
|
||||
|
||||
|
||||
def _service_control_endpoints():
|
||||
endpoints = []
|
||||
for action, summary in SERVICE_ACTIONS:
|
||||
endpoints.append(
|
||||
endpoint(
|
||||
id=f"services-{action.replace('-', '')}",
|
||||
method="POST",
|
||||
path=f"/admin/services/{{name}}/{action}",
|
||||
title=f"Service: {action}",
|
||||
summary=summary + ".",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"name",
|
||||
"path",
|
||||
required=True,
|
||||
example="news",
|
||||
description="Registered service name.",
|
||||
)
|
||||
],
|
||||
sample_response={"ok": True},
|
||||
)
|
||||
)
|
||||
return endpoints
|
||||
|
||||
|
||||
def _field_to_param(spec):
|
||||
description = spec["label"]
|
||||
if spec.get("help"):
|
||||
description = f"{spec['label']} - {spec['help']}"
|
||||
value = "" if spec.get("secret") else str(spec.get("value", ""))
|
||||
if spec.get("secret"):
|
||||
description += " Leave blank to keep the current value."
|
||||
if spec["type"] == "bool":
|
||||
return field(
|
||||
spec["key"], "form", "enum", False, value or "0", description, ["1", "0"]
|
||||
)
|
||||
if spec["type"] == "select":
|
||||
options = [option["value"] for option in spec.get("options") or []]
|
||||
return field(
|
||||
spec["key"],
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
value or (options[0] if options else ""),
|
||||
description,
|
||||
options,
|
||||
)
|
||||
if spec["type"] in ("int", "float"):
|
||||
return field(spec["key"], "form", "int", False, value, description)
|
||||
return field(spec["key"], "form", "string", False, value, description)
|
||||
|
||||
|
||||
def _service_section(service):
|
||||
enabled = "yes" if service.get("enabled") else "no"
|
||||
groups = ", ".join(group["name"] for group in service.get("field_groups", []))
|
||||
lines = [
|
||||
f"## {service.get('title') or service['name']}",
|
||||
"",
|
||||
service.get("description", ""),
|
||||
"",
|
||||
f"- Service name: `{service['name']}`",
|
||||
f"- Enabled: {enabled}",
|
||||
f"- Run interval: {service.get('interval_seconds', 0)}s",
|
||||
]
|
||||
if groups:
|
||||
lines.append(f"- Configuration groups: {groups}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_services_group(services, base):
|
||||
intro_parts = [
|
||||
"# Background Services",
|
||||
"",
|
||||
"DevPlace runs background services supervised by a single manager. Each service has its "
|
||||
"own enable flag, run interval, live status, metrics, and a rolling log buffer. Manage "
|
||||
"them from the admin panel at `/admin/services` or with the endpoints below.",
|
||||
"",
|
||||
"Control a service by its `name` (shown per service): start or stop it, trigger a single "
|
||||
"run, clear its logs, or save its configuration. `GET /admin/services/data` returns the "
|
||||
"live status, metrics, and log tail for every service.",
|
||||
"",
|
||||
"See the [Admin API](/docs/admin.html) for the rest of administration; the Devii assistant "
|
||||
"is itself a service, configured in [Configuration and CLI](/docs/devii-config.html).",
|
||||
]
|
||||
config_endpoints = []
|
||||
for service in services:
|
||||
intro_parts.append("")
|
||||
intro_parts.append(_service_section(service))
|
||||
params = [
|
||||
_field_to_param(spec)
|
||||
for spec in service.get("fields", [])
|
||||
if not spec["key"].endswith("_enabled")
|
||||
]
|
||||
config_endpoints.append(
|
||||
endpoint(
|
||||
id=f"services-config-{service['name']}",
|
||||
method="POST",
|
||||
path=f"/admin/services/{service['name']}/config",
|
||||
title=f"Configure {service.get('title') or service['name']}",
|
||||
summary=f"Save configuration for the {service['name']} service. Empty values keep the current setting.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=params,
|
||||
notes=[
|
||||
"Use start/stop to enable or disable the service; this saves the remaining settings."
|
||||
],
|
||||
)
|
||||
)
|
||||
control_endpoints = [
|
||||
endpoint(
|
||||
id="services-data",
|
||||
method="GET",
|
||||
path="/admin/services/data",
|
||||
title="Service status",
|
||||
summary="Live status, metrics, and log tail for every background service.",
|
||||
auth="admin",
|
||||
sample_response={
|
||||
"services": [{"name": "news", "status": "running", "enabled": True}]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="services-detail-data",
|
||||
method="GET",
|
||||
path="/admin/services/{name}/data",
|
||||
title="One service status",
|
||||
summary="Live status, metrics, and log tail for a single background service.",
|
||||
auth="admin",
|
||||
params=[field("name", "path", "string", True, "news", "Service name.")],
|
||||
sample_response={
|
||||
"service": {"name": "news", "status": "running", "enabled": True}
|
||||
},
|
||||
),
|
||||
*_service_control_endpoints(),
|
||||
]
|
||||
endpoints = control_endpoints + config_endpoints
|
||||
for ep in endpoints:
|
||||
ep["negotiation"] = _classify(ep)
|
||||
return {
|
||||
"slug": "services",
|
||||
"title": "Background Services",
|
||||
"admin": True,
|
||||
"intro": "\n".join(intro_parts),
|
||||
"endpoints": endpoints,
|
||||
}
|
||||
@ -26,6 +26,7 @@ from devplacepy.database import (
|
||||
get_user_media,
|
||||
search_users_by_username,
|
||||
mark_notifications_read_by_target,
|
||||
resolve_object_url,
|
||||
)
|
||||
from devplacepy.content import can_view_project, enrich_items
|
||||
from devplacepy.utils import (
|
||||
@ -209,6 +210,7 @@ async def profile_page(
|
||||
"time_ago": time_ago(p["created_at"]),
|
||||
"created_at": p["created_at"],
|
||||
"uid": p["uid"],
|
||||
"url": resolve_object_url("post", p["uid"]),
|
||||
}
|
||||
)
|
||||
comments_table = get_table("comments")
|
||||
@ -228,6 +230,7 @@ async def profile_page(
|
||||
"created_at": c["created_at"],
|
||||
"uid": target_uid,
|
||||
"target_type": target_type,
|
||||
"url": f"{resolve_object_url(target_type, target_uid)}#comment-{c['uid']}",
|
||||
}
|
||||
)
|
||||
activities.sort(key=lambda a: a["created_at"], reverse=True)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
147
devplacepy/schemas/__init__.py
Normal file
147
devplacepy/schemas/__init__.py
Normal file
@ -0,0 +1,147 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from devplacepy.schemas.base import (
|
||||
ActionResult,
|
||||
ErrorOut,
|
||||
ValidationErrorOut,
|
||||
_Out,
|
||||
)
|
||||
from devplacepy.schemas.content import (
|
||||
AttachmentOut,
|
||||
BadgeOut,
|
||||
CommentEditOut,
|
||||
CommentItemOut,
|
||||
CommentOut,
|
||||
GistOut,
|
||||
MessageOut,
|
||||
NewsOut,
|
||||
NotificationOut,
|
||||
PollOptionOut,
|
||||
PollOut,
|
||||
PostOut,
|
||||
ProjectFileOut,
|
||||
ProjectFileRawOut,
|
||||
ProjectFilesOut,
|
||||
ProjectOut,
|
||||
ReactionsOut,
|
||||
UserOut,
|
||||
VotesOut,
|
||||
)
|
||||
from devplacepy.schemas.listings import (
|
||||
ConversationOut,
|
||||
FeedItemOut,
|
||||
FeedOut,
|
||||
GistDetailOut,
|
||||
GistItemOut,
|
||||
GistsOut,
|
||||
LeaderboardEntryOut,
|
||||
LeaderboardOut,
|
||||
MessageItemOut,
|
||||
MessagesOut,
|
||||
NewsDetailOut,
|
||||
NewsListItemOut,
|
||||
NewsListOut,
|
||||
NotificationGroupOut,
|
||||
NotificationItemOut,
|
||||
NotificationsOut,
|
||||
PostDetailOut,
|
||||
ProjectDetailOut,
|
||||
ProjectListItemOut,
|
||||
ProjectsOut,
|
||||
SavedItemOut,
|
||||
SavedOut,
|
||||
)
|
||||
from devplacepy.schemas.profile import (
|
||||
MediaItemOut,
|
||||
ProfileOut,
|
||||
TelegramPairOut,
|
||||
)
|
||||
from devplacepy.schemas.issues import (
|
||||
IssueAttachmentsOut,
|
||||
IssueCommentOut,
|
||||
IssueDetailOut,
|
||||
IssueItemOut,
|
||||
IssueJobOut,
|
||||
IssuesOut,
|
||||
)
|
||||
from devplacepy.schemas.containers import (
|
||||
AdminBotsOut,
|
||||
AdminContainerEditOut,
|
||||
AdminContainerInstanceOut,
|
||||
AdminContainersOut,
|
||||
BotFrameOut,
|
||||
ContainersOut,
|
||||
InstanceOut,
|
||||
ScheduleOut,
|
||||
)
|
||||
from devplacepy.schemas.jobs import (
|
||||
DbQueryJobOut,
|
||||
DeepsearchJobOut,
|
||||
DeepsearchSessionOut,
|
||||
ForkJobOut,
|
||||
PlanningJobOut,
|
||||
SeoJobOut,
|
||||
SeoMetaOut,
|
||||
SeoReportOut,
|
||||
ZipJobOut,
|
||||
)
|
||||
from devplacepy.schemas.backups import (
|
||||
BackupDashboardOut,
|
||||
BackupJobOut,
|
||||
BackupOut,
|
||||
BackupScheduleOut,
|
||||
BackupStoragePathOut,
|
||||
)
|
||||
from devplacepy.schemas.admin import (
|
||||
AdminMediaItemOut,
|
||||
AdminMediaOut,
|
||||
AdminNewsItemOut,
|
||||
AdminNewsOut,
|
||||
AdminNotificationsOut,
|
||||
AdminSettingsOut,
|
||||
AdminTrashOut,
|
||||
AdminUserOut,
|
||||
AdminUsersOut,
|
||||
TrashItemOut,
|
||||
)
|
||||
from devplacepy.schemas.gateway import (
|
||||
GatewayUsageOut,
|
||||
UserAiUsageOut,
|
||||
)
|
||||
from devplacepy.schemas.auth import (
|
||||
AuthPageOut,
|
||||
DeviiPageOut,
|
||||
LandingArticleOut,
|
||||
LandingOut,
|
||||
LandingPostOut,
|
||||
)
|
||||
from devplacepy.schemas.audit import (
|
||||
AuditEntryOut,
|
||||
AuditEventOut,
|
||||
AuditLinkOut,
|
||||
AuditLogOut,
|
||||
)
|
||||
from devplacepy.schemas.dbapi import (
|
||||
DbColumnOut,
|
||||
DbQueryOut,
|
||||
DbRowOut,
|
||||
DbRowsOut,
|
||||
DbSchemaOut,
|
||||
DbTableListOut,
|
||||
DbTableOut,
|
||||
NlQueryOut,
|
||||
)
|
||||
from devplacepy.schemas.game import (
|
||||
GameCropOut,
|
||||
GameFarmOut,
|
||||
GameFarmViewOut,
|
||||
GameLeaderboardEntryOut,
|
||||
GameLeaderboardOut,
|
||||
GameLegacyOut,
|
||||
GamePerkOut,
|
||||
GamePlotOut,
|
||||
GameQuestOut,
|
||||
GameStateOut,
|
||||
)
|
||||
74
devplacepy/schemas/admin.py
Normal file
74
devplacepy/schemas/admin.py
Normal file
@ -0,0 +1,74 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.content import NewsOut, UserOut
|
||||
from devplacepy.schemas.profile import MediaItemOut
|
||||
|
||||
|
||||
class AdminUserOut(UserOut):
|
||||
role: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
posts_count: Optional[int] = None
|
||||
|
||||
|
||||
class AdminNewsItemOut(_Out):
|
||||
article: NewsOut
|
||||
time_ago: Optional[str] = None
|
||||
synced_at: Optional[str] = None
|
||||
grade: Optional[int] = None
|
||||
has_image: Optional[bool] = None
|
||||
|
||||
|
||||
class AdminUsersOut(_Out):
|
||||
users: list[AdminUserOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminNewsOut(_Out):
|
||||
articles: list[AdminNewsItemOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminSettingsOut(_Out):
|
||||
settings: dict = {}
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminNotificationsOut(_Out):
|
||||
notification_defaults: list[Any] = []
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminMediaItemOut(MediaItemOut):
|
||||
deleted_at: Optional[str] = None
|
||||
uploader: Optional[str] = None
|
||||
|
||||
|
||||
class AdminMediaOut(_Out):
|
||||
media: list[AdminMediaItemOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class TrashItemOut(_Out):
|
||||
uid: str
|
||||
table: str
|
||||
label: Optional[str] = None
|
||||
deleted_at: Optional[str] = None
|
||||
deleted_by: Optional[str] = None
|
||||
target_url: Optional[str] = None
|
||||
|
||||
|
||||
class AdminTrashOut(_Out):
|
||||
items: list[TrashItemOut] = []
|
||||
table: str = "posts"
|
||||
tables: list[dict] = []
|
||||
pagination: Optional[Any] = None
|
||||
admin_section: Optional[str] = None
|
||||
56
devplacepy/schemas/audit.py
Normal file
56
devplacepy/schemas/audit.py
Normal file
@ -0,0 +1,56 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class AuditLinkOut(_Out):
|
||||
uid: str = ""
|
||||
relation: str = ""
|
||||
object_type: str = ""
|
||||
object_uid: str = ""
|
||||
object_label: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class AuditEntryOut(_Out):
|
||||
uid: str = ""
|
||||
created_at: Optional[str] = None
|
||||
event_key: str = ""
|
||||
category: str = ""
|
||||
actor_kind: str = ""
|
||||
actor_uid: Optional[str] = None
|
||||
actor_username: Optional[str] = None
|
||||
actor_role: str = ""
|
||||
origin: str = ""
|
||||
via_agent: int = 0
|
||||
request_method: Optional[str] = None
|
||||
request_path: Optional[str] = None
|
||||
actor_ip: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
target_type: Optional[str] = None
|
||||
target_uid: Optional[str] = None
|
||||
target_label: Optional[str] = None
|
||||
old_value: Optional[str] = None
|
||||
new_value: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
metadata: Optional[Any] = None
|
||||
result: str = "success"
|
||||
|
||||
|
||||
class AuditLogOut(_Out):
|
||||
entries: list[AuditEntryOut] = []
|
||||
pagination: Optional[dict] = None
|
||||
pagination_query: Optional[str] = None
|
||||
filters: dict = {}
|
||||
options: dict = {}
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AuditEventOut(_Out):
|
||||
event: Optional[AuditEntryOut] = None
|
||||
links: list[AuditLinkOut] = []
|
||||
admin_section: Optional[str] = None
|
||||
64
devplacepy/schemas/auth.py
Normal file
64
devplacepy/schemas/auth.py
Normal file
@ -0,0 +1,64 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.content import UserOut
|
||||
|
||||
|
||||
class AuthPageOut(_Out):
|
||||
page: str = ""
|
||||
next_url: Optional[str] = None
|
||||
registration_closed: Optional[bool] = None
|
||||
sent: Optional[bool] = None
|
||||
token: Optional[str] = None
|
||||
errors: list = []
|
||||
|
||||
|
||||
class LandingArticleOut(_Out):
|
||||
uid: str = ""
|
||||
slug: str = ""
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
source_name: Optional[str] = None
|
||||
grade: int = 0
|
||||
featured: int = 0
|
||||
synced_at: Optional[str] = None
|
||||
time_ago: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
|
||||
|
||||
class LandingPostOut(_Out):
|
||||
post: Optional[Any] = None
|
||||
author: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
comment_count: int = 0
|
||||
stars: int = 0
|
||||
slug: str = ""
|
||||
|
||||
|
||||
class LandingOut(_Out):
|
||||
is_authenticated: bool = False
|
||||
user_post_count: int = 0
|
||||
user_stars: int = 0
|
||||
landing_articles: list[LandingArticleOut] = []
|
||||
landing_posts: list[LandingPostOut] = []
|
||||
|
||||
|
||||
class DeviiPageOut(_Out):
|
||||
page_title: Optional[str] = None
|
||||
meta_description: Optional[str] = None
|
||||
meta_robots: Optional[str] = None
|
||||
canonical_url: Optional[str] = None
|
||||
og_title: Optional[str] = None
|
||||
og_description: Optional[str] = None
|
||||
og_image: Optional[str] = None
|
||||
og_type: Optional[str] = None
|
||||
breadcrumbs: list = []
|
||||
page_schema: Optional[Any] = None
|
||||
prev_url: Optional[str] = None
|
||||
next_url: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
80
devplacepy/schemas/backups.py
Normal file
80
devplacepy/schemas/backups.py
Normal file
@ -0,0 +1,80 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class BackupStoragePathOut(_Out):
|
||||
key: str = ""
|
||||
label: str = ""
|
||||
path: str = ""
|
||||
size_bytes: int = 0
|
||||
size_human: str = ""
|
||||
file_count: int = 0
|
||||
exists: bool = False
|
||||
|
||||
|
||||
class BackupOut(_Out):
|
||||
uid: str = ""
|
||||
job_uid: str = ""
|
||||
target: str = ""
|
||||
label: str = ""
|
||||
status: str = ""
|
||||
filename: str = ""
|
||||
size_bytes: int = 0
|
||||
size_human: str = ""
|
||||
bytes_in: int = 0
|
||||
file_count: int = 0
|
||||
dir_count: int = 0
|
||||
sha256: str = ""
|
||||
schedule_uid: str = ""
|
||||
created_by: str = ""
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
download_url: Optional[str] = None
|
||||
|
||||
|
||||
class BackupJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
target: Optional[str] = None
|
||||
backup_uid: Optional[str] = None
|
||||
download_url: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
bytes_out: int = 0
|
||||
file_count: int = 0
|
||||
sha256: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class BackupScheduleOut(_Out):
|
||||
uid: str = ""
|
||||
name: str = ""
|
||||
target: str = ""
|
||||
kind: str = ""
|
||||
every_seconds: int = 0
|
||||
cron: Optional[str] = None
|
||||
enabled: int = 0
|
||||
keep_last: int = 0
|
||||
next_run_at: Optional[str] = None
|
||||
last_run_at: Optional[str] = None
|
||||
last_job_uid: Optional[str] = None
|
||||
run_count: int = 0
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class BackupDashboardOut(_Out):
|
||||
storage: dict = {}
|
||||
backups: list[BackupOut] = []
|
||||
schedules: list[BackupScheduleOut] = []
|
||||
targets: list[dict] = []
|
||||
metrics: dict = {}
|
||||
generated_at: Optional[str] = None
|
||||
can_download_backups: bool = False
|
||||
admin_section: Optional[str] = None
|
||||
27
devplacepy/schemas/base.py
Normal file
27
devplacepy/schemas/base.py
Normal file
@ -0,0 +1,27 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class _Out(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class ActionResult(_Out):
|
||||
ok: bool = True
|
||||
redirect: Optional[str] = None
|
||||
data: Optional[Any] = None
|
||||
|
||||
|
||||
class ErrorOut(_Out):
|
||||
error: dict
|
||||
|
||||
|
||||
class ValidationErrorOut(_Out):
|
||||
error: str = "validation"
|
||||
fields: dict = {}
|
||||
messages: list = []
|
||||
100
devplacepy/schemas/containers.py
Normal file
100
devplacepy/schemas/containers.py
Normal file
@ -0,0 +1,100 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class InstanceOut(_Out):
|
||||
uid: str = ""
|
||||
project_uid: str = ""
|
||||
name: str = ""
|
||||
slug: str = ""
|
||||
container_id: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
desired_state: Optional[str] = None
|
||||
restart_policy: Optional[str] = None
|
||||
restart_count: int = 0
|
||||
ingress_slug: Optional[str] = None
|
||||
ingress_port: int = 0
|
||||
boot_command: Optional[str] = None
|
||||
boot_language: Optional[str] = None
|
||||
boot_script: Optional[str] = None
|
||||
run_as_uid: Optional[str] = None
|
||||
start_on_boot: int = 0
|
||||
cpu_limit: Optional[str] = None
|
||||
mem_limit: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
started_at: Optional[str] = None
|
||||
stopped_at: Optional[str] = None
|
||||
|
||||
|
||||
class ScheduleOut(_Out):
|
||||
uid: str = ""
|
||||
instance_uid: str = ""
|
||||
action: Optional[str] = None
|
||||
enabled: int = 0
|
||||
next_run_at: Optional[str] = None
|
||||
last_run_at: Optional[str] = None
|
||||
run_count: int = 0
|
||||
|
||||
|
||||
class ContainersOut(_Out):
|
||||
project: Optional[dict] = None
|
||||
instances: list[InstanceOut] = []
|
||||
|
||||
|
||||
class AdminContainersOut(_Out):
|
||||
instances: list = []
|
||||
projects: list = []
|
||||
boot_languages: list = []
|
||||
restart_policies: list = []
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
class AdminContainerEditOut(_Out):
|
||||
instance: Optional[Any] = None
|
||||
project: Optional[dict] = None
|
||||
run_as_user: Optional[dict] = None
|
||||
boot_languages: list = []
|
||||
restart_policies: list = []
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
class AdminContainerInstanceOut(_Out):
|
||||
instance: Optional[Any] = None
|
||||
project: Optional[dict] = None
|
||||
project_slug: Optional[str] = None
|
||||
events: list = []
|
||||
schedules: list = []
|
||||
stats: Optional[Any] = None
|
||||
runtime: Optional[Any] = None
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
class BotFrameOut(_Out):
|
||||
slot: int = 0
|
||||
username: str = ""
|
||||
persona: str = ""
|
||||
action: str = ""
|
||||
status: str = ""
|
||||
url: str = ""
|
||||
label: str = ""
|
||||
captured_at: int = 0
|
||||
age_seconds: int = 0
|
||||
has_image: bool = False
|
||||
active: bool = False
|
||||
frame_url: str = ""
|
||||
|
||||
|
||||
class AdminBotsOut(_Out):
|
||||
frames: list[BotFrameOut] = []
|
||||
enabled: bool = False
|
||||
service_status: str = ""
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
203
devplacepy/schemas/content.py
Normal file
203
devplacepy/schemas/content.py
Normal file
@ -0,0 +1,203 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class UserOut(_Out):
|
||||
uid: str = ""
|
||||
username: str = ""
|
||||
avatar_seed: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
git_link: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
level: Optional[int] = None
|
||||
xp: Optional[int] = None
|
||||
stars: Optional[int] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class AttachmentOut(_Out):
|
||||
uid: str = ""
|
||||
filename: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
size: Optional[int] = None
|
||||
is_image: Optional[bool] = None
|
||||
is_video: Optional[bool] = None
|
||||
mime_type: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
can_modify: bool = False
|
||||
|
||||
|
||||
class VotesOut(_Out):
|
||||
up: int = 0
|
||||
down: int = 0
|
||||
|
||||
|
||||
class ReactionsOut(_Out):
|
||||
counts: dict = {}
|
||||
mine: list = []
|
||||
|
||||
|
||||
class PollOptionOut(_Out):
|
||||
uid: str = ""
|
||||
label: Optional[str] = None
|
||||
count: Optional[int] = None
|
||||
votes: Optional[int] = None
|
||||
pct: Optional[int] = None
|
||||
|
||||
|
||||
class PollOut(_Out):
|
||||
uid: str = ""
|
||||
question: Optional[str] = None
|
||||
options: list[PollOptionOut] = []
|
||||
total: int = 0
|
||||
my_choice: Optional[str] = None
|
||||
voted: Optional[str] = None
|
||||
|
||||
|
||||
class BadgeOut(_Out):
|
||||
name: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class PostOut(_Out):
|
||||
uid: str = ""
|
||||
slug: Optional[str] = None
|
||||
user_uid: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
topic: Optional[str] = None
|
||||
stars: Optional[int] = None
|
||||
image: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class GistOut(_Out):
|
||||
uid: str = ""
|
||||
slug: Optional[str] = None
|
||||
user_uid: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
source_code: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
stars: Optional[int] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectOut(_Out):
|
||||
uid: str = ""
|
||||
slug: Optional[str] = None
|
||||
user_uid: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
project_type: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
platforms: Optional[Any] = None
|
||||
stars: Optional[int] = None
|
||||
is_private: Optional[bool] = None
|
||||
read_only: Optional[bool] = None
|
||||
release_date: Optional[str] = None
|
||||
demo_date: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectFileOut(_Out):
|
||||
uid: str = ""
|
||||
path: str = ""
|
||||
name: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
is_binary: Optional[bool] = None
|
||||
mime_type: Optional[str] = None
|
||||
size: Optional[int] = None
|
||||
url: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectFileRawOut(ProjectFileOut):
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectFilesOut(_Out):
|
||||
project: ProjectOut
|
||||
files: list[ProjectFileOut] = []
|
||||
is_owner: bool = False
|
||||
read_only: bool = False
|
||||
is_private: bool = False
|
||||
|
||||
|
||||
class NewsOut(_Out):
|
||||
uid: str = ""
|
||||
slug: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
source_name: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
grade: Optional[int] = None
|
||||
ai_grade: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
featured: Optional[int] = None
|
||||
has_unique_image: Optional[int] = None
|
||||
article_published: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
synced_at: Optional[str] = None
|
||||
|
||||
|
||||
class CommentOut(_Out):
|
||||
uid: str = ""
|
||||
user_uid: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
parent_uid: Optional[str] = None
|
||||
target_type: Optional[str] = None
|
||||
target_uid: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class CommentEditOut(_Out):
|
||||
uid: str = ""
|
||||
content: str = ""
|
||||
url: str = ""
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class CommentItemOut(_Out):
|
||||
comment: CommentOut
|
||||
author: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
votes: VotesOut = VotesOut()
|
||||
my_vote: int = 0
|
||||
children: list["CommentItemOut"] = []
|
||||
attachments: list[AttachmentOut] = []
|
||||
reactions: ReactionsOut = ReactionsOut()
|
||||
|
||||
|
||||
class NotificationOut(_Out):
|
||||
uid: str = ""
|
||||
type: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
read: Optional[bool] = None
|
||||
related_uid: Optional[str] = None
|
||||
target_url: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class MessageOut(_Out):
|
||||
uid: str = ""
|
||||
sender_uid: Optional[str] = None
|
||||
receiver_uid: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
read: Optional[bool] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
CommentItemOut.model_rebuild()
|
||||
70
devplacepy/schemas/dbapi.py
Normal file
70
devplacepy/schemas/dbapi.py
Normal file
@ -0,0 +1,70 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class DbTableOut(_Out):
|
||||
name: str = ""
|
||||
row_count: int = 0
|
||||
soft_delete: bool = False
|
||||
|
||||
|
||||
class DbTableListOut(_Out):
|
||||
tables: list[DbTableOut] = []
|
||||
count: int = 0
|
||||
|
||||
|
||||
class DbColumnOut(_Out):
|
||||
name: str = ""
|
||||
type: str = ""
|
||||
|
||||
|
||||
class DbSchemaOut(_Out):
|
||||
table: str = ""
|
||||
columns: list[DbColumnOut] = []
|
||||
soft_delete: bool = False
|
||||
row_count: int = 0
|
||||
|
||||
|
||||
class DbRowsOut(_Out):
|
||||
table: str = ""
|
||||
rows: list[dict] = []
|
||||
count: int = 0
|
||||
next_cursor: Optional[Any] = None
|
||||
|
||||
|
||||
class DbRowOut(_Out):
|
||||
table: str = ""
|
||||
row: Optional[dict] = None
|
||||
|
||||
|
||||
class DbQueryOut(_Out):
|
||||
sql: str = ""
|
||||
valid: bool = False
|
||||
statement_type: str = ""
|
||||
rows: list[dict] = []
|
||||
row_count: int = 0
|
||||
truncated: bool = False
|
||||
suspicious: list[str] = []
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class NlQueryOut(_Out):
|
||||
question: str = ""
|
||||
table: str = ""
|
||||
sql: str = ""
|
||||
valid: bool = False
|
||||
attempts: int = 0
|
||||
dialect: str = "sqlite"
|
||||
applied_soft_delete: bool = False
|
||||
suspicious: list[str] = []
|
||||
tables: list[str] = []
|
||||
executed: bool = False
|
||||
rows: list[dict] = []
|
||||
row_count: int = 0
|
||||
truncated: bool = False
|
||||
error: Optional[str] = None
|
||||
136
devplacepy/schemas/game.py
Normal file
136
devplacepy/schemas/game.py
Normal file
@ -0,0 +1,136 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class GameCropOut(_Out):
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
icon: str = ""
|
||||
cost: int = 0
|
||||
reward_coins: int = 0
|
||||
reward_xp: int = 0
|
||||
min_level: int = 1
|
||||
grow_seconds: int = 0
|
||||
locked: bool = False
|
||||
|
||||
|
||||
class GamePlotOut(_Out):
|
||||
slot: int = 0
|
||||
state: str = "empty"
|
||||
crop_key: str = ""
|
||||
crop_name: str = ""
|
||||
crop_icon: str = ""
|
||||
reward_coins: int = 0
|
||||
reward_xp: int = 0
|
||||
ready_at: str = ""
|
||||
remaining_seconds: int = 0
|
||||
watered_count: int = 0
|
||||
max_waters: int = 0
|
||||
can_water: bool = False
|
||||
can_steal: bool = False
|
||||
steal_coins: int = 0
|
||||
steal_cooldown_seconds: int = 0
|
||||
steal_reason: str = ""
|
||||
is_golden: bool = False
|
||||
fertilize_cost: int = 0
|
||||
|
||||
|
||||
class GamePerkOut(_Out):
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
icon: str = ""
|
||||
description: str = ""
|
||||
level: int = 0
|
||||
max_level: int = 0
|
||||
cost: int = 0
|
||||
maxed: bool = False
|
||||
effect: str = ""
|
||||
|
||||
|
||||
class GameLegacyOut(_Out):
|
||||
key: str = ""
|
||||
name: str = ""
|
||||
icon: str = ""
|
||||
description: str = ""
|
||||
level: int = 0
|
||||
max_level: int = 0
|
||||
cost: int = 0
|
||||
maxed: bool = False
|
||||
effect: str = ""
|
||||
|
||||
|
||||
class GameQuestOut(_Out):
|
||||
kind: str = ""
|
||||
label: str = ""
|
||||
goal: int = 0
|
||||
progress: int = 0
|
||||
reward_coins: int = 0
|
||||
reward_xp: int = 0
|
||||
claimed: bool = False
|
||||
can_claim: bool = False
|
||||
|
||||
|
||||
class GameFarmOut(_Out):
|
||||
owner_username: str = ""
|
||||
owner_uid: str = ""
|
||||
is_owner: bool = False
|
||||
coins: int = 0
|
||||
xp: int = 0
|
||||
level: int = 1
|
||||
level_into: int = 0
|
||||
level_span: int = 0
|
||||
level_is_max: bool = False
|
||||
total_harvests: int = 0
|
||||
plot_count: int = 0
|
||||
max_plots: int = 0
|
||||
next_plot_cost: int = 0
|
||||
ci_tier: int = 1
|
||||
ci_label: str = ""
|
||||
ci_speed: float = 1.0
|
||||
ci_next_tier: int = 0
|
||||
ci_next_label: str = ""
|
||||
ci_next_cost: int = 0
|
||||
plots: list[GamePlotOut] = []
|
||||
crops: list[GameCropOut] = []
|
||||
prestige: int = 0
|
||||
prestige_multiplier: float = 1.0
|
||||
prestige_min_level: int = 0
|
||||
prestige_available: bool = False
|
||||
streak: int = 0
|
||||
daily_available: bool = False
|
||||
daily_reward: int = 0
|
||||
perks: list[GamePerkOut] = []
|
||||
quests: list[GameQuestOut] = []
|
||||
stars: int = 0
|
||||
legacy: list[GameLegacyOut] = []
|
||||
steal_cooldown_seconds: int = 0
|
||||
|
||||
|
||||
class GameStateOut(_Out):
|
||||
ok: bool = True
|
||||
farm: GameFarmOut
|
||||
|
||||
|
||||
class GameFarmViewOut(_Out):
|
||||
farm: GameFarmOut
|
||||
page_title: str = ""
|
||||
meta_description: str = ""
|
||||
stole_coins: int = 0
|
||||
|
||||
|
||||
class GameLeaderboardEntryOut(_Out):
|
||||
rank: int = 0
|
||||
username: str = ""
|
||||
level: int = 1
|
||||
xp: int = 0
|
||||
coins: int = 0
|
||||
total_harvests: int = 0
|
||||
prestige: int = 0
|
||||
score: int = 0
|
||||
|
||||
|
||||
class GameLeaderboardOut(_Out):
|
||||
entries: list[GameLeaderboardEntryOut] = []
|
||||
42
devplacepy/schemas/gateway.py
Normal file
42
devplacepy/schemas/gateway.py
Normal file
@ -0,0 +1,42 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class GatewayUsageOut(_Out):
|
||||
window_hours: int = 48
|
||||
generated_at: Optional[str] = None
|
||||
requests: int = 0
|
||||
volume: dict = {}
|
||||
tokens: dict = {}
|
||||
latency: dict = {}
|
||||
errors: dict = {}
|
||||
cost: dict = {}
|
||||
behavior: dict = {}
|
||||
hourly: list = []
|
||||
notes: dict = {}
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class UserAiUsageOut(_Out):
|
||||
owner_id: str = ""
|
||||
window_hours: int = 24
|
||||
generated_at: Optional[str] = None
|
||||
requests: int = 0
|
||||
success: int = 0
|
||||
failed: int = 0
|
||||
success_pct: float = 0.0
|
||||
error_pct: float = 0.0
|
||||
tokens: dict = {}
|
||||
cost: dict = {}
|
||||
latency: dict = {}
|
||||
first_used: Optional[str] = None
|
||||
last_used: Optional[str] = None
|
||||
by_model: list = []
|
||||
by_backend: list = []
|
||||
hourly: list = []
|
||||
notes: dict = {}
|
||||
69
devplacepy/schemas/issues.py
Normal file
69
devplacepy/schemas/issues.py
Normal file
@ -0,0 +1,69 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.content import AttachmentOut
|
||||
|
||||
|
||||
class IssueItemOut(_Out):
|
||||
number: int = 0
|
||||
title: str = ""
|
||||
state: str = "open"
|
||||
html_url: Optional[str] = None
|
||||
comments_count: int = 0
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
author_username: Optional[str] = None
|
||||
author_uid: Optional[str] = None
|
||||
author_avatar_seed: Optional[str] = None
|
||||
is_local_author: bool = False
|
||||
|
||||
|
||||
class IssueCommentOut(_Out):
|
||||
id: int = 0
|
||||
body: str = ""
|
||||
html_url: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
author_username: Optional[str] = None
|
||||
author_uid: Optional[str] = None
|
||||
author_avatar_seed: Optional[str] = None
|
||||
is_local_author: bool = False
|
||||
attachments: list[AttachmentOut] = []
|
||||
|
||||
|
||||
class IssueDetailOut(_Out):
|
||||
issue: IssueItemOut
|
||||
body: str = ""
|
||||
comments: list[IssueCommentOut] = []
|
||||
attachments: list[AttachmentOut] = []
|
||||
can_comment: bool = False
|
||||
can_attach: bool = False
|
||||
viewer_is_admin: bool = False
|
||||
|
||||
|
||||
class IssueAttachmentsOut(_Out):
|
||||
number: int = 0
|
||||
attachments: list[AttachmentOut] = []
|
||||
|
||||
|
||||
class IssueJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
number: Optional[int] = None
|
||||
issue_url: Optional[str] = None
|
||||
enhanced: bool = False
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class IssuesOut(_Out):
|
||||
issues: list[IssueItemOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
state: str = "open"
|
||||
configured: bool = True
|
||||
error_message: Optional[str] = None
|
||||
155
devplacepy/schemas/jobs.py
Normal file
155
devplacepy/schemas/jobs.py
Normal file
@ -0,0 +1,155 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
class ZipJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
preferred_name: Optional[str] = None
|
||||
download_url: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
bytes_in: int = 0
|
||||
bytes_out: int = 0
|
||||
item_count: int = 0
|
||||
file_count: int = 0
|
||||
dir_count: int = 0
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class PlanningJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
preferred_name: Optional[str] = None
|
||||
download_url: Optional[str] = None
|
||||
markdown: Optional[str] = None
|
||||
ai_used: bool = False
|
||||
error: Optional[str] = None
|
||||
issue_count: int = 0
|
||||
bytes_out: int = 0
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class ForkJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
preferred_name: Optional[str] = None
|
||||
project_uid: Optional[str] = None
|
||||
project_url: Optional[str] = None
|
||||
source_project_uid: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
item_count: int = 0
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class SeoJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
target: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
ws_url: Optional[str] = None
|
||||
report_url: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
grade: Optional[str] = None
|
||||
page_count: int = 0
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class SeoReportOut(_Out):
|
||||
uid: str = ""
|
||||
status: str = ""
|
||||
target: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
grade: Optional[str] = None
|
||||
page_count: int = 0
|
||||
counts: dict = {}
|
||||
categories: dict = {}
|
||||
pages: list = []
|
||||
checks: list = []
|
||||
site: dict = {}
|
||||
generated_at: Optional[str] = None
|
||||
|
||||
|
||||
class SeoMetaOut(_Out):
|
||||
uid: str = ""
|
||||
target_type: str = ""
|
||||
target_uid: str = ""
|
||||
seo_title: str = ""
|
||||
seo_description: str = ""
|
||||
seo_keywords: str = ""
|
||||
status: str = ""
|
||||
source: str = ""
|
||||
generated_at: Optional[str] = None
|
||||
|
||||
|
||||
class DeepsearchJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
query: Optional[str] = None
|
||||
depth: int = 0
|
||||
max_pages: int = 0
|
||||
ws_url: Optional[str] = None
|
||||
chat_ws_url: Optional[str] = None
|
||||
session_url: Optional[str] = None
|
||||
score: Optional[int] = None
|
||||
confidence: Optional[float] = None
|
||||
source_diversity: Optional[float] = None
|
||||
page_count: int = 0
|
||||
chunk_count: int = 0
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class DeepsearchSessionOut(_Out):
|
||||
uid: str = ""
|
||||
status: str = ""
|
||||
query: Optional[str] = None
|
||||
depth: int = 0
|
||||
max_pages: int = 0
|
||||
score: Optional[int] = None
|
||||
confidence: Optional[float] = None
|
||||
source_diversity: Optional[float] = None
|
||||
page_count: int = 0
|
||||
chunk_count: int = 0
|
||||
summary: Optional[str] = None
|
||||
sources: list = []
|
||||
findings: list = []
|
||||
gaps: list = []
|
||||
timeline: list = []
|
||||
chat_ws_url: Optional[str] = None
|
||||
export_md_url: Optional[str] = None
|
||||
export_json_url: Optional[str] = None
|
||||
export_pdf_url: Optional[str] = None
|
||||
viewer_is_admin: bool = False
|
||||
viewer_owns: bool = False
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class DbQueryJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
ws_url: Optional[str] = None
|
||||
result_url: Optional[str] = None
|
||||
row_count: Optional[int] = None
|
||||
truncated: Optional[bool] = None
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
231
devplacepy/schemas/listings.py
Normal file
231
devplacepy/schemas/listings.py
Normal file
@ -0,0 +1,231 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.content import (
|
||||
AttachmentOut,
|
||||
CommentItemOut,
|
||||
GistOut,
|
||||
MessageOut,
|
||||
NewsOut,
|
||||
NotificationOut,
|
||||
PollOut,
|
||||
PostOut,
|
||||
ProjectOut,
|
||||
ReactionsOut,
|
||||
UserOut,
|
||||
)
|
||||
|
||||
|
||||
class FeedItemOut(_Out):
|
||||
post: PostOut
|
||||
author: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
my_vote: int = 0
|
||||
comment_count: int = 0
|
||||
attachments: list[AttachmentOut] = []
|
||||
recent_comments: list[CommentItemOut] = []
|
||||
reactions: ReactionsOut = ReactionsOut()
|
||||
bookmarked: bool = False
|
||||
poll: Optional[PollOut] = None
|
||||
|
||||
|
||||
class GistItemOut(_Out):
|
||||
gist: GistOut
|
||||
author: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
my_vote: int = 0
|
||||
comment_count: int = 0
|
||||
recent_comments: list[CommentItemOut] = []
|
||||
|
||||
|
||||
class ProjectListItemOut(ProjectOut):
|
||||
author_name: Optional[str] = None
|
||||
my_vote: int = 0
|
||||
recent_comments: list[CommentItemOut] = []
|
||||
|
||||
|
||||
class NewsListItemOut(_Out):
|
||||
article: NewsOut
|
||||
time_ago: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
grade: Optional[int] = None
|
||||
featured: Optional[int] = None
|
||||
recent_comments: list[CommentItemOut] = []
|
||||
|
||||
|
||||
class ConversationOut(_Out):
|
||||
other_user: Optional[UserOut] = None
|
||||
last_message: Optional[str] = None
|
||||
last_message_at: Optional[str] = None
|
||||
unread: bool = False
|
||||
|
||||
|
||||
class MessageItemOut(_Out):
|
||||
message: MessageOut
|
||||
sender: Optional[UserOut] = None
|
||||
is_mine: bool = False
|
||||
time_ago: Optional[str] = None
|
||||
attachments: list[AttachmentOut] = []
|
||||
|
||||
|
||||
class NotificationItemOut(_Out):
|
||||
notification: NotificationOut
|
||||
actor: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
|
||||
|
||||
class NotificationGroupOut(_Out):
|
||||
label: Optional[str] = None
|
||||
entries: list[NotificationItemOut] = []
|
||||
|
||||
|
||||
class LeaderboardEntryOut(_Out):
|
||||
uid: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
avatar_seed: Optional[str] = None
|
||||
stars: Optional[int] = None
|
||||
level: Optional[int] = None
|
||||
rank: Optional[int] = None
|
||||
|
||||
|
||||
class SavedItemOut(_Out):
|
||||
target_type: Optional[str] = None
|
||||
target_uid: Optional[str] = None
|
||||
type_label: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
time_ago: Optional[str] = None
|
||||
|
||||
|
||||
class FeedOut(_Out):
|
||||
posts: list[FeedItemOut] = []
|
||||
current_tab: Optional[str] = None
|
||||
current_topic: Optional[str] = None
|
||||
search: Optional[str] = None
|
||||
next_cursor: Optional[str] = None
|
||||
total_members: Optional[int] = None
|
||||
posts_today: Optional[int] = None
|
||||
total_projects: Optional[int] = None
|
||||
total_gists: Optional[int] = None
|
||||
top_authors: list[UserOut] = []
|
||||
daily_topic: Optional[Any] = None
|
||||
|
||||
|
||||
class PostDetailOut(_Out):
|
||||
post: PostOut
|
||||
author: Optional[UserOut] = None
|
||||
is_owner: bool = False
|
||||
star_count: int = 0
|
||||
my_vote: int = 0
|
||||
time_ago: Optional[str] = None
|
||||
comments: list[CommentItemOut] = []
|
||||
attachments: list[AttachmentOut] = []
|
||||
reactions: ReactionsOut = ReactionsOut()
|
||||
bookmarked: bool = False
|
||||
poll: Optional[PollOut] = None
|
||||
comment_count: Optional[int] = None
|
||||
related_posts: list[FeedItemOut] = []
|
||||
topics: list[str] = []
|
||||
|
||||
|
||||
class ProjectsOut(_Out):
|
||||
projects: list[ProjectListItemOut] = []
|
||||
current_tab: Optional[str] = None
|
||||
search: Optional[str] = None
|
||||
project_type: Optional[str] = None
|
||||
total_count: Optional[int] = None
|
||||
next_cursor: Optional[str] = None
|
||||
total_members: Optional[int] = None
|
||||
top_authors: list[UserOut] = []
|
||||
|
||||
|
||||
class ProjectDetailOut(_Out):
|
||||
project: ProjectOut
|
||||
author: Optional[UserOut] = None
|
||||
is_owner: bool = False
|
||||
star_count: int = 0
|
||||
my_vote: int = 0
|
||||
time_ago: Optional[str] = None
|
||||
comments: list[CommentItemOut] = []
|
||||
attachments: list[AttachmentOut] = []
|
||||
reactions: ReactionsOut = ReactionsOut()
|
||||
bookmarked: bool = False
|
||||
platforms: Optional[Any] = None
|
||||
is_private: bool = False
|
||||
read_only: bool = False
|
||||
forked_from: Optional[dict] = None
|
||||
fork_count: int = 0
|
||||
file_count: int = 0
|
||||
|
||||
|
||||
class GistsOut(_Out):
|
||||
gists: list[GistItemOut] = []
|
||||
total_count: Optional[int] = None
|
||||
next_cursor: Optional[str] = None
|
||||
current_language: Optional[str] = None
|
||||
search: Optional[str] = None
|
||||
languages: list[tuple[str, str]] = []
|
||||
gist_language_codes: list[str] = []
|
||||
|
||||
|
||||
class GistDetailOut(_Out):
|
||||
gist: GistOut
|
||||
author: Optional[UserOut] = None
|
||||
is_owner: bool = False
|
||||
star_count: int = 0
|
||||
my_vote: int = 0
|
||||
time_ago: Optional[str] = None
|
||||
comments: list[CommentItemOut] = []
|
||||
attachments: list[AttachmentOut] = []
|
||||
reactions: ReactionsOut = ReactionsOut()
|
||||
bookmarked: bool = False
|
||||
|
||||
|
||||
class NewsListOut(_Out):
|
||||
articles: list[NewsListItemOut] = []
|
||||
next_cursor: Optional[str] = None
|
||||
|
||||
|
||||
class NewsDetailOut(_Out):
|
||||
article: NewsOut
|
||||
canonical_slug: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
grade: Optional[int] = None
|
||||
time_ago: Optional[str] = None
|
||||
comments: list[CommentItemOut] = []
|
||||
bookmarked: bool = False
|
||||
|
||||
|
||||
class MessagesOut(_Out):
|
||||
conversations: list[ConversationOut] = []
|
||||
messages: list[MessageItemOut] = []
|
||||
other_user: Optional[UserOut] = None
|
||||
current_conversation: Optional[str] = None
|
||||
search: Optional[str] = None
|
||||
other_online: bool = False
|
||||
other_last_seen: Optional[str] = None
|
||||
|
||||
|
||||
class NotificationsOut(_Out):
|
||||
notification_groups: list[NotificationGroupOut] = []
|
||||
next_cursor: Optional[str] = None
|
||||
|
||||
|
||||
class LeaderboardOut(_Out):
|
||||
entries: list[LeaderboardEntryOut] = []
|
||||
user_rank: Optional[int] = None
|
||||
total_members: Optional[int] = None
|
||||
posts_today: Optional[int] = None
|
||||
total_projects: Optional[int] = None
|
||||
total_gists: Optional[int] = None
|
||||
top_authors: list[UserOut] = []
|
||||
featured_news: list[NewsOut] = []
|
||||
|
||||
|
||||
class SavedOut(_Out):
|
||||
items: list[SavedItemOut] = []
|
||||
next_cursor: Optional[str] = None
|
||||
79
devplacepy/schemas/profile.py
Normal file
79
devplacepy/schemas/profile.py
Normal file
@ -0,0 +1,79 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
from devplacepy.schemas.content import BadgeOut, ProjectOut, UserOut
|
||||
from devplacepy.schemas.listings import FeedItemOut, GistItemOut
|
||||
|
||||
|
||||
class MediaItemOut(_Out):
|
||||
uid: str = ""
|
||||
original_filename: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
mime_type: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
thumbnail_url: Optional[str] = None
|
||||
has_thumbnail: bool = False
|
||||
is_image: bool = False
|
||||
is_video: bool = False
|
||||
target_type: Optional[str] = None
|
||||
target_uid: Optional[str] = None
|
||||
target_url: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileOut(_Out):
|
||||
profile_user: UserOut
|
||||
posts: list[FeedItemOut] = []
|
||||
badges: list[BadgeOut] = []
|
||||
achievements: list[dict] = []
|
||||
badge_total: int = 0
|
||||
badge_earned: int = 0
|
||||
projects: list[ProjectOut] = []
|
||||
gists: list[GistItemOut] = []
|
||||
current_tab: Optional[str] = None
|
||||
posts_count: Optional[int] = None
|
||||
is_following: bool = False
|
||||
is_blocked: bool = False
|
||||
is_muted: bool = False
|
||||
is_owner: bool = False
|
||||
can_view_api_key: bool = False
|
||||
api_key: Optional[str] = None
|
||||
ai_correction_enabled: bool = False
|
||||
ai_correction_sync: bool = False
|
||||
ai_correction_prompt: Optional[str] = None
|
||||
ai_modifier_enabled: bool = False
|
||||
ai_modifier_sync: bool = False
|
||||
ai_modifier_prompt: Optional[str] = None
|
||||
telegram_paired: bool = False
|
||||
notif_telegram_paired: bool = False
|
||||
can_manage_customization: bool = False
|
||||
cust_disable_global: bool = False
|
||||
cust_disable_pagetype: bool = False
|
||||
ai_quota: Optional[dict] = None
|
||||
correction_usage: Optional[dict] = None
|
||||
modifier_usage: Optional[dict] = None
|
||||
activities: list[Any] = []
|
||||
rank: Optional[int] = None
|
||||
heatmap: Optional[Any] = None
|
||||
heatmap_months: Optional[Any] = None
|
||||
streak: Optional[Any] = None
|
||||
people: list[Any] = []
|
||||
follow_pagination: Optional[Any] = None
|
||||
followers_count: Optional[int] = None
|
||||
following_count: Optional[int] = None
|
||||
viewer_is_admin: bool = False
|
||||
media: list[MediaItemOut] = []
|
||||
media_pagination: Optional[Any] = None
|
||||
notification_prefs: list[Any] = []
|
||||
|
||||
|
||||
class TelegramPairOut(_Out):
|
||||
ok: bool = True
|
||||
paired: bool = False
|
||||
code: Optional[str] = None
|
||||
expires_at: Optional[str] = None
|
||||
ttl_minutes: Optional[int] = None
|
||||
238
devplacepy/services/bot/agent.py
Normal file
238
devplacepy/services/bot/agent.py
Normal file
@ -0,0 +1,238 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
import random
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotAgentMixin:
|
||||
def _build_menu(self, page_state: dict) -> list[dict]:
|
||||
page = page_state["page"]
|
||||
menu: list[dict] = []
|
||||
|
||||
def add(action: str, desc: str) -> None:
|
||||
menu.append({"action": action, "desc": desc})
|
||||
|
||||
if page == "post":
|
||||
if not page_state["own_post"] or page_state["mentioned"]:
|
||||
add("comment", "write a comment on this post")
|
||||
add("reply", "reply to an existing comment in the thread")
|
||||
add("vote", "upvote this post")
|
||||
add("vote_comment", "upvote a good comment in the thread")
|
||||
add("vote_poll", "answer the attached poll if there is one")
|
||||
add("react", "add an emoji reaction to this post")
|
||||
elif page == "news_detail":
|
||||
add("comment", "write a comment reacting to this news article")
|
||||
elif page == "project_detail":
|
||||
add("vote", "star this project")
|
||||
add("react", "add an emoji reaction")
|
||||
add("comment", "write a comment on this project")
|
||||
elif page == "gist_detail":
|
||||
add("comment", "write a comment on this code snippet")
|
||||
add("vote", "star this gist")
|
||||
add("react", "add an emoji reaction")
|
||||
elif page == "profile":
|
||||
if page_state.get("own_profile"):
|
||||
if not self.state.profile_filled:
|
||||
add("update_profile", "fill in your own profile bio and links")
|
||||
add("open_post", "open one of your posts to read it")
|
||||
else:
|
||||
if not page_state["follows"]:
|
||||
add("follow", "follow this developer")
|
||||
if page_state["can_message"]:
|
||||
add("message", "send this developer a direct message")
|
||||
add("open_post", "open one of their posts to read it")
|
||||
elif page == "projects_list":
|
||||
add("vote", "star a project in the list")
|
||||
if page_state["can_project"]:
|
||||
add("create_project", "create a new project of your own")
|
||||
add("open_project", "open a project to read it")
|
||||
elif page == "gists_list":
|
||||
add("vote", "star a gist in the list")
|
||||
if not page_state["gists"]:
|
||||
add("create_gist", "publish a new code snippet of your own")
|
||||
add("open_gist", "open a gist to read it")
|
||||
elif page == "news_list":
|
||||
add("open_news", "open a news article to read it")
|
||||
elif page == "notifications":
|
||||
add("check_notifications", "read and act on your notifications")
|
||||
else:
|
||||
add("open_post", "open a post from the feed to read it")
|
||||
add("vote", "upvote a post in the feed")
|
||||
add("react", "react to a post in the feed")
|
||||
add("vote_poll", "answer a poll in the feed")
|
||||
if page_state["can_post"] and not page_state["did_post"]:
|
||||
add("create_post", "write a new post reacting to a tech news article")
|
||||
if not page_state["gists"]:
|
||||
add("create_gist", "publish a new code snippet of your own")
|
||||
add("open_profile", "open another developer's profile")
|
||||
add("search", "search the platform for a topic you care about")
|
||||
add("browse", "browse a category or section")
|
||||
if page != "notifications" and page_state.get("unread_notifications"):
|
||||
add(
|
||||
"check_notifications",
|
||||
"open your notifications and reply to anyone who mentioned or replied to you",
|
||||
)
|
||||
add("navigate", "move to another section: feed, projects, gists, or news")
|
||||
return menu
|
||||
|
||||
def _record_history(self, action: str, rationale: str) -> None:
|
||||
note = action.replace("_", " ")
|
||||
if rationale:
|
||||
note = f"{note} ({rationale[:60]})"
|
||||
self._session_history.append(note)
|
||||
if len(self._session_history) > 20:
|
||||
self._session_history = self._session_history[-20:]
|
||||
|
||||
async def _vote_for_page(self, page: str) -> bool:
|
||||
if page in ("project_detail", "projects_list"):
|
||||
return await self._vote_on_project()
|
||||
if page in ("gist_detail", "gists_list"):
|
||||
return await self._vote_on_gist()
|
||||
return await self._vote_on_feed()
|
||||
|
||||
async def _navigate_to(self, target: str) -> bool:
|
||||
routes = {
|
||||
"feed": "/feed",
|
||||
"projects": "/projects",
|
||||
"gists": "/gists",
|
||||
"news": "/news",
|
||||
"notifications": "/notifications",
|
||||
}
|
||||
path = routes.get((target or "").lower())
|
||||
if path:
|
||||
await self.b.goto(f"{self.base_url}{path}")
|
||||
return True
|
||||
return await self._random_nav_click()
|
||||
|
||||
async def _dispatch_action(self, action: str, target: str, page_state: dict) -> bool:
|
||||
if action == "navigate":
|
||||
return await self._navigate_to(target)
|
||||
if action == "vote":
|
||||
return await self._vote_for_page(page_state["page"])
|
||||
handlers = {
|
||||
"comment": self._comment_on_post,
|
||||
"reply": self._reply_to_random_comment,
|
||||
"vote_comment": self._vote_on_comments,
|
||||
"vote_poll": self._vote_on_poll,
|
||||
"react": self._react_to_object,
|
||||
"create_post": self._create_post,
|
||||
"create_gist": self._create_gist,
|
||||
"create_project": self._click_create_project,
|
||||
"update_profile": self._update_profile,
|
||||
"follow": self._follow_user,
|
||||
"message": self._send_message,
|
||||
"open_post": self._click_post_link,
|
||||
"open_gist": self._click_gist_link,
|
||||
"open_profile": self._click_profile_link,
|
||||
"open_project": lambda: self._open_content("project"),
|
||||
"open_news": lambda: self._open_content("news"),
|
||||
"check_notifications": self._check_notifications,
|
||||
"search": lambda: self._search("feed"),
|
||||
"browse": self._browse_section,
|
||||
}
|
||||
handler = handlers.get(action)
|
||||
if handler is None:
|
||||
return False
|
||||
try:
|
||||
result = await handler()
|
||||
except Exception as e:
|
||||
logger.debug("dispatch %s failed: %s", action, e)
|
||||
return False
|
||||
return True if result is None else bool(result)
|
||||
|
||||
async def _run_plan(
|
||||
self, plan: list[dict], page_state: dict
|
||||
) -> tuple[int, bool, int, int, int]:
|
||||
b = self.b
|
||||
actions = 0
|
||||
did_post = False
|
||||
projs = 0
|
||||
follows = 0
|
||||
gists = 0
|
||||
if not plan:
|
||||
await self._navigate_to("feed")
|
||||
return 0, did_post, projs, follows, gists
|
||||
for entry in plan:
|
||||
action = entry["action"]
|
||||
if action == "create_post" and (did_post or page_state["did_post"]):
|
||||
continue
|
||||
if action == "create_gist" and (gists or page_state["gists"]):
|
||||
continue
|
||||
if action == "create_project" and projs:
|
||||
continue
|
||||
if await self._dispatch_action(action, entry.get("target", ""), page_state):
|
||||
actions += 1
|
||||
self._record_history(action, entry.get("rationale", ""))
|
||||
if action == "create_post":
|
||||
did_post = True
|
||||
elif action == "create_project":
|
||||
projs += 1
|
||||
elif action == "follow":
|
||||
follows += 1
|
||||
elif action == "create_gist":
|
||||
gists += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
return actions, did_post, projs, follows, gists
|
||||
|
||||
async def _cycle_ai(
|
||||
self,
|
||||
mood: str = "normal",
|
||||
*,
|
||||
session_posted: bool = False,
|
||||
session_projects: int = 0,
|
||||
session_follows: int = 0,
|
||||
session_gists: int = 0,
|
||||
can_project: bool = False,
|
||||
can_post: bool = False,
|
||||
can_message: bool = False,
|
||||
) -> tuple[int, bool, int, int, int]:
|
||||
b = self.b
|
||||
curl = await b.url()
|
||||
page_text = (await b.html()).lower()
|
||||
page_state = await self._page_state(
|
||||
curl,
|
||||
page_text,
|
||||
did_post=session_posted,
|
||||
follows=session_follows,
|
||||
gists=session_gists,
|
||||
can_project=can_project,
|
||||
can_post=can_post,
|
||||
can_message=can_message,
|
||||
)
|
||||
if page_state["page"] in (
|
||||
"post",
|
||||
"news_detail",
|
||||
"project_detail",
|
||||
"gist_detail",
|
||||
):
|
||||
await self._read_like_human(4, 90)
|
||||
menu = self._build_menu(page_state)
|
||||
decision = await self._generate(
|
||||
self.llm.decide,
|
||||
self.state.identity,
|
||||
page_state,
|
||||
menu,
|
||||
self._session_history,
|
||||
self._decision_temperature,
|
||||
)
|
||||
if not decision:
|
||||
decision = {"plan": [], "energy": self._session_energy, "stop_after": 0}
|
||||
self._session_energy = decision.get("energy", self._session_energy)
|
||||
self._session_stop_after = decision.get("stop_after", 0)
|
||||
actions, did_post, projs, follows, gists = await self._run_plan(
|
||||
decision["plan"], page_state
|
||||
)
|
||||
return (
|
||||
actions,
|
||||
did_post or session_posted,
|
||||
session_projects + projs,
|
||||
session_follows + follows,
|
||||
session_gists + gists,
|
||||
)
|
||||
|
||||
def _scaled_pause(self) -> int:
|
||||
base = random.randint(self._pause_min, self._pause_max)
|
||||
factor = 1.4 - 0.8 * self._session_energy
|
||||
return max(1, int(base * factor))
|
||||
123
devplacepy/services/bot/auth.py
Normal file
123
devplacepy/services/bot/auth.py
Normal file
@ -0,0 +1,123 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from devplacepy.services.bot.config import SEARCH_TERMS
|
||||
from devplacepy.services.bot.handles import make_handle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotAuthMixin:
|
||||
def _generate_handle(self) -> str:
|
||||
return make_handle(SEARCH_TERMS.get(self.state.persona, []))
|
||||
|
||||
def _next_handle(self) -> str:
|
||||
while self.state.handle_candidates:
|
||||
candidate = self.state.handle_candidates.pop(0)
|
||||
if candidate:
|
||||
return candidate
|
||||
return self._generate_handle()
|
||||
|
||||
async def _ensure_auth(self) -> bool:
|
||||
b = self.b
|
||||
if not self.state.username:
|
||||
if not self.state.handle_candidates:
|
||||
self.state.handle_candidates = (
|
||||
await self._generate(
|
||||
self.llm.generate_handle_candidates, self.state.persona
|
||||
)
|
||||
or []
|
||||
)
|
||||
self.state.username = self._next_handle()
|
||||
self.state.account_api_key = ""
|
||||
self.state.email = self.faker.email()
|
||||
self.state.password = self.faker.password(length=random.randint(12, 18))
|
||||
self.state.started_at = datetime.now().isoformat()
|
||||
self._log(f"Identity: {self.state.username} / {self.state.email}")
|
||||
|
||||
curl = await b.url()
|
||||
if "/feed" in curl:
|
||||
return True
|
||||
|
||||
await b.goto(f"{self.base_url}/auth/login")
|
||||
curl = await b.url()
|
||||
if "/feed" in curl:
|
||||
return True
|
||||
|
||||
if await b.fill("#email", self.state.email):
|
||||
await b._idle(0.2, 0.5)
|
||||
await b.fill("#password", self.state.password)
|
||||
await b._idle(0.3, 0.8)
|
||||
await b.click(".auth-submit")
|
||||
await asyncio.sleep(2)
|
||||
curl = await b.url()
|
||||
if "/feed" in curl:
|
||||
self._log("Logged in")
|
||||
return True
|
||||
|
||||
self._log("Login failed, registering")
|
||||
for attempt in range(4):
|
||||
await b.goto(f"{self.base_url}/auth/signup")
|
||||
if not await b.fill("#username", self.state.username):
|
||||
return False
|
||||
await b._idle(0.2, 0.5)
|
||||
await b.fill("#email", self.state.email)
|
||||
await b._idle(0.2, 0.5)
|
||||
await b.fill("#password", self.state.password)
|
||||
await b._idle(0.2, 0.5)
|
||||
await b.fill("#confirm_password", self.state.password)
|
||||
await b._idle(0.5, 1.0)
|
||||
await b.click(".auth-submit")
|
||||
await asyncio.sleep(2)
|
||||
curl = await b.url()
|
||||
if "/feed" in curl:
|
||||
self._log(f"Registered as {self.state.username}")
|
||||
return True
|
||||
if attempt >= 2:
|
||||
base = self._next_handle()
|
||||
self.state.username = f"{base}{random.randint(10, 9999)}"[:20]
|
||||
else:
|
||||
self.state.username = self._next_handle()
|
||||
self.state.account_api_key = ""
|
||||
self.state.email = self.faker.email()
|
||||
self._log(f"Signup failed, retrying as {self.state.username}")
|
||||
return False
|
||||
|
||||
async def _fetch_account_api_key(self) -> str:
|
||||
if not self.state.username:
|
||||
return ""
|
||||
try:
|
||||
key = await self.b.page.evaluate(
|
||||
"""async (username) => {
|
||||
const resp = await fetch(`/profile/${username}`, {
|
||||
headers: {Accept: 'application/json'},
|
||||
});
|
||||
if (!resp.ok) return '';
|
||||
const data = await resp.json();
|
||||
return data.api_key || '';
|
||||
}""",
|
||||
self.state.username,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("fetch account api key failed: %s", e)
|
||||
return ""
|
||||
return (key or "").strip()
|
||||
|
||||
async def _adopt_account_api_key(self) -> bool:
|
||||
for attempt in range(5):
|
||||
key = await self._fetch_account_api_key()
|
||||
if key:
|
||||
if key != self.state.account_api_key:
|
||||
self.state.account_api_key = key
|
||||
self._save()
|
||||
self._log("Adopted account API key for gateway calls")
|
||||
self.llm.api_key = key
|
||||
return True
|
||||
if attempt < 4:
|
||||
await asyncio.sleep(2)
|
||||
self._log("Account API key unavailable after retries")
|
||||
return False
|
||||
File diff suppressed because it is too large
Load Diff
140
devplacepy/services/bot/browse.py
Normal file
140
devplacepy/services/bot/browse.py
Normal file
@ -0,0 +1,140 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
|
||||
from devplacepy.services.bot.config import (
|
||||
FEED_TOPICS,
|
||||
GIST_LANGUAGES,
|
||||
PROJECT_TYPES,
|
||||
SEARCH_TERMS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotBrowseMixin:
|
||||
async def _scroll_page(self) -> None:
|
||||
await self.b.scroll(random.randint(300, 900))
|
||||
self._log("Scrolled down")
|
||||
|
||||
async def _random_nav_click(self) -> bool:
|
||||
b = self.b
|
||||
nav_links = b.page.locator(".topnav-links a, .topnav a, a.topnav-link")
|
||||
count = await nav_links.count()
|
||||
if count == 0:
|
||||
nav_links = b.page.locator(
|
||||
"a[href='/feed'], a[href='/projects'], a[href='/messages'], a[href='/notifications']"
|
||||
)
|
||||
count = await nav_links.count()
|
||||
if count == 0:
|
||||
return False
|
||||
picked = random.randrange(count)
|
||||
try:
|
||||
text = await nav_links.nth(picked).inner_text()
|
||||
href = await nav_links.nth(picked).get_attribute("href")
|
||||
await nav_links.nth(picked).click(timeout=5000)
|
||||
await b._idle(1.0, 2.0)
|
||||
self._log(f"Nav: {text.strip()[:30] if text else href}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("_random_nav_click failed: %s", e)
|
||||
return False
|
||||
|
||||
async def _browse_section(self) -> bool:
|
||||
section, query = random.choice(
|
||||
[
|
||||
("feed", f"?tab={random.choice(['trending', 'recent', 'following'])}"),
|
||||
("feed", f"?topic={random.choice(FEED_TOPICS)}"),
|
||||
("projects", f"?type={random.choice(PROJECT_TYPES)}"),
|
||||
("gists", f"?language={random.choice(GIST_LANGUAGES)}"),
|
||||
]
|
||||
)
|
||||
await self.b.goto(f"{self.base_url}/{section}{query}")
|
||||
self._action("BROWSE", f"/{section}{query}")
|
||||
await self.b._idle(0.8, 1.8)
|
||||
return True
|
||||
|
||||
async def _search(self, section: str = "feed") -> bool:
|
||||
terms = list(
|
||||
SEARCH_TERMS.get(self.state.persona, ["python", "rust", "ai", "web"])
|
||||
)
|
||||
for href in self.state.known_posts[-10:]:
|
||||
slug = href.rsplit("/", 1)[-1]
|
||||
words = [w for w in re.split(r"[-_]", slug) if len(w) > 4]
|
||||
if words:
|
||||
terms.append(random.choice(words))
|
||||
term = random.choice(terms)
|
||||
await self.b.goto(f"{self.base_url}/{section}?search={term.replace(' ', '+')}")
|
||||
self.state.searches_run += 1
|
||||
self._action("SEARCH", f"/{section} '{term}'")
|
||||
await self.b._idle(0.8, 1.8)
|
||||
return True
|
||||
|
||||
async def _engage_community(self) -> bool:
|
||||
b = self.b
|
||||
tab = random.choice(["recent", "recent", "trending"])
|
||||
await b.goto(f"{self.base_url}/feed?tab={tab}")
|
||||
await b._idle(0.8, 1.8)
|
||||
links = b.page.locator("a[href*='/posts/']")
|
||||
count = await links.count()
|
||||
if count == 0:
|
||||
return False
|
||||
own = set(self.state.own_post_urls)
|
||||
known = {u.lower() for u in self.state.known_users if u}
|
||||
me = self.state.username.lower()
|
||||
ranked: list[tuple[float, int, str]] = []
|
||||
for i in range(min(count, 30)):
|
||||
link = links.nth(i)
|
||||
href = await link.get_attribute("href") or ""
|
||||
if "/posts/" not in href or len(href) < 20:
|
||||
continue
|
||||
if any(href in u for u in own):
|
||||
continue
|
||||
score = random.random()
|
||||
try:
|
||||
card = link.locator(
|
||||
"xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
|
||||
).first
|
||||
if await card.count() > 0:
|
||||
author = card.locator("a[href*='/profile/']").first
|
||||
if await author.count() > 0:
|
||||
ah = await author.get_attribute("href") or ""
|
||||
uname = (
|
||||
ah.split("/profile/")[-1].split("?")[0].split("/")[0].lower()
|
||||
)
|
||||
if uname and uname == me:
|
||||
continue
|
||||
if uname in known:
|
||||
score += 2.0
|
||||
except Exception:
|
||||
pass
|
||||
ranked.append((score, i, href))
|
||||
if not ranked:
|
||||
return False
|
||||
ranked.sort(key=lambda r: r[0], reverse=True)
|
||||
idx, href = ranked[0][1], ranked[0][2]
|
||||
try:
|
||||
await links.nth(idx).click(timeout=5000)
|
||||
except Exception as e:
|
||||
logger.debug("_engage_community open failed: %s", e)
|
||||
return False
|
||||
await b._idle(1.0, 2.0)
|
||||
if href and href not in self.state.known_posts:
|
||||
self.state.known_posts.append(href)
|
||||
await self._read_like_human(3, 60)
|
||||
acted = False
|
||||
if await self._comment_on_post():
|
||||
acted = True
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.6 and await self._reply_to_random_comment():
|
||||
acted = True
|
||||
await b._idle(0.4, 1.2)
|
||||
if random.random() < 0.7:
|
||||
await self._vote_on_feed()
|
||||
if random.random() < 0.5:
|
||||
await self._react_to_object()
|
||||
if acted:
|
||||
self._action("ENGAGE", f"community thread {href[-40:]}")
|
||||
return acted
|
||||
705
devplacepy/services/bot/engage.py
Normal file
705
devplacepy/services/bot/engage.py
Normal file
@ -0,0 +1,705 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.services.bot.config import (
|
||||
COMMENT_COOLDOWN_MAX_SECONDS,
|
||||
COMMENT_COOLDOWN_MIN_SECONDS,
|
||||
MAX_SIBLING_COMMENTS,
|
||||
REACT_RATE_DEFAULT,
|
||||
REACT_RATES,
|
||||
SIBLING_SNIPPET_LEN,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotEngageMixin:
|
||||
async def _vote_on_feed(self) -> bool:
|
||||
b = self.b
|
||||
sel = "button.vote-up, button.post-action-btn.vote-up, form[action*='/votes/'] button[type='submit']"
|
||||
btns = b.page.locator(sel)
|
||||
count = await btns.count()
|
||||
if count == 0:
|
||||
return False
|
||||
voted_set = set(self.state.voted_post_ids)
|
||||
known_set = set(self.state.known_users)
|
||||
candidates: list[tuple[int, str, bool]] = []
|
||||
for i in range(min(count, 30)):
|
||||
btn = btns.nth(i)
|
||||
post_id = ""
|
||||
try:
|
||||
form = btn.locator("xpath=ancestor::form[1]").first
|
||||
if await form.count() > 0:
|
||||
action = await form.get_attribute("action") or ""
|
||||
m = re.search(r"/votes/(?:post|comment|gist)/([a-f0-9-]+)", action)
|
||||
if m:
|
||||
post_id = m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
if not post_id:
|
||||
try:
|
||||
card = btn.locator(
|
||||
"xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
|
||||
).first
|
||||
if await card.count() > 0:
|
||||
link = card.locator("a[href*='/posts/']").first
|
||||
if await link.count() > 0:
|
||||
href = await link.get_attribute("href") or ""
|
||||
m = re.search(r"/posts/([^/?#]+)", href)
|
||||
if m:
|
||||
post_id = m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
if post_id and post_id in voted_set:
|
||||
continue
|
||||
is_known_author = False
|
||||
try:
|
||||
card = btn.locator(
|
||||
"xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
|
||||
).first
|
||||
if await card.count() > 0:
|
||||
author = card.locator("a[href*='/profile/']").first
|
||||
if await author.count() > 0:
|
||||
ah = await author.get_attribute("href") or ""
|
||||
uname = ah.split("/profile/")[-1].split("?")[0].split("/")[0]
|
||||
if uname and uname in known_set:
|
||||
is_known_author = True
|
||||
except Exception:
|
||||
pass
|
||||
candidates.append((i, post_id, is_known_author))
|
||||
if not candidates:
|
||||
return False
|
||||
reciprocal = [c for c in candidates if c[2]]
|
||||
pool = reciprocal if reciprocal and random.random() < 0.65 else candidates
|
||||
idx, post_id, _ = random.choice(pool)
|
||||
try:
|
||||
await btns.nth(idx).click(timeout=3000)
|
||||
self.state.votes_cast += 1
|
||||
if post_id:
|
||||
self.state.voted_post_ids.append(post_id)
|
||||
if len(self.state.voted_post_ids) > 1000:
|
||||
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
|
||||
self._action(
|
||||
"VOTE", f"post {post_id[:12] or '?'} (total {self.state.votes_cast})"
|
||||
)
|
||||
await b._idle(1.0, 2.0)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("_vote_on_feed failed: %s", e)
|
||||
return False
|
||||
|
||||
async def _vote_on_comments(self) -> bool:
|
||||
b = self.b
|
||||
sel = "button.comment-vote-btn, form[action*='/vote/comment'] button[type='submit']"
|
||||
btns = b.page.locator(sel)
|
||||
count = await btns.count()
|
||||
if count < 2:
|
||||
return False
|
||||
idx = random.randrange(count)
|
||||
try:
|
||||
await btns.nth(idx).click(timeout=3000)
|
||||
self.state.votes_cast += 1
|
||||
self._action("VOTE", f"comment (total {self.state.votes_cast})")
|
||||
await b._idle(1.0, 2.0)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("_vote_on_comments failed: %s", e)
|
||||
return False
|
||||
|
||||
async def _is_own_post(self) -> bool:
|
||||
curl = await self.b.url()
|
||||
if "/posts/" not in curl:
|
||||
return False
|
||||
if curl in self.state.own_post_urls:
|
||||
return True
|
||||
author_selectors = [
|
||||
".post-detail-header a[href*='/profile/']",
|
||||
".post-detail .post-detail-header a[href*='/profile/']",
|
||||
".post-author a[href*='/profile/']",
|
||||
".post-header a[href*='/profile/']",
|
||||
]
|
||||
for sel in author_selectors:
|
||||
loc = self.b.page.locator(sel).first
|
||||
if await loc.count() == 0:
|
||||
continue
|
||||
href = await loc.get_attribute("href") or ""
|
||||
if "/profile/" not in href:
|
||||
continue
|
||||
author = href.split("/profile/")[-1].split("?")[0].split("/")[0].lower()
|
||||
is_own = author == self.state.username.lower()
|
||||
if is_own and curl not in self.state.own_post_urls:
|
||||
self.state.own_post_urls.append(curl)
|
||||
return is_own
|
||||
try:
|
||||
author = await self.b.page.evaluate(
|
||||
"""(uname) => {
|
||||
const links = document.querySelectorAll('a[href*="/profile/"]');
|
||||
for (const a of links) {
|
||||
let p = a;
|
||||
let in_nav = false;
|
||||
while (p && p !== document.body) {
|
||||
const cn = (p.className || '').toString();
|
||||
if (p.tagName === 'HEADER' || p.tagName === 'NAV' || /topnav|navbar|sidebar/i.test(cn)) {
|
||||
in_nav = true;
|
||||
break;
|
||||
}
|
||||
p = p.parentElement;
|
||||
}
|
||||
if (in_nav) continue;
|
||||
const m = a.getAttribute('href').match(/\\/profile\\/([^?/]+)/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return '';
|
||||
}""",
|
||||
self.state.username,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("_is_own_post evaluate failed: %s", e)
|
||||
return False
|
||||
if not author:
|
||||
return False
|
||||
is_own = author.lower() == self.state.username.lower()
|
||||
if is_own and curl not in self.state.own_post_urls:
|
||||
self.state.own_post_urls.append(curl)
|
||||
return is_own
|
||||
|
||||
async def _existing_comments(self) -> list[str]:
|
||||
bodies = self.b.page.locator(".comment-body")
|
||||
try:
|
||||
count = await bodies.count()
|
||||
except Exception:
|
||||
return []
|
||||
if count == 0:
|
||||
return []
|
||||
own = self.state.username.lower()
|
||||
collected: list[str] = []
|
||||
start = max(0, count - MAX_SIBLING_COMMENTS)
|
||||
for idx in range(start, count):
|
||||
node = bodies.nth(idx)
|
||||
try:
|
||||
text = (await node.locator(".comment-text").first.inner_text()).strip()
|
||||
except Exception:
|
||||
continue
|
||||
if not text:
|
||||
continue
|
||||
author = ""
|
||||
try:
|
||||
link = node.locator("a[href*='/profile/']").first
|
||||
if await link.count() > 0:
|
||||
href = await link.get_attribute("href") or ""
|
||||
author = href.split("/profile/")[-1].split("?")[0].split("/")[0]
|
||||
except Exception:
|
||||
author = ""
|
||||
if author and author.lower() == own:
|
||||
continue
|
||||
snippet = text[:SIBLING_SNIPPET_LEN]
|
||||
collected.append(f"{author}: {snippet}" if author else snippet)
|
||||
return collected
|
||||
|
||||
async def _comment_on_post(self) -> bool:
|
||||
b = self.b
|
||||
curl = await b.url()
|
||||
if "/posts/" in curl:
|
||||
kind = "post"
|
||||
elif "/gists/" in curl:
|
||||
kind = "gist"
|
||||
elif "/news/" in curl:
|
||||
kind = "news"
|
||||
elif "/projects/" in curl:
|
||||
kind = "project"
|
||||
else:
|
||||
return False
|
||||
|
||||
page_text = (await b.html()).lower()
|
||||
mentioned_here = f"@{self.state.username.lower()}" in page_text
|
||||
thread_id = self._thread_id(curl)
|
||||
|
||||
if self._session_comments >= self._session_comment_cap and not mentioned_here:
|
||||
self._log(
|
||||
f"Comment cap reached ({self._session_comment_cap}/session), skipping"
|
||||
)
|
||||
return False
|
||||
|
||||
if kind == "post":
|
||||
if await self._is_own_post() and not mentioned_here:
|
||||
self._log("Own post, no mention - not commenting")
|
||||
return False
|
||||
elif curl in self.state.own_post_urls:
|
||||
self._log(f"Own {kind} - not commenting")
|
||||
return False
|
||||
|
||||
if (
|
||||
thread_id
|
||||
and thread_id in self.state.commented_post_ids
|
||||
and not mentioned_here
|
||||
):
|
||||
self._log(f"Already commented on {thread_id[:12]}, not repeating")
|
||||
return False
|
||||
|
||||
post_body = (
|
||||
await b.text(".post-content")
|
||||
or await b.text(".gist-detail-desc")
|
||||
or await b.text(".news-detail-content")
|
||||
or await b.text(".news-detail-desc")
|
||||
or await b.text(".project-detail-desc")
|
||||
or await b.text(".rendered-content")
|
||||
or await b.text("article")
|
||||
or ""
|
||||
)
|
||||
if not post_body:
|
||||
self._log("Comment: no content found")
|
||||
return False
|
||||
|
||||
known = list(
|
||||
{u for u in self.state.known_users if u and u != self.state.username}
|
||||
)
|
||||
mention_target = ""
|
||||
if known and random.random() < 0.3:
|
||||
mention_target = random.choice(known)
|
||||
|
||||
style = self.llm.pick_comment_style()
|
||||
siblings = await self._existing_comments()
|
||||
siblings_block = "\n".join(f"- {c}" for c in siblings)
|
||||
siblings_text = " ".join(siblings)
|
||||
|
||||
comment = ""
|
||||
for attempt in range(2):
|
||||
candidate = await self._generate(
|
||||
self.llm.generate_comment,
|
||||
post_body[:1500],
|
||||
self.state.persona,
|
||||
mention_target,
|
||||
"",
|
||||
style,
|
||||
siblings_block,
|
||||
)
|
||||
if not candidate:
|
||||
continue
|
||||
verdict = await self._generate(
|
||||
self.llm.quality_check,
|
||||
"comment",
|
||||
candidate,
|
||||
post_body[:1500],
|
||||
style,
|
||||
siblings_text,
|
||||
)
|
||||
if verdict is None or verdict[0]:
|
||||
comment = candidate
|
||||
break
|
||||
self.state.quality_rejections += 1
|
||||
action = "regenerating" if attempt == 0 else "skipping"
|
||||
self._log(f"Comment quality reject ({verdict[1]}), {action}")
|
||||
if not comment:
|
||||
self._log("Comment: no quality candidate")
|
||||
return False
|
||||
|
||||
if mention_target and f"@{mention_target.lower()}" not in comment.lower():
|
||||
words = comment.split(" ", 1)
|
||||
if len(words) > 1:
|
||||
comment = f"{words[0]} @{mention_target} {words[1]}"
|
||||
else:
|
||||
comment = f"@{mention_target} {comment}"
|
||||
|
||||
comment = comment[:2000]
|
||||
mention_log = f" @{mention_target}" if mention_target else ""
|
||||
|
||||
textarea_sels = [
|
||||
"form.comment-form textarea[name='content']",
|
||||
"textarea#comment-content",
|
||||
"textarea.emoji-picker-target",
|
||||
"textarea[name='content']",
|
||||
"#comment-content",
|
||||
]
|
||||
ok = False
|
||||
used_sel = ""
|
||||
for sel in textarea_sels:
|
||||
if await self._type_like_human(sel, comment):
|
||||
ok = True
|
||||
used_sel = sel
|
||||
break
|
||||
|
||||
if not ok:
|
||||
self._log("Comment: no textarea found")
|
||||
return False
|
||||
|
||||
self._log(f"Typed comment into {used_sel}")
|
||||
|
||||
await b._idle(0.5, 1.5)
|
||||
|
||||
submit_sels = [
|
||||
"form.comment-form button[type='submit']",
|
||||
"button.btn-primary:has-text('Post')",
|
||||
"form[action*='/comments/create'] button[type='submit']",
|
||||
".comment-form button[type='submit']",
|
||||
]
|
||||
ok = False
|
||||
for sel in submit_sels:
|
||||
if await b.click(sel):
|
||||
ok = True
|
||||
break
|
||||
|
||||
if ok:
|
||||
self.state.comments_posted += 1
|
||||
self._session_comments += 1
|
||||
if thread_id and thread_id not in self.state.commented_post_ids:
|
||||
self.state.commented_post_ids.append(thread_id)
|
||||
if len(self.state.commented_post_ids) > 1000:
|
||||
self.state.commented_post_ids = self.state.commented_post_ids[-500:]
|
||||
if kind == "news":
|
||||
self.state.news_comments += 1
|
||||
tag = "NEWS" if kind == "news" else "COMMENT"
|
||||
self._action(
|
||||
tag,
|
||||
f"on {kind}{mention_log} [{self.state.comments_posted}]: {comment[:60]}",
|
||||
)
|
||||
await asyncio.sleep(
|
||||
random.uniform(
|
||||
COMMENT_COOLDOWN_MIN_SECONDS, COMMENT_COOLDOWN_MAX_SECONDS
|
||||
)
|
||||
)
|
||||
return True
|
||||
self._log("Comment: submit button not found/enabled")
|
||||
return False
|
||||
|
||||
async def _reply_to_random_comment(self) -> bool:
|
||||
b = self.b
|
||||
curl = await b.url()
|
||||
if "/posts/" not in curl:
|
||||
return False
|
||||
if self._session_comments >= self._session_comment_cap:
|
||||
self._log(
|
||||
f"Comment cap reached ({self._session_comment_cap}/session), skipping reply"
|
||||
)
|
||||
return False
|
||||
comments = b.page.locator(
|
||||
".comment, li.comment, article.comment, .comment-item"
|
||||
)
|
||||
count = await comments.count()
|
||||
if count == 0:
|
||||
return False
|
||||
limit = min(count, 10)
|
||||
for _ in range(limit):
|
||||
idx = random.randrange(limit)
|
||||
target = comments.nth(idx)
|
||||
try:
|
||||
text = (await target.inner_text() or "")[:1000]
|
||||
except Exception:
|
||||
continue
|
||||
if not text:
|
||||
continue
|
||||
if f"@{self.state.username.lower()}" in text.lower():
|
||||
continue
|
||||
mentioner = ""
|
||||
try:
|
||||
author_link = target.locator("a[href*='/profile/']").first
|
||||
if await author_link.count() > 0:
|
||||
ah = await author_link.get_attribute("href") or ""
|
||||
if "/profile/" in ah:
|
||||
mentioner = (
|
||||
ah.split("/profile/")[-1].split("?")[0].split("/")[0]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if not mentioner or mentioner.lower() == self.state.username.lower():
|
||||
continue
|
||||
try:
|
||||
await target.scroll_into_view_if_needed(timeout=2000)
|
||||
await b._idle(0.3, 0.8)
|
||||
reply_btn = target.locator(
|
||||
".comment-action-btn, button:has-text('Reply'), a:has-text('Reply')"
|
||||
).first
|
||||
if await reply_btn.count() == 0:
|
||||
continue
|
||||
await reply_btn.click(timeout=3000)
|
||||
await b._idle(0.5, 1.5)
|
||||
except Exception as e:
|
||||
logger.debug("reply-btn click failed: %s", e)
|
||||
continue
|
||||
post_body = await b.text(".post-content") or await b.text("article") or ""
|
||||
siblings = [c for c in await self._existing_comments() if text[:60] not in c]
|
||||
siblings_block = "\n".join(f"- {c}" for c in siblings)
|
||||
reply = await self._generate(
|
||||
self.llm.generate_comment,
|
||||
post_body[:1200],
|
||||
self.state.persona,
|
||||
mentioner,
|
||||
text,
|
||||
self.llm.pick_comment_style(),
|
||||
siblings_block,
|
||||
)
|
||||
if not reply:
|
||||
self._log("Reply: generation failed/empty")
|
||||
return False
|
||||
if mentioner and f"@{mentioner.lower()}" not in reply.lower():
|
||||
reply = f"@{mentioner} {reply}"
|
||||
reply = reply[:2000]
|
||||
textarea_sels = [
|
||||
".reply-form textarea[name='content']",
|
||||
".comment-form textarea[name='content']",
|
||||
"textarea[name='content']",
|
||||
]
|
||||
typed = False
|
||||
for sel in textarea_sels:
|
||||
if await self._type_like_human(sel, reply):
|
||||
typed = True
|
||||
break
|
||||
if not typed:
|
||||
return False
|
||||
await b._idle(0.5, 1.5)
|
||||
submit_sels = [
|
||||
".reply-form button[type='submit']",
|
||||
"form.comment-form button[type='submit']",
|
||||
"button.btn-primary:has-text('Reply')",
|
||||
"button.btn-primary:has-text('Post')",
|
||||
]
|
||||
for sel in submit_sels:
|
||||
if await b.click(sel):
|
||||
self.state.comments_posted += 1
|
||||
self._session_comments += 1
|
||||
if mentioner not in self.state.known_users:
|
||||
self.state.known_users.append(mentioner)
|
||||
self._action(
|
||||
"REPLY",
|
||||
f"to @{mentioner} [{self.state.comments_posted}]: {reply[:60]}",
|
||||
)
|
||||
await asyncio.sleep(
|
||||
random.uniform(
|
||||
COMMENT_COOLDOWN_MIN_SECONDS, COMMENT_COOLDOWN_MAX_SECONDS
|
||||
)
|
||||
)
|
||||
return True
|
||||
return False
|
||||
return False
|
||||
|
||||
async def _vote_on_gist(self) -> bool:
|
||||
b = self.b
|
||||
sel = (
|
||||
"form[action*='/votes/gist/'] button.gist-card-star, "
|
||||
"form[action*='/votes/gist/'] button[type='submit']"
|
||||
)
|
||||
btns = b.page.locator(sel)
|
||||
count = await btns.count()
|
||||
if count == 0:
|
||||
return False
|
||||
voted = set(self.state.voted_post_ids)
|
||||
candidates: list[tuple[int, str]] = []
|
||||
for i in range(min(count, 30)):
|
||||
gist_id = ""
|
||||
try:
|
||||
form = btns.nth(i).locator("xpath=ancestor::form[1]").first
|
||||
if await form.count() > 0:
|
||||
action = await form.get_attribute("action") or ""
|
||||
m = re.search(r"/votes/gist/([a-f0-9-]+)", action)
|
||||
if m:
|
||||
gist_id = m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
if gist_id and gist_id in voted:
|
||||
continue
|
||||
candidates.append((i, gist_id))
|
||||
if not candidates:
|
||||
return False
|
||||
idx, gist_id = random.choice(candidates)
|
||||
try:
|
||||
await btns.nth(idx).click(timeout=3000)
|
||||
self.state.votes_cast += 1
|
||||
if gist_id:
|
||||
self.state.voted_post_ids.append(gist_id)
|
||||
if len(self.state.voted_post_ids) > 1000:
|
||||
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
|
||||
self._action(
|
||||
"VOTE", f"gist {gist_id[:12] or '?'} (total {self.state.votes_cast})"
|
||||
)
|
||||
await b._idle(1.0, 2.0)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("_vote_on_gist failed: %s", e)
|
||||
return False
|
||||
|
||||
async def _vote_on_project(self) -> bool:
|
||||
b = self.b
|
||||
sel = (
|
||||
"form[action*='/votes/project/'] button.project-star-btn, "
|
||||
"form[action*='/votes/project/'] button[type='submit']"
|
||||
)
|
||||
btns = b.page.locator(sel)
|
||||
count = await btns.count()
|
||||
if count == 0:
|
||||
return False
|
||||
voted = set(self.state.voted_post_ids)
|
||||
candidates: list[tuple[int, str]] = []
|
||||
for i in range(min(count, 30)):
|
||||
project_id = ""
|
||||
try:
|
||||
form = btns.nth(i).locator("xpath=ancestor::form[1]").first
|
||||
if await form.count() > 0:
|
||||
action = await form.get_attribute("action") or ""
|
||||
m = re.search(r"/votes/project/([a-f0-9-]+)", action)
|
||||
if m:
|
||||
project_id = m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
if project_id and project_id in voted:
|
||||
continue
|
||||
candidates.append((i, project_id))
|
||||
if not candidates:
|
||||
return False
|
||||
idx, project_id = random.choice(candidates)
|
||||
try:
|
||||
await btns.nth(idx).click(timeout=3000)
|
||||
self.state.votes_cast += 1
|
||||
self.state.projects_starred += 1
|
||||
if project_id:
|
||||
self.state.voted_post_ids.append(project_id)
|
||||
if len(self.state.voted_post_ids) > 1000:
|
||||
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
|
||||
self._action(
|
||||
"STAR",
|
||||
f"project {project_id[:12] or '?'} (total {self.state.votes_cast})",
|
||||
)
|
||||
await b._idle(1.0, 2.0)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("_vote_on_project failed: %s", e)
|
||||
return False
|
||||
|
||||
async def _vote_on_poll(self) -> bool:
|
||||
b = self.b
|
||||
polls = b.page.locator(".poll[data-poll-uid]")
|
||||
count = await polls.count()
|
||||
if count == 0:
|
||||
return False
|
||||
voted = set(self.state.polls_voted)
|
||||
for i in range(min(count, 20)):
|
||||
poll = polls.nth(i)
|
||||
poll_uid = await poll.get_attribute("data-poll-uid") or ""
|
||||
if not poll_uid or poll_uid in voted:
|
||||
continue
|
||||
if await poll.locator(".poll-option.chosen").count() > 0:
|
||||
self.state.polls_voted.append(poll_uid)
|
||||
continue
|
||||
options = poll.locator(".poll-option:not([disabled])")
|
||||
opt_count = await options.count()
|
||||
if opt_count == 0:
|
||||
continue
|
||||
choice = options.nth(random.randrange(opt_count))
|
||||
option_uid = await choice.get_attribute("data-option-uid") or ""
|
||||
try:
|
||||
await choice.scroll_into_view_if_needed(timeout=2000)
|
||||
await b._idle(0.3, 0.8)
|
||||
await choice.click(timeout=3000)
|
||||
except Exception as e:
|
||||
logger.debug("_vote_on_poll click failed: %s", e)
|
||||
continue
|
||||
try:
|
||||
await poll.locator(".poll-option.chosen").first.wait_for(
|
||||
state="visible", timeout=4000
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("_vote_on_poll confirm failed: %s", e)
|
||||
self.state.polls_voted.append(poll_uid)
|
||||
if len(self.state.polls_voted) > 1000:
|
||||
self.state.polls_voted = self.state.polls_voted[-500:]
|
||||
self.state.poll_votes_cast += 1
|
||||
self._action(
|
||||
"POLL",
|
||||
f"voted {poll_uid[:12]} opt {option_uid[:8] or '?'} (total {self.state.poll_votes_cast})",
|
||||
)
|
||||
await b._idle(1.0, 2.0)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _reaction_content(self, bar: Any) -> str:
|
||||
try:
|
||||
container = bar.locator(
|
||||
"xpath=ancestor::article[contains(concat(' ',normalize-space(@class),' '),' post-card ')][1]"
|
||||
" | ancestor::div[contains(concat(' ',normalize-space(@class),' '),' comment ')][1]"
|
||||
).first
|
||||
if await container.count() > 0:
|
||||
text = (await container.inner_text() or "").strip()
|
||||
if text:
|
||||
return text[:1200]
|
||||
except Exception as e:
|
||||
logger.debug("_reaction_content container failed: %s", e)
|
||||
body = (
|
||||
await self.b.text(".post-content")
|
||||
or await self.b.text(".gist-detail-desc")
|
||||
or await self.b.text(".news-detail-content")
|
||||
or await self.b.text(".news-detail-desc")
|
||||
or await self.b.text(".project-detail-desc")
|
||||
or await self.b.text(".rendered-content")
|
||||
or await self.b.text("article")
|
||||
or ""
|
||||
)
|
||||
return body[:1200]
|
||||
|
||||
async def _react_to_object(self) -> bool:
|
||||
b = self.b
|
||||
bars = b.page.locator(".reaction-bar[data-reaction-uid]")
|
||||
count = await bars.count()
|
||||
if count == 0:
|
||||
return False
|
||||
decided = set(self.state.reacted_targets)
|
||||
react_rate = REACT_RATES.get(self.state.persona, REACT_RATE_DEFAULT)
|
||||
for i in range(min(count, 20)):
|
||||
bar = bars.nth(i)
|
||||
uid = await bar.get_attribute("data-reaction-uid") or ""
|
||||
rtype = await bar.get_attribute("data-reaction-type") or ""
|
||||
if not uid or uid in decided:
|
||||
continue
|
||||
|
||||
self.state.reacted_targets.append(uid)
|
||||
if len(self.state.reacted_targets) > 2000:
|
||||
self.state.reacted_targets = self.state.reacted_targets[-1000:]
|
||||
|
||||
if random.random() > react_rate:
|
||||
self._log(f"Reaction: skipping {rtype} {uid[:12]} (persona reticence)")
|
||||
return False
|
||||
|
||||
snippet = await self._reaction_content(bar)
|
||||
if not snippet:
|
||||
self._log(f"Reaction: no content for {rtype} {uid[:12]}")
|
||||
return False
|
||||
emoji = await self._generate(
|
||||
self.llm.select_reaction, snippet, self.state.persona
|
||||
)
|
||||
if not emoji:
|
||||
self._log(f"Reaction: nothing fitting for {rtype} {uid[:12]}")
|
||||
return False
|
||||
|
||||
add_btn = bar.locator(".reaction-add-btn").first
|
||||
if await add_btn.count() == 0:
|
||||
return False
|
||||
palette_btn = bar.locator(
|
||||
f".reaction-palette-btn[data-reaction-emoji='{emoji}']"
|
||||
).first
|
||||
if await palette_btn.count() == 0:
|
||||
return False
|
||||
try:
|
||||
await add_btn.scroll_into_view_if_needed(timeout=2000)
|
||||
await b._idle(0.2, 0.6)
|
||||
await add_btn.click(timeout=3000)
|
||||
await b._idle(0.3, 0.7)
|
||||
await palette_btn.click(timeout=3000)
|
||||
except Exception as e:
|
||||
logger.debug("_react_to_object click failed: %s", e)
|
||||
return False
|
||||
try:
|
||||
await bar.locator(".reaction-chip.reacted").first.wait_for(
|
||||
state="visible", timeout=4000
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("_react_to_object confirm failed: %s", e)
|
||||
self.state.reactions_cast += 1
|
||||
self._action(
|
||||
"REACT",
|
||||
f"{emoji} on {rtype} {uid[:12]} (total {self.state.reactions_cast})",
|
||||
)
|
||||
await b._idle(1.0, 2.0)
|
||||
return True
|
||||
return False
|
||||
157
devplacepy/services/bot/helpers.py
Normal file
157
devplacepy/services/bot/helpers.py
Normal file
@ -0,0 +1,157 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.services.bot.config import persona_article_score, pick_category
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotHelpersMixin:
|
||||
def _identity(self) -> str:
|
||||
return f"{self.state.username or '?'}/{self.state.persona}"
|
||||
|
||||
def _cost_tag(self) -> str:
|
||||
return f"${self.llm.total_cost:.4f} / {self.llm.total_calls} calls"
|
||||
|
||||
def _notify(self, line: str) -> None:
|
||||
if self.on_event:
|
||||
try:
|
||||
self.on_event(line)
|
||||
except Exception as e:
|
||||
logger.debug("on_event failed: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def _thread_id(url: str) -> str:
|
||||
m = re.search(r"/(?:posts|gists|news|projects)/([^/?#]+)", url)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
def _sync_cost(self) -> None:
|
||||
self.state.total_cost = self.llm.total_cost
|
||||
self.state.total_calls = self.llm.total_calls
|
||||
self.state.total_in_tokens = self.llm.total_in_tokens
|
||||
self.state.total_out_tokens = self.llm.total_out_tokens
|
||||
|
||||
def _save(self) -> None:
|
||||
self._sync_cost()
|
||||
self.state.save(self.state_path)
|
||||
|
||||
def _log(self, msg: str) -> None:
|
||||
self.state.log.append(f"[{time.strftime('%H:%M:%S')}] {msg}")
|
||||
if len(self.state.log) > 500:
|
||||
self.state.log = self.state.log[-250:]
|
||||
logger.info("[%s] %s", self._identity(), msg)
|
||||
|
||||
def _action(self, tag: str, detail: str) -> None:
|
||||
line = f"{tag}: {detail}"
|
||||
self.state.log.append(f"[{time.strftime('%H:%M:%S')}] {line}")
|
||||
if len(self.state.log) > 500:
|
||||
self.state.log = self.state.log[-250:]
|
||||
logger.info("[%s] %s (%s)", self._identity(), line, self._cost_tag())
|
||||
self._notify(f"[{self._identity()}] {line}")
|
||||
self.b.note(action=line)
|
||||
asyncio.create_task(self.b.capture(tag.lower(), force=True))
|
||||
|
||||
def _bind_monitor(self) -> None:
|
||||
self.b.bind_monitor(
|
||||
self._slot,
|
||||
username=self.state.username,
|
||||
persona=self.state.persona,
|
||||
)
|
||||
|
||||
async def _generate(self, fn: Any, *args: Any) -> Any:
|
||||
try:
|
||||
return await asyncio.to_thread(fn, *args)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"LLM generation failed (%s): %s", getattr(fn, "__name__", fn), e
|
||||
)
|
||||
return None
|
||||
|
||||
def _cost_summary(self, label: str) -> None:
|
||||
self._notify(
|
||||
f"[{self._identity()}] cost ({label}): ${self.llm.total_cost:.6f} "
|
||||
f"over {self.llm.total_calls} calls (in={self.llm.total_in_tokens} out={self.llm.total_out_tokens})"
|
||||
)
|
||||
|
||||
def _session_summary(self, mood: str, actions: int) -> None:
|
||||
s = self.state
|
||||
sess_cost = self.llm.total_cost - self._session_start_cost
|
||||
sess_calls = self.llm.total_calls - self._session_start_calls
|
||||
self._notify(
|
||||
f"[{self._identity()}] session done mood={mood} actions={actions} "
|
||||
f"posts={s.created_posts} comments={s.comments_posted} votes={s.votes_cast} "
|
||||
f"session=${sess_cost:.4f}/{sess_calls} lifetime=${self.llm.total_cost:.4f}"
|
||||
)
|
||||
|
||||
def _session_banner(self, mood: str, session_len: int) -> None:
|
||||
self._notify(
|
||||
f"[{self._identity()}] session start mood={mood} target~{session_len} actions"
|
||||
)
|
||||
|
||||
def _pick_article(self, articles: list[dict]) -> Optional[dict]:
|
||||
if not articles:
|
||||
return None
|
||||
ranked = sorted(
|
||||
articles,
|
||||
key=lambda a: persona_article_score(a, self.state.persona)
|
||||
+ random.random(),
|
||||
reverse=True,
|
||||
)
|
||||
top = ranked[: max(3, len(ranked) // 3)]
|
||||
return random.choice(top)
|
||||
|
||||
async def _refill_cache_async(self, kind: str) -> None:
|
||||
try:
|
||||
if kind == "post":
|
||||
articles = self.news.fetch()
|
||||
a = self._pick_article(articles)
|
||||
if a:
|
||||
desc = a.get("description", "")[:500]
|
||||
cat = pick_category(self.state.persona)
|
||||
post_title = await asyncio.to_thread(
|
||||
self.llm.generate_post_title,
|
||||
a["title"],
|
||||
self.state.persona,
|
||||
cat,
|
||||
)
|
||||
content = await asyncio.to_thread(
|
||||
self.llm.generate_post,
|
||||
a["title"],
|
||||
desc,
|
||||
self.state.persona,
|
||||
cat,
|
||||
self.state.recent_post_titles,
|
||||
)
|
||||
self._post_cache.append(
|
||||
(a["title"], post_title or "", desc, content, cat)
|
||||
)
|
||||
elif kind == "project":
|
||||
t = await asyncio.to_thread(
|
||||
self.llm.generate_project_title, self.state.persona
|
||||
)
|
||||
desc = await asyncio.to_thread(
|
||||
self.llm.generate_project_desc, t, self.state.persona
|
||||
)
|
||||
self._project_cache.append((t, desc))
|
||||
elif kind == "issue":
|
||||
topic = random.choice(["UI glitch", "Broken link", "Performance issue"])
|
||||
issue = await asyncio.to_thread(self.llm.generate_issue, topic)
|
||||
self._issue_cache.append(issue)
|
||||
except Exception as e:
|
||||
logger.warning("Cache refill (%s) failed: %s", kind, e)
|
||||
|
||||
async def _warm_cache(self) -> None:
|
||||
self._log("Warming content cache (background)...")
|
||||
self._post_cache = []
|
||||
self._project_cache = []
|
||||
self._issue_cache = []
|
||||
self.news.fetch()
|
||||
asyncio.create_task(self._refill_cache_async("post"))
|
||||
asyncio.create_task(self._refill_cache_async("project"))
|
||||
asyncio.create_task(self._refill_cache_async("issue"))
|
||||
178
devplacepy/services/bot/human.py
Normal file
178
devplacepy/services/bot/human.py
Normal file
@ -0,0 +1,178 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotHumanMixin:
|
||||
async def _read_like_human(self, min_s: float = 3.0, max_s: float = 90.0) -> None:
|
||||
if not self.state.reading_speed_wpm:
|
||||
self.state.reading_speed_wpm = random.randint(180, 320)
|
||||
wpm = self.state.reading_speed_wpm
|
||||
sec_per_word = 60.0 / wpm
|
||||
curl = await self.b.url()
|
||||
if "/posts/" in curl:
|
||||
body = (
|
||||
await self.b.text("article") or await self.b.text(".post-content") or ""
|
||||
)
|
||||
wc = len(body.split())
|
||||
jitter = random.uniform(0.7, 1.25)
|
||||
pause = min(max(wc * sec_per_word * jitter, min_s), max_s)
|
||||
self._log(f"Reading {wc} words ({pause:.0f}s @ {wpm}wpm)")
|
||||
scrolls = max(1, int(pause / 12))
|
||||
chunk = pause / scrolls
|
||||
for _ in range(scrolls):
|
||||
await asyncio.sleep(chunk)
|
||||
if random.random() < 0.55:
|
||||
await self.b.scroll(random.randint(150, 450))
|
||||
else:
|
||||
await asyncio.sleep(random.uniform(1.0, 4.0))
|
||||
|
||||
async def _type_like_human(self, sel: str, val: str) -> bool:
|
||||
el = self.b.page.locator(sel).first
|
||||
if await el.count() == 0:
|
||||
return False
|
||||
try:
|
||||
await el.click(timeout=3000)
|
||||
await self.b._idle(0.1, 0.3)
|
||||
await el.fill("")
|
||||
words = val.split(" ")
|
||||
for i, word in enumerate(words):
|
||||
for ch in word:
|
||||
delay = random.randint(15, 80)
|
||||
if ch in ".!?":
|
||||
delay = random.randint(200, 500)
|
||||
elif ch == ",":
|
||||
delay = random.randint(100, 250)
|
||||
await self.b.page.keyboard.type(ch, delay=delay)
|
||||
if i < len(words) - 1:
|
||||
space_delay = random.randint(30, 120)
|
||||
await self.b.page.keyboard.type(" ", delay=space_delay)
|
||||
if random.random() < 0.08:
|
||||
await self.b._idle(0.3, 1.0)
|
||||
if (i + 1) % 5 == 0 and random.random() < 0.3:
|
||||
await self.b._idle(0.2, 0.8)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("_type_like_human failed for '%s': %s", sel, e)
|
||||
return False
|
||||
|
||||
async def _fatigue_check(self, session_actions: int) -> Optional[str]:
|
||||
if session_actions < 5:
|
||||
return None
|
||||
chance = session_actions / 100.0
|
||||
if random.random() < chance:
|
||||
if session_actions < 10:
|
||||
return "casual"
|
||||
elif session_actions < 20:
|
||||
return "normal"
|
||||
else:
|
||||
return "deep"
|
||||
return None
|
||||
|
||||
async def _session_type(self) -> tuple[str, int, tuple]:
|
||||
roll = random.random()
|
||||
if roll < 0.25:
|
||||
return ("lurking", random.randint(2, 5), (3600, 21600))
|
||||
elif roll < 0.60:
|
||||
return ("casual", random.randint(4, 10), (1800, 7200))
|
||||
elif roll < 0.85:
|
||||
return ("normal", random.randint(8, 18), (3600, 14400))
|
||||
else:
|
||||
return ("deep", random.randint(15, 30), (14400, 43200))
|
||||
|
||||
async def _ensure_identity(self) -> None:
|
||||
if self.state.identity:
|
||||
return
|
||||
identity = await self._generate(
|
||||
self.llm.generate_identity, self.state.persona
|
||||
)
|
||||
if identity:
|
||||
self.state.identity = identity
|
||||
self._log(
|
||||
f"Identity card: {identity.get('name', '?')} ({self.state.persona})"
|
||||
)
|
||||
self._save()
|
||||
|
||||
async def _page_summary(self, page: str) -> str:
|
||||
b = self.b
|
||||
title = ""
|
||||
for sel in ("h1", ".post-title", ".content-title", "title"):
|
||||
title = (await b.text(sel) or "").strip()
|
||||
if title:
|
||||
break
|
||||
summary = f'title="{title[:100]}"' if title else "no title"
|
||||
try:
|
||||
comments = await b.page.locator(".comment, .comment-item").count()
|
||||
if comments:
|
||||
summary += f", {comments} comments"
|
||||
except Exception as e:
|
||||
logger.debug("page summary count failed: %s", e)
|
||||
return summary
|
||||
|
||||
async def _unread_notification_count(self) -> int:
|
||||
try:
|
||||
badge = self.b.page.locator(
|
||||
"a[data-counter='notifications'] .nav-badge[data-counter-badge]"
|
||||
).first
|
||||
if await badge.count() == 0:
|
||||
return 0
|
||||
if await badge.get_attribute("hidden") is not None:
|
||||
return 0
|
||||
text = (await badge.inner_text() or "").strip()
|
||||
digits = re.sub(r"[^0-9]", "", text)
|
||||
return int(digits) if digits else 0
|
||||
except Exception as e:
|
||||
logger.debug("_unread_notification_count failed: %s", e)
|
||||
return 0
|
||||
|
||||
async def _page_state(
|
||||
self,
|
||||
curl: str,
|
||||
page_text: str,
|
||||
*,
|
||||
did_post: bool,
|
||||
follows: int,
|
||||
gists: int,
|
||||
can_project: bool,
|
||||
can_post: bool,
|
||||
can_message: bool,
|
||||
) -> dict:
|
||||
pages = [
|
||||
("post", "/posts/" in curl),
|
||||
("profile", "/profile/" in curl),
|
||||
("news_detail", "/news/" in curl),
|
||||
("news_list", bool(re.search(r"/news(\?|$)", curl))),
|
||||
("project_detail", "/projects/" in curl),
|
||||
("projects_list", bool(re.search(r"/projects(\?|$)", curl))),
|
||||
("gist_detail", "/gists/" in curl),
|
||||
("gists_list", bool(re.search(r"/gists(\?|$)", curl))),
|
||||
("notifications", "/notifications" in curl),
|
||||
]
|
||||
page = next((name for name, hit in pages if hit), "feed")
|
||||
own_post = page == "post" and await self._is_own_post()
|
||||
mentioned = own_post and f"@{self.state.username.lower()}" in page_text
|
||||
own_profile = (
|
||||
page == "profile"
|
||||
and f"/profile/{self.state.username.lower()}" in curl.lower()
|
||||
)
|
||||
return {
|
||||
"page": page,
|
||||
"url": curl,
|
||||
"visible": await self._page_summary(page),
|
||||
"own_post": own_post,
|
||||
"own_profile": own_profile,
|
||||
"mentioned": mentioned,
|
||||
"did_post": did_post,
|
||||
"follows": follows,
|
||||
"gists": gists,
|
||||
"can_project": can_project,
|
||||
"can_post": can_post,
|
||||
"can_message": can_message,
|
||||
"unread_notifications": await self._unread_notification_count(),
|
||||
}
|
||||
514
devplacepy/services/bot/loop.py
Normal file
514
devplacepy/services/bot/loop.py
Normal file
@ -0,0 +1,514 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
|
||||
from devplacepy.services.bot.config import (
|
||||
NOTIFICATION_CHECK_PROB,
|
||||
SESSION_COMMENT_CAP_MAX,
|
||||
SESSION_COMMENT_CAP_MIN,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotLoopMixin:
|
||||
async def _cycle(
|
||||
self,
|
||||
mood: str = "normal",
|
||||
*,
|
||||
session_posted: bool = False,
|
||||
session_projects: int = 0,
|
||||
session_follows: int = 0,
|
||||
session_gists: int = 0,
|
||||
can_project: bool = False,
|
||||
can_post: bool = False,
|
||||
can_message: bool = False,
|
||||
) -> tuple[int, bool, int, int, int]:
|
||||
b = self.b
|
||||
curl = await b.url()
|
||||
page_text = (await b.html()).lower()
|
||||
|
||||
on_post = "/posts/" in curl
|
||||
on_profile = "/profile/" in curl
|
||||
on_project_detail = "/projects/" in curl
|
||||
on_projects_list = bool(re.search(r"/projects(\?|$)", curl))
|
||||
on_gist_detail = "/gists/" in curl
|
||||
on_gists_list = bool(re.search(r"/gists(\?|$)", curl))
|
||||
on_news_detail = "/news/" in curl
|
||||
on_news_list = bool(re.search(r"/news(\?|$)", curl))
|
||||
on_notifications = "/notifications" in curl
|
||||
|
||||
actions = 0
|
||||
did_post = session_posted
|
||||
projs = session_projects
|
||||
follows = session_follows
|
||||
gists = session_gists
|
||||
msgs = 0
|
||||
mood_factor = {"lurking": 0.3, "casual": 0.6, "normal": 1.0, "deep": 1.5}
|
||||
|
||||
burst = max(1, int(random.gauss(3, 1) * mood_factor[mood]))
|
||||
for _ in range(burst):
|
||||
if mood == "lurking" and not on_post and random.random() < 0.4:
|
||||
await self._scroll_page()
|
||||
await b._idle(0.3, 1.0)
|
||||
actions += 1
|
||||
curl = await b.url()
|
||||
page_text = (await b.html()).lower()
|
||||
on_post = "/posts/" in curl
|
||||
continue
|
||||
|
||||
if on_post:
|
||||
page_text = (await b.html()).lower()
|
||||
own_post = await self._is_own_post()
|
||||
mentioned = own_post and f"@{self.state.username.lower()}" in page_text
|
||||
|
||||
await self._read_like_human(4, 90)
|
||||
|
||||
if own_post and not mentioned:
|
||||
self._log("Own post, no mention - skipping interactions")
|
||||
await self._random_nav_click()
|
||||
await b._idle(1.0, 2.5)
|
||||
else:
|
||||
comment_p = {
|
||||
"lurking": 0.5,
|
||||
"casual": 0.75,
|
||||
"normal": 0.85,
|
||||
"deep": 0.9,
|
||||
}[mood]
|
||||
if random.random() < comment_p:
|
||||
if await self._comment_on_post():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.5:
|
||||
if await self._reply_to_random_comment():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.7:
|
||||
if await self._vote_on_feed():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.3:
|
||||
if await self._vote_on_comments():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.6:
|
||||
if await self._vote_on_poll():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.6:
|
||||
if await self._react_to_object():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.75:
|
||||
await b.goto(f"{self.base_url}/feed")
|
||||
await b._idle(0.8, 2.0)
|
||||
elif random.random() < 0.3:
|
||||
await self._random_nav_click()
|
||||
await b._idle(1.0, 2.5)
|
||||
|
||||
elif on_profile:
|
||||
if not follows and random.random() < 0.4:
|
||||
if await self._follow_user():
|
||||
follows += 1
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if can_message and not msgs and random.random() < 0.3:
|
||||
if await self._send_message():
|
||||
msgs += 1
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
await self._scroll_page()
|
||||
await b._idle(0.5, 1.0)
|
||||
if random.random() < 0.3:
|
||||
if await self._click_post_link():
|
||||
actions += 1
|
||||
await self._random_nav_click()
|
||||
await b._idle(1.0, 2.0)
|
||||
actions += 1
|
||||
|
||||
elif on_news_detail:
|
||||
await self._read_like_human(4, 60)
|
||||
comment_p = {"lurking": 0.4, "casual": 0.6, "normal": 0.7, "deep": 0.8}[
|
||||
mood
|
||||
]
|
||||
if random.random() < comment_p:
|
||||
if await self._comment_on_post():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.7:
|
||||
await b.goto(f"{self.base_url}/feed")
|
||||
await b._idle(0.8, 1.8)
|
||||
else:
|
||||
await self._random_nav_click()
|
||||
await b._idle(1.0, 2.5)
|
||||
|
||||
elif on_news_list:
|
||||
await self._scroll_page()
|
||||
await b._idle(0.4, 1.0)
|
||||
if random.random() < 0.7:
|
||||
if await self._open_content("news"):
|
||||
actions += 1
|
||||
else:
|
||||
await self._random_nav_click()
|
||||
await b._idle(0.8, 1.8)
|
||||
actions += 1
|
||||
|
||||
elif on_project_detail:
|
||||
await self._read_like_human(4, 60)
|
||||
if random.random() < 0.6:
|
||||
if await self._vote_on_project():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.5:
|
||||
if await self._react_to_object():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.4:
|
||||
if await self._comment_on_post():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.7:
|
||||
await b.goto(f"{self.base_url}/projects")
|
||||
await b._idle(0.8, 1.8)
|
||||
else:
|
||||
await self._random_nav_click()
|
||||
await b._idle(1.0, 2.5)
|
||||
|
||||
elif on_projects_list:
|
||||
if random.random() < 0.55:
|
||||
if await self._vote_on_project():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if can_project and projs < 1 and random.random() < 0.3:
|
||||
if await self._click_create_project():
|
||||
projs += 1
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.0)
|
||||
elif random.random() < 0.6:
|
||||
if await self._open_content("project"):
|
||||
actions += 1
|
||||
else:
|
||||
await self._scroll_page()
|
||||
await b._idle(0.8, 1.8)
|
||||
actions += 1
|
||||
|
||||
elif on_gist_detail:
|
||||
await self._read_like_human(4, 60)
|
||||
if random.random() < 0.45:
|
||||
if await self._comment_on_post():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.6:
|
||||
if await self._vote_on_feed():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.5:
|
||||
if await self._react_to_object():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.4:
|
||||
await self._random_nav_click()
|
||||
await b._idle(1.0, 2.5)
|
||||
|
||||
elif on_gists_list:
|
||||
if random.random() < 0.6:
|
||||
if await self._vote_on_gist():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if gists < 1 and random.random() < 0.25:
|
||||
if await self._create_gist():
|
||||
gists += 1
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.0)
|
||||
elif random.random() < 0.5:
|
||||
if await self._click_gist_link():
|
||||
actions += 1
|
||||
else:
|
||||
await self._scroll_page()
|
||||
await b._idle(0.8, 1.8)
|
||||
actions += 1
|
||||
|
||||
elif on_notifications:
|
||||
await self._check_notifications()
|
||||
await b._idle(1.0, 2.0)
|
||||
await self._random_nav_click()
|
||||
actions += 1
|
||||
|
||||
else:
|
||||
clicked = False
|
||||
if random.random() < 0.3:
|
||||
acted = await self._react_to_object()
|
||||
if not acted:
|
||||
acted = await self._vote_on_poll()
|
||||
if acted:
|
||||
actions += 1
|
||||
clicked = True
|
||||
if not clicked and random.random() < 0.12:
|
||||
if random.random() < 0.5:
|
||||
await self._search(
|
||||
"feed" if random.random() < 0.7 else "projects"
|
||||
)
|
||||
else:
|
||||
await self._browse_section()
|
||||
actions += 1
|
||||
clicked = True
|
||||
if not clicked and random.random() < 0.85:
|
||||
if await self._click_post_link():
|
||||
clicked = True
|
||||
actions += 1
|
||||
if not clicked and can_post and not did_post and random.random() < 0.5:
|
||||
if await self._create_post():
|
||||
did_post = True
|
||||
actions += 1
|
||||
clicked = True
|
||||
if not clicked and random.random() < 0.2:
|
||||
if await self._click_profile_link():
|
||||
clicked = True
|
||||
actions += 1
|
||||
if not clicked and gists < 1 and random.random() < 0.1:
|
||||
if await self._create_gist():
|
||||
gists += 1
|
||||
actions += 1
|
||||
clicked = True
|
||||
if not clicked:
|
||||
if await self._vote_on_feed():
|
||||
actions += 1
|
||||
clicked = True
|
||||
if not clicked:
|
||||
if await self._random_nav_click():
|
||||
clicked = True
|
||||
if not clicked:
|
||||
await self._scroll_page()
|
||||
actions += 1
|
||||
await b._idle(1.0, 2.5)
|
||||
|
||||
curl = await b.url()
|
||||
page_text = (await b.html()).lower()
|
||||
on_post = "/posts/" in curl
|
||||
on_profile = "/profile/" in curl
|
||||
on_project_detail = "/projects/" in curl
|
||||
on_projects_list = bool(re.search(r"/projects(\?|$)", curl))
|
||||
on_gist_detail = "/gists/" in curl
|
||||
on_gists_list = bool(re.search(r"/gists(\?|$)", curl))
|
||||
on_news_detail = "/news/" in curl
|
||||
on_news_list = bool(re.search(r"/news(\?|$)", curl))
|
||||
on_notifications = "/notifications" in curl
|
||||
|
||||
return actions, did_post, projs, follows, gists
|
||||
|
||||
async def run_forever(self, action_limit: int = 0) -> None:
|
||||
b = self.b
|
||||
self._save()
|
||||
lifetime_actions = 0
|
||||
crash_streak = 0
|
||||
session_number = 0
|
||||
sessions_since_project = 99
|
||||
sessions_since_post = 99
|
||||
sessions_since_message = 99
|
||||
|
||||
self._bind_monitor()
|
||||
|
||||
if self._startup_jitter:
|
||||
delay = random.uniform(0, self._startup_jitter)
|
||||
self.b.note(status=f"warming up, first session in {delay / 60:.0f}m")
|
||||
self._log(f"Startup jitter: first session in {delay / 60:.1f}m")
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
except asyncio.CancelledError:
|
||||
self._save()
|
||||
raise
|
||||
|
||||
while True:
|
||||
session_number += 1
|
||||
mood, session_len, break_range = await self._session_type()
|
||||
self._session_start_cost = self.llm.total_cost
|
||||
self._session_start_calls = self.llm.total_calls
|
||||
self._session_banner(mood, session_len)
|
||||
self._session_history = []
|
||||
self._session_energy = 0.5
|
||||
self._session_stop_after = 0
|
||||
self._session_comments = 0
|
||||
self._session_comment_cap = random.randint(
|
||||
SESSION_COMMENT_CAP_MIN, SESSION_COMMENT_CAP_MAX
|
||||
)
|
||||
self._session_mention_replies = 0
|
||||
use_ai = False
|
||||
|
||||
try:
|
||||
await b.launch()
|
||||
except Exception as e:
|
||||
self._log(f"Launch failed: {e}")
|
||||
crash_streak += 1
|
||||
wait = min(30 * crash_streak, 300)
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
|
||||
session_ok = False
|
||||
try:
|
||||
if not await self._ensure_auth():
|
||||
self._log("Auth failed")
|
||||
await b.close()
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
if not await self._adopt_account_api_key():
|
||||
self._log("No own API key yet; ending session to avoid shared-key billing")
|
||||
await b.close()
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
self._bind_monitor()
|
||||
|
||||
if self._ai_decisions:
|
||||
await self._ensure_identity()
|
||||
use_ai = self._ai_decisions and bool(self.state.identity)
|
||||
|
||||
await self._warm_cache()
|
||||
self._log(f"Session active ({mood}) as {self._identity()}")
|
||||
|
||||
if not self.state.profile_filled:
|
||||
await self._update_profile()
|
||||
await b.goto(f"{self.base_url}/feed")
|
||||
await b._idle(0.8, 2.0)
|
||||
|
||||
unread = await self._unread_notification_count()
|
||||
if unread:
|
||||
self._log(f"Unread notifications badge: {unread}")
|
||||
check_prob = NOTIFICATION_CHECK_PROB.get(mood, 1.0)
|
||||
want_notifications = mood in ("normal", "deep") or (
|
||||
unread > 0 and random.random() < check_prob
|
||||
)
|
||||
|
||||
if mood in ("normal", "deep"):
|
||||
await self._check_messages()
|
||||
await b._idle(0.5, 1.5)
|
||||
if want_notifications:
|
||||
await self._check_notifications()
|
||||
await b._idle(1.0, 3.0)
|
||||
if mood in ("normal", "deep"):
|
||||
await self._engage_community()
|
||||
await b._idle(0.8, 2.0)
|
||||
await b.goto(f"{self.base_url}/feed")
|
||||
|
||||
session_actions = 0
|
||||
max_actions = session_len + random.randint(0, 5)
|
||||
session_posted = False
|
||||
session_projects = 0
|
||||
session_follows = 0
|
||||
session_gists = 0
|
||||
session_messages_start = self.state.messages_sent
|
||||
sessions_since_project += 1
|
||||
sessions_since_post += 1
|
||||
sessions_since_message += 1
|
||||
can_create_project = (
|
||||
sessions_since_project >= 3 and random.random() < 0.3
|
||||
)
|
||||
can_create_post = sessions_since_post >= 2 and random.random() < 0.5
|
||||
can_create_message = (
|
||||
sessions_since_message >= 2 and random.random() < 0.4
|
||||
)
|
||||
|
||||
while session_actions < max_actions:
|
||||
if action_limit and lifetime_actions >= action_limit:
|
||||
self._log(f"Hit max actions ({action_limit}), stopping")
|
||||
self._save()
|
||||
self._cost_summary("final")
|
||||
return
|
||||
(
|
||||
taken,
|
||||
session_posted,
|
||||
session_projects,
|
||||
session_follows,
|
||||
session_gists,
|
||||
) = await (self._cycle_ai if use_ai else self._cycle)(
|
||||
mood=mood,
|
||||
session_posted=session_posted,
|
||||
session_projects=session_projects,
|
||||
session_follows=session_follows,
|
||||
session_gists=session_gists,
|
||||
can_project=can_create_project,
|
||||
can_post=can_create_post,
|
||||
can_message=can_create_message,
|
||||
)
|
||||
if session_posted:
|
||||
sessions_since_post = 0
|
||||
if session_projects:
|
||||
sessions_since_project = 0
|
||||
if self.state.messages_sent > session_messages_start:
|
||||
sessions_since_message = 0
|
||||
session_actions += max(taken, 1)
|
||||
lifetime_actions += max(taken, 1)
|
||||
self._save()
|
||||
crash_streak = 0
|
||||
|
||||
if use_ai:
|
||||
if (
|
||||
self._session_stop_after
|
||||
and session_actions >= self._session_stop_after
|
||||
):
|
||||
self._log(
|
||||
f"Energy spent (stop_after={self._session_stop_after}): ending session"
|
||||
)
|
||||
break
|
||||
intra_pause = self._scaled_pause()
|
||||
else:
|
||||
fatigue = await self._fatigue_check(session_actions)
|
||||
if fatigue:
|
||||
self._log(f"Fatigue ({fatigue}): ending session")
|
||||
break
|
||||
intra_pause = random.randint(self._pause_min, self._pause_max)
|
||||
|
||||
self._log(
|
||||
f"Session pause {intra_pause}s [{session_actions}/{max_actions}] {self._cost_tag()}"
|
||||
)
|
||||
await asyncio.sleep(intra_pause)
|
||||
|
||||
session_ok = session_actions > 0
|
||||
self._session_summary(mood, session_actions)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
self._save()
|
||||
try:
|
||||
await b.close()
|
||||
except Exception as e:
|
||||
logger.debug("close on cancel failed: %s", e)
|
||||
raise
|
||||
except Exception as e:
|
||||
crash_streak += 1
|
||||
self._log(f"Session crash ({crash_streak}): {e}")
|
||||
self._save()
|
||||
finally:
|
||||
try:
|
||||
await b.close()
|
||||
except Exception as e:
|
||||
logger.debug("close in finally failed: %s", e)
|
||||
|
||||
if crash_streak > 3:
|
||||
self._log("Too many consecutive crashes, exiting")
|
||||
self._save()
|
||||
self._cost_summary("final")
|
||||
break
|
||||
|
||||
if not session_ok and crash_streak > 0:
|
||||
break_s = random.randint(30, 120)
|
||||
self._log(f"Recovery wait {break_s}s")
|
||||
else:
|
||||
break_s = max(5, int(random.randint(*break_range) * self._break_scale))
|
||||
if use_ai:
|
||||
factor = 1.5 - 1.0 * self._session_energy
|
||||
break_s = max(60, int(break_s * factor))
|
||||
break_h = break_s / 3600
|
||||
self._log(f"Session done. Break {break_h:.1f}h")
|
||||
|
||||
self._save()
|
||||
try:
|
||||
await asyncio.sleep(break_s)
|
||||
except asyncio.CancelledError:
|
||||
self._save()
|
||||
raise
|
||||
|
||||
self._cost_summary("final")
|
||||
self._log("Bot process exiting")
|
||||
345
devplacepy/services/bot/posting.py
Normal file
345
devplacepy/services/bot/posting.py
Normal file
@ -0,0 +1,345 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import random
|
||||
|
||||
from devplacepy.services.bot.config import pick_category
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotPostingMixin:
|
||||
async def _create_gist(self) -> bool:
|
||||
b = self.b
|
||||
await b.goto(f"{self.base_url}/gists")
|
||||
if not await b.click("#create-gist-btn, .gists-fab, .feed-fab"):
|
||||
self._log("Create gist: trigger not found")
|
||||
return False
|
||||
await b._idle(0.8, 1.5)
|
||||
if await b.page.locator("#create-gist-modal").count() == 0:
|
||||
self._log("Create gist: modal not found")
|
||||
return False
|
||||
|
||||
generated = None
|
||||
for attempt in range(2):
|
||||
candidate = await self._generate(self.llm.generate_gist, self.state.persona)
|
||||
if not candidate:
|
||||
continue
|
||||
cand_title, cand_desc, cand_lang, cand_code = candidate
|
||||
if not cand_code.strip():
|
||||
continue
|
||||
verdict = await self._generate(
|
||||
self.llm.gist_quality_check, cand_title, cand_code, cand_lang
|
||||
)
|
||||
if verdict is None or verdict[0]:
|
||||
generated = candidate
|
||||
break
|
||||
self.state.quality_rejections += 1
|
||||
action = "regenerating" if attempt == 0 else "skipping"
|
||||
self._log(f"Gist quality reject ({verdict[1]}), {action}")
|
||||
if not generated:
|
||||
self._log("Create gist: no quality candidate")
|
||||
return False
|
||||
title, desc, language, code = generated
|
||||
|
||||
title = self.llm.strip_md(title)[:200] or title[:200]
|
||||
if not await b.fill("#gist-title", title):
|
||||
self._log("Create gist: title field not found")
|
||||
return False
|
||||
await b._idle(0.2, 0.5)
|
||||
await b.fill("#gist-description", desc[:500])
|
||||
await b._idle(0.2, 0.5)
|
||||
try:
|
||||
await b.page.select_option("#gist-language", language)
|
||||
except Exception as e:
|
||||
logger.debug("gist language select failed: %s", e)
|
||||
await b._idle(0.3, 0.7)
|
||||
|
||||
cm = b.page.locator("#create-gist-modal .CodeMirror")
|
||||
if await cm.count() == 0:
|
||||
self._log("Create gist: code editor not found")
|
||||
return False
|
||||
try:
|
||||
await cm.first.click()
|
||||
await b._idle(0.3, 0.6)
|
||||
await b.page.evaluate(
|
||||
"""(code) => {
|
||||
const el = document.querySelector('#create-gist-modal .CodeMirror');
|
||||
if (el && el.CodeMirror) { el.CodeMirror.setValue(code); el.CodeMirror.save(); }
|
||||
const ta = document.querySelector('#gist-source-editor');
|
||||
if (ta) { ta.value = code; ta.dispatchEvent(new Event('input', {bubbles: true})); }
|
||||
}""",
|
||||
code,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("gist code fill failed: %s", e)
|
||||
return False
|
||||
await b._idle(0.5, 1.2)
|
||||
|
||||
ok = await b.click(
|
||||
"#create-gist-modal button[type='submit'], #create-gist-modal .btn-primary"
|
||||
)
|
||||
if not ok:
|
||||
self._log("Create gist: submit not found")
|
||||
return False
|
||||
await asyncio.sleep(2)
|
||||
url = await b.url()
|
||||
if "/gists/" in url:
|
||||
if url not in self.state.own_post_urls:
|
||||
self.state.own_post_urls.append(url)
|
||||
self.state.gists_created += 1
|
||||
self._action(
|
||||
"GIST",
|
||||
f"{title[:40]} [{language}] [{self.state.gists_created}] -> {url[-50:]}",
|
||||
)
|
||||
return True
|
||||
self._log("Create gist: no redirect to gist detail")
|
||||
return False
|
||||
|
||||
async def _create_post(self) -> bool:
|
||||
b = self.b
|
||||
await b.goto(f"{self.base_url}/feed")
|
||||
|
||||
fab_sel = "#create-post-btn, .feed-fab, button.feed-fab, a.feed-fab"
|
||||
ok = await b.click(fab_sel)
|
||||
if not ok:
|
||||
self._log("Create post: FAB button not found")
|
||||
return False
|
||||
self._log("Create post: FAB clicked, waiting for modal")
|
||||
await b._idle(1.0, 2.0)
|
||||
|
||||
modal = await b.page.locator("#create-post-modal").count()
|
||||
if modal == 0:
|
||||
self._log("Create post: modal #create-post-modal not in DOM")
|
||||
return False
|
||||
hidden = await b.page.locator("#create-post-modal").get_attribute("class") or ""
|
||||
if "hidden" in hidden or await b.page.locator("#create-post-modal").is_hidden():
|
||||
self._log("Create post: modal is hidden")
|
||||
return False
|
||||
self._log("Create post: modal visible")
|
||||
|
||||
article_title = ""
|
||||
article_desc = ""
|
||||
post_title = ""
|
||||
|
||||
if self._post_cache:
|
||||
article_title, post_title, article_desc, content, topic = (
|
||||
self._post_cache.pop(0)
|
||||
)
|
||||
asyncio.create_task(self._refill_cache_async("post"))
|
||||
else:
|
||||
articles = self.news.fetch()
|
||||
topic = pick_category(self.state.persona)
|
||||
article = self.registry.reserve_unused(
|
||||
articles,
|
||||
self.state.username,
|
||||
self.llm.clean,
|
||||
topic,
|
||||
self.state.persona,
|
||||
)
|
||||
if not article:
|
||||
self._log("Create post: no unused articles available")
|
||||
return False
|
||||
desc = article.get("description", "")[:500]
|
||||
post_title = await self._generate(
|
||||
self.llm.generate_post_title,
|
||||
article["title"],
|
||||
self.state.persona,
|
||||
topic,
|
||||
)
|
||||
content = await self._generate(
|
||||
self.llm.generate_post,
|
||||
article["title"],
|
||||
desc,
|
||||
self.state.persona,
|
||||
topic,
|
||||
self.state.recent_post_titles,
|
||||
)
|
||||
if not content:
|
||||
self._log("Create post: generation failed/empty")
|
||||
return False
|
||||
article_title = article["title"]
|
||||
article_desc = desc
|
||||
|
||||
verdict = await self._generate(
|
||||
self.llm.quality_check, "post", content, article_desc
|
||||
)
|
||||
if verdict is not None and not verdict[0]:
|
||||
self.state.quality_rejections += 1
|
||||
self._log(f"Post quality reject ({verdict[1]}), regenerating")
|
||||
content = await self._generate(
|
||||
self.llm.generate_post,
|
||||
article_title,
|
||||
article_desc,
|
||||
self.state.persona,
|
||||
topic,
|
||||
self.state.recent_post_titles,
|
||||
)
|
||||
if not content:
|
||||
self._log("Create post: regeneration failed/empty")
|
||||
return False
|
||||
verdict = await self._generate(
|
||||
self.llm.quality_check, "post", content, article_desc
|
||||
)
|
||||
if verdict is not None and not verdict[0]:
|
||||
self.state.quality_rejections += 1
|
||||
self._log(f"Post quality reject ({verdict[1]}), skipping")
|
||||
return False
|
||||
|
||||
content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
|
||||
if content_hash in self.state.posted_content_hashes:
|
||||
self._log(f"Create post: duplicate content {content_hash}, skipping")
|
||||
return False
|
||||
|
||||
clean_title = self.llm.clean(article_title[:200]) if article_title else ""
|
||||
if clean_title:
|
||||
if not self.registry.reserve(clean_title, self.state.username, topic):
|
||||
self._log(
|
||||
f"Create post: article '{clean_title[:50]}...' already covered, skipping"
|
||||
)
|
||||
return False
|
||||
|
||||
type_title = self.llm.strip_md(post_title)[:120] if post_title else ""
|
||||
if not type_title:
|
||||
type_title = clean_title
|
||||
|
||||
ts = f"#create-post-modal input[name='topic'][value='{topic}']"
|
||||
if await b.page.locator(ts).count() == 0:
|
||||
self._log(f"Create post: topic radio '{topic}' not found")
|
||||
else:
|
||||
await b.click(ts)
|
||||
await b._idle(0.2, 0.5)
|
||||
|
||||
if type_title:
|
||||
ok = await b.fill("#create-post-modal input[name='title']", type_title)
|
||||
if not ok:
|
||||
self._log("Create post: title input not found")
|
||||
await b._idle(0.2, 0.4)
|
||||
|
||||
content_sel = "#create-post-modal textarea[name='content']"
|
||||
if await b.page.locator(content_sel).count() == 0:
|
||||
self._log("Create post: content textarea not found")
|
||||
return False
|
||||
if not await self._type_like_human(content_sel, content[:5000]):
|
||||
self._log("Create post: typing content into modal failed")
|
||||
return False
|
||||
await b._idle(0.5, 1.5)
|
||||
|
||||
submit_sels = [
|
||||
"#create-post-modal button[type='submit']",
|
||||
"#create-post-modal .btn-primary",
|
||||
".modal-overlay#create-post-modal button[type='submit']",
|
||||
"#create-post-modal button:has-text('Post')",
|
||||
]
|
||||
ok = False
|
||||
for sel in submit_sels:
|
||||
if await b.click(sel):
|
||||
ok = True
|
||||
break
|
||||
if not ok:
|
||||
self._log("Create post: submit button not found")
|
||||
return False
|
||||
|
||||
post_url = await b.url()
|
||||
for _ in range(12):
|
||||
if "/posts/" in post_url:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
post_url = await b.url()
|
||||
if "/posts/" not in post_url:
|
||||
self._log(f"Create post: submit did not create a post (url={post_url[-45:]})")
|
||||
return False
|
||||
self.state.created_posts += 1
|
||||
self.state.posted_content_hashes.append(content_hash)
|
||||
if type_title:
|
||||
self.state.recent_post_titles.append(type_title)
|
||||
self.state.recent_post_titles = self.state.recent_post_titles[-8:]
|
||||
if post_url not in self.state.own_post_urls:
|
||||
self.state.own_post_urls.append(post_url)
|
||||
if post_url not in self.state.known_posts:
|
||||
self.state.known_posts.append(post_url)
|
||||
self._action(
|
||||
"POST",
|
||||
f"{topic} [{self.state.created_posts}]: {type_title[:50] or content[:50]} -> {post_url[-45:]}",
|
||||
)
|
||||
return True
|
||||
|
||||
async def _click_post_link(self) -> bool:
|
||||
b = self.b
|
||||
links = b.page.locator("a[href*='/posts/']")
|
||||
count = await links.count()
|
||||
if count == 0:
|
||||
return False
|
||||
|
||||
candidates = []
|
||||
for i in range(count):
|
||||
h = await links.nth(i).get_attribute("href")
|
||||
if h and "/posts/" in h and len(h) > 20:
|
||||
candidates.append(i)
|
||||
|
||||
if not candidates:
|
||||
return False
|
||||
|
||||
idx = random.choice(candidates)
|
||||
href = await links.nth(idx).get_attribute("href")
|
||||
try:
|
||||
await links.nth(idx).click(timeout=5000)
|
||||
await b._idle(1.0, 2.0)
|
||||
if href and href not in self.state.known_posts:
|
||||
self.state.known_posts.append(href)
|
||||
self._action("OPEN", f"post {href[-45:] if href else '?'}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("_click_post_link failed: %s", e)
|
||||
return False
|
||||
|
||||
async def _click_gist_link(self) -> bool:
|
||||
b = self.b
|
||||
links = b.page.locator("a[href*='/gists/']")
|
||||
count = await links.count()
|
||||
if count == 0:
|
||||
return False
|
||||
candidates = []
|
||||
for i in range(count):
|
||||
h = await links.nth(i).get_attribute("href")
|
||||
if h and "/gists/" in h and "?" not in h and h.rstrip("/") != "/gists":
|
||||
candidates.append(i)
|
||||
if not candidates:
|
||||
return False
|
||||
idx = random.choice(candidates)
|
||||
href = await links.nth(idx).get_attribute("href")
|
||||
try:
|
||||
await links.nth(idx).click(timeout=5000)
|
||||
await b._idle(1.0, 2.0)
|
||||
self._action("OPEN", f"gist {href[-45:] if href else '?'}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("_click_gist_link failed: %s", e)
|
||||
return False
|
||||
|
||||
async def _open_content(self, kind: str) -> bool:
|
||||
b = self.b
|
||||
base = "/news/" if kind == "news" else f"/{kind}s/"
|
||||
links = b.page.locator(f"a[href*='{base}']")
|
||||
count = await links.count()
|
||||
if count == 0:
|
||||
return False
|
||||
candidates = []
|
||||
for i in range(count):
|
||||
h = await links.nth(i).get_attribute("href")
|
||||
if h and base in h and "?" not in h and h.rstrip("/") != base.rstrip("/"):
|
||||
candidates.append(i)
|
||||
if not candidates:
|
||||
return False
|
||||
idx = random.choice(candidates)
|
||||
href = await links.nth(idx).get_attribute("href")
|
||||
try:
|
||||
await links.nth(idx).click(timeout=5000)
|
||||
await b._idle(1.0, 2.0)
|
||||
self._action("OPEN", f"{kind} {href[-45:] if href else '?'}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("_open_content(%s) failed: %s", kind, e)
|
||||
return False
|
||||
624
devplacepy/services/bot/social.py
Normal file
624
devplacepy/services/bot/social.py
Normal file
@ -0,0 +1,624 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import random
|
||||
|
||||
from devplacepy.services.bot.config import (
|
||||
MAX_THREAD_REPLIES,
|
||||
MENTION_REPLIES_PER_SESSION,
|
||||
PROJECT_STATUSES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotSocialMixin:
|
||||
async def _click_profile_link(self) -> bool:
|
||||
b = self.b
|
||||
links = b.page.locator("a[href*='/profile/']")
|
||||
count = await links.count()
|
||||
if count == 0:
|
||||
return False
|
||||
|
||||
for i in range(count):
|
||||
h = await links.nth(i).get_attribute("href")
|
||||
if h and self.state.username not in h:
|
||||
try:
|
||||
await links.nth(i).click(timeout=5000)
|
||||
await b._idle(1.0, 2.0)
|
||||
uname = h.split("/profile/")[-1].split("?")[0]
|
||||
if uname not in self.state.known_users:
|
||||
self.state.known_users.append(uname)
|
||||
self.state.profiles_viewed += 1
|
||||
self._action(
|
||||
"PROFILE", f"viewed @{uname} [{self.state.profiles_viewed}]"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("_click_profile_link failed: %s", e)
|
||||
continue
|
||||
return False
|
||||
|
||||
async def _click_create_project(self) -> bool:
|
||||
b = self.b
|
||||
await b.goto(f"{self.base_url}/projects")
|
||||
ok = await b.click("#create-project-btn")
|
||||
if not ok:
|
||||
return False
|
||||
await b._idle(0.8, 1.5)
|
||||
|
||||
if not await b.text("#create-project-modal"):
|
||||
return False
|
||||
|
||||
if self._project_cache:
|
||||
raw_title, desc = self._project_cache.pop(0)
|
||||
asyncio.create_task(self._refill_cache_async("project"))
|
||||
else:
|
||||
raw_title = await self._generate(
|
||||
self.llm.generate_project_title, self.state.persona
|
||||
)
|
||||
if not raw_title:
|
||||
self._log("Create project: title generation failed")
|
||||
return False
|
||||
raw_title = raw_title[:200]
|
||||
desc = await self._generate(
|
||||
self.llm.generate_project_desc, raw_title, self.state.persona
|
||||
)
|
||||
if not desc:
|
||||
self._log("Create project: description generation failed")
|
||||
return False
|
||||
desc = desc[:5000]
|
||||
|
||||
title = self.llm.strip_md(raw_title)[:120] or raw_title[:120]
|
||||
types = ["game", "software", "mobile_app", "website", "game_asset"]
|
||||
ptype = random.choice(types)
|
||||
status = random.choice(PROJECT_STATUSES)
|
||||
|
||||
await b.fill("#title", title)
|
||||
await b._idle(0.2, 0.5)
|
||||
await b.fill("#description", desc)
|
||||
await b._idle(0.2, 0.5)
|
||||
|
||||
await b.click(f"input[name='project_type'][value='{ptype}']")
|
||||
await b._idle(0.2, 0.5)
|
||||
|
||||
await b.click(f"input[name='status'][value='{status}']")
|
||||
await b._idle(0.2, 0.5)
|
||||
|
||||
ok = await b.click("#create-project-modal .btn-primary")
|
||||
if ok:
|
||||
self.state.projects_created += 1
|
||||
self._action(
|
||||
"PROJECT", f"{title} ({ptype}) [{self.state.projects_created}]"
|
||||
)
|
||||
await asyncio.sleep(2)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _report_issue(self) -> bool:
|
||||
b = self.b
|
||||
await b.goto(f"{self.base_url}/issues")
|
||||
ok = await b.click("[data-modal='create-issue-modal']")
|
||||
if not ok:
|
||||
return False
|
||||
await b._idle(0.5, 1.5)
|
||||
|
||||
if not await b.text("#create-issue-modal"):
|
||||
return False
|
||||
|
||||
topic = random.choice(
|
||||
[
|
||||
"UI glitch",
|
||||
"Broken link",
|
||||
"Performance issue",
|
||||
"Auth problem",
|
||||
"Formatting error",
|
||||
]
|
||||
)
|
||||
if self._issue_cache:
|
||||
title, desc = self._issue_cache.pop(0)
|
||||
asyncio.create_task(self._refill_cache_async("issue"))
|
||||
else:
|
||||
generated = await self._generate(self.llm.generate_issue, topic)
|
||||
if not generated:
|
||||
self._log("Report issue: generation failed")
|
||||
return False
|
||||
title, desc = generated
|
||||
|
||||
await b.fill("#issue-title", title[:200])
|
||||
await b._idle(0.2, 0.5)
|
||||
await b.fill("textarea[name='description']", desc[:2000])
|
||||
await b._idle(0.5, 1.0)
|
||||
|
||||
ok = await b.click(
|
||||
"button:has-text('Submit Report'), #create-issue-modal .btn-primary"
|
||||
)
|
||||
if ok:
|
||||
self.state.issues_filed += 1
|
||||
self._action("ISSUE", f"{title[:50]} [{self.state.issues_filed}]")
|
||||
await asyncio.sleep(2)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _update_profile(self) -> bool:
|
||||
b = self.b
|
||||
await b.goto(f"{self.base_url}/profile/{self.state.username}")
|
||||
|
||||
ok = await b.click("[data-edit-field='bio']")
|
||||
if not ok:
|
||||
return False
|
||||
await b._idle(0.3, 0.8)
|
||||
|
||||
bio = await self._generate(self.llm.generate_bio)
|
||||
if not bio:
|
||||
self._log("Update profile: bio generation failed")
|
||||
return False
|
||||
bio = bio[:500]
|
||||
await b.fill("textarea[name='bio']", bio)
|
||||
await b._idle(0.3, 0.8)
|
||||
|
||||
filled = ["bio"]
|
||||
fields = await self._generate(
|
||||
self.llm.generate_profile_fields, self.state.username
|
||||
)
|
||||
if fields:
|
||||
location, git_link, website = fields
|
||||
for field_name, value in (
|
||||
("location", location),
|
||||
("git_link", git_link),
|
||||
("website", website),
|
||||
):
|
||||
if not value:
|
||||
continue
|
||||
await b.click(f"[data-edit-field='{field_name}']")
|
||||
await b._idle(0.2, 0.5)
|
||||
if await b.fill(
|
||||
f"input[name='{field_name}'], textarea[name='{field_name}']", value
|
||||
):
|
||||
filled.append(field_name)
|
||||
await b._idle(0.2, 0.5)
|
||||
|
||||
ok = await b.click(
|
||||
"form[action*='/profile/update'] button[type='submit'], button[type='submit']"
|
||||
)
|
||||
if ok:
|
||||
self.state.profile_filled = True
|
||||
self._save()
|
||||
self._action("PROFILE", f"updated own profile ({', '.join(filled)})")
|
||||
await asyncio.sleep(2)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _follow_user(self) -> bool:
|
||||
b = self.b
|
||||
curl = await b.url()
|
||||
if "/profile/" not in curl:
|
||||
return False
|
||||
uname = curl.split("/profile/")[-1].split("?")[0]
|
||||
if uname == self.state.username:
|
||||
return False
|
||||
form = b.page.locator("form[action*='/follow/'] button[type='submit']")
|
||||
if await form.count() == 0:
|
||||
return False
|
||||
text = (await form.first.inner_text()).strip()
|
||||
if text.lower() not in ("follow", "unfollow"):
|
||||
return False
|
||||
try:
|
||||
await form.first.click(timeout=3000)
|
||||
self._action(
|
||||
"FOLLOW",
|
||||
f"{'followed' if text.lower() == 'follow' else 'unfollowed'} @{uname}",
|
||||
)
|
||||
await b._idle(1.0, 2.0)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("_follow_user failed: %s", e)
|
||||
return False
|
||||
|
||||
def _classify_notification(self, text: str) -> bool:
|
||||
tl = text.lower()
|
||||
return (
|
||||
f"@{self.state.username.lower()}" in tl
|
||||
or "mentioned you" in tl
|
||||
or "replied to your" in tl
|
||||
or "replied to you" in tl
|
||||
or "tagged you" in tl
|
||||
)
|
||||
|
||||
async def _collect_notifications(self) -> list[dict]:
|
||||
b = self.b
|
||||
cards = b.page.locator(".notification-card")
|
||||
try:
|
||||
count = await cards.count()
|
||||
except Exception:
|
||||
count = 0
|
||||
if count == 0:
|
||||
return await self._collect_notifications_fallback()
|
||||
items: list[dict] = []
|
||||
seen_keys: set = set()
|
||||
for i in range(min(count, 30)):
|
||||
card = cards.nth(i)
|
||||
text = ""
|
||||
try:
|
||||
body = card.locator(".notification-text").first
|
||||
if await body.count() > 0:
|
||||
text = (await body.inner_text() or "")[:600]
|
||||
except Exception:
|
||||
text = ""
|
||||
if not text:
|
||||
try:
|
||||
text = (await card.inner_text() or "")[:600]
|
||||
except Exception:
|
||||
text = ""
|
||||
href = ""
|
||||
try:
|
||||
link = card.locator("a.card-link").first
|
||||
if await link.count() > 0:
|
||||
href = (await link.get_attribute("href")) or ""
|
||||
except Exception:
|
||||
href = ""
|
||||
unread = False
|
||||
try:
|
||||
cls = (await card.get_attribute("class")) or ""
|
||||
unread = "unread" in cls.split()
|
||||
except Exception:
|
||||
unread = False
|
||||
if not text and not href:
|
||||
continue
|
||||
key = hashlib.sha256(f"{href}|{text[:200]}".encode()).hexdigest()[:24]
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
items.append(
|
||||
{
|
||||
"key": key,
|
||||
"href": href,
|
||||
"text": text,
|
||||
"is_mention": self._classify_notification(text),
|
||||
"unread": unread,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
async def _collect_notifications_fallback(self) -> list[dict]:
|
||||
b = self.b
|
||||
loc = b.page.locator(".notification-item, .notif-item, [data-notification-id]")
|
||||
try:
|
||||
count = await loc.count()
|
||||
except Exception:
|
||||
count = 0
|
||||
items: list[dict] = []
|
||||
seen_keys: set = set()
|
||||
for i in range(min(count, 30)):
|
||||
el = loc.nth(i)
|
||||
try:
|
||||
text = (await el.inner_text() or "")[:600]
|
||||
except Exception:
|
||||
text = ""
|
||||
href = ""
|
||||
try:
|
||||
inner = el.locator("a[href]").first
|
||||
if await inner.count() > 0:
|
||||
href = (await inner.get_attribute("href")) or ""
|
||||
except Exception:
|
||||
href = ""
|
||||
if not text and not href:
|
||||
continue
|
||||
key = hashlib.sha256(f"{href}|{text[:200]}".encode()).hexdigest()[:24]
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
items.append(
|
||||
{
|
||||
"key": key,
|
||||
"href": href,
|
||||
"text": text,
|
||||
"is_mention": self._classify_notification(text),
|
||||
"unread": True,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
async def _check_notifications(self) -> bool:
|
||||
b = self.b
|
||||
await b.goto(f"{self.base_url}/notifications")
|
||||
await b._idle(0.8, 1.8)
|
||||
notifications = await self._collect_notifications()
|
||||
if not notifications:
|
||||
self._log("Notifications: none")
|
||||
return True
|
||||
unseen_mentions = [
|
||||
n
|
||||
for n in notifications
|
||||
if n["is_mention"]
|
||||
and n["unread"]
|
||||
and n["key"] not in self.state.notifications_seen
|
||||
]
|
||||
self._log(
|
||||
f"Notifications: {len(notifications)} ({len(unseen_mentions)} unseen mentions)"
|
||||
)
|
||||
self.state.mentions_received += len(unseen_mentions)
|
||||
replied = 0
|
||||
for n in unseen_mentions:
|
||||
if self._session_mention_replies >= MENTION_REPLIES_PER_SESSION:
|
||||
self._log(
|
||||
f"Mention reply cap reached ({MENTION_REPLIES_PER_SESSION}/session), leaving the rest"
|
||||
)
|
||||
break
|
||||
ok = await self._respond_to_mention(n["href"], n["text"])
|
||||
if ok:
|
||||
self.state.mentions_replied += 1
|
||||
self._session_mention_replies += 1
|
||||
replied += 1
|
||||
self.state.notifications_seen.append(n["key"])
|
||||
if len(self.state.notifications_seen) > 500:
|
||||
self.state.notifications_seen = self.state.notifications_seen[-250:]
|
||||
await b._idle(2.0, 5.0)
|
||||
if replied == 0:
|
||||
remaining = [
|
||||
n
|
||||
for n in notifications
|
||||
if n["key"] not in self.state.notifications_seen
|
||||
]
|
||||
if remaining:
|
||||
pick = random.choice(remaining)
|
||||
target = pick["href"]
|
||||
if target and not target.startswith("http"):
|
||||
target = self.base_url + (
|
||||
target if target.startswith("/") else "/" + target
|
||||
)
|
||||
if target:
|
||||
try:
|
||||
await b.goto(target)
|
||||
self._log(f"Notifications: opened {target[-60:]}")
|
||||
except Exception as e:
|
||||
logger.debug("notification goto failed: %s", e)
|
||||
self.state.notifications_seen.append(pick["key"])
|
||||
try:
|
||||
mark = b.page.locator(
|
||||
"button:has-text('Mark all read'), form[action*='/mark-all-read'] button"
|
||||
)
|
||||
if await mark.count() > 0:
|
||||
await mark.first.click(timeout=3000)
|
||||
await b._idle(0.5, 1.5)
|
||||
except Exception as e:
|
||||
logger.debug("mark-read failed: %s", e)
|
||||
return True
|
||||
|
||||
async def _respond_to_mention(self, href: str, snippet: str) -> bool:
|
||||
b = self.b
|
||||
target = href
|
||||
if target and not target.startswith("http"):
|
||||
target = self.base_url + (href if href.startswith("/") else "/" + href)
|
||||
if not target:
|
||||
return False
|
||||
try:
|
||||
await b.goto(target)
|
||||
except Exception as e:
|
||||
logger.debug("mention goto failed: %s", e)
|
||||
return False
|
||||
await b._idle(2.0, 4.0)
|
||||
curl = await b.url()
|
||||
if not any(k in curl for k in ("/posts/", "/gists/", "/projects/", "/news/")):
|
||||
self._log("Mention target has no comment thread, skipping")
|
||||
return False
|
||||
thread_id = self._thread_id(curl)
|
||||
if (
|
||||
thread_id
|
||||
and self.state.thread_reply_counts.get(thread_id, 0) >= MAX_THREAD_REPLIES
|
||||
):
|
||||
self._log(
|
||||
f"Conversation taper on {thread_id[:12]} ({MAX_THREAD_REPLIES} replies), letting it rest"
|
||||
)
|
||||
return False
|
||||
needle = f"@{self.state.username}"
|
||||
comment_loc = None
|
||||
comment_selectors = [
|
||||
f".comment:has-text('{needle}')",
|
||||
f"li.comment:has-text('{needle}')",
|
||||
f".comment-item:has-text('{needle}')",
|
||||
f"article.comment:has-text('{needle}')",
|
||||
]
|
||||
for sel in comment_selectors:
|
||||
try:
|
||||
loc = b.page.locator(sel).first
|
||||
if await loc.count() > 0:
|
||||
comment_loc = loc
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
parent_text = ""
|
||||
mentioner = ""
|
||||
if comment_loc is not None:
|
||||
try:
|
||||
parent_text = (await comment_loc.inner_text() or "")[:1000]
|
||||
except Exception:
|
||||
parent_text = ""
|
||||
try:
|
||||
author_link = comment_loc.locator("a[href*='/profile/']").first
|
||||
if await author_link.count() > 0:
|
||||
ah = await author_link.get_attribute("href") or ""
|
||||
if "/profile/" in ah:
|
||||
mentioner = (
|
||||
ah.split("/profile/")[-1].split("?")[0].split("/")[0]
|
||||
)
|
||||
except Exception:
|
||||
mentioner = ""
|
||||
try:
|
||||
await comment_loc.scroll_into_view_if_needed(timeout=2000)
|
||||
await b._idle(0.3, 0.9)
|
||||
except Exception as e:
|
||||
logger.debug("scroll mention failed: %s", e)
|
||||
try:
|
||||
reply_btn = comment_loc.locator(
|
||||
".comment-action-btn, button:has-text('Reply'), a:has-text('Reply')"
|
||||
).first
|
||||
if await reply_btn.count() > 0:
|
||||
await reply_btn.click(timeout=3000)
|
||||
await b._idle(0.5, 1.5)
|
||||
except Exception as e:
|
||||
logger.debug("reply-btn click failed: %s", e)
|
||||
try:
|
||||
post_body = (
|
||||
await b.text(".post-content")
|
||||
or await b.text(".gist-detail-desc")
|
||||
or await b.text(".news-detail-content")
|
||||
or await b.text(".news-detail-desc")
|
||||
or await b.text(".project-detail-desc")
|
||||
or await b.text(".rendered-content")
|
||||
or await b.text("article")
|
||||
or snippet
|
||||
)
|
||||
except Exception:
|
||||
post_body = snippet
|
||||
reply = await self._generate(
|
||||
self.llm.generate_comment,
|
||||
post_body[:1500],
|
||||
self.state.persona,
|
||||
mentioner,
|
||||
parent_text,
|
||||
)
|
||||
if not reply:
|
||||
self._log("Mention reply: generation failed/empty")
|
||||
return False
|
||||
if mentioner and f"@{mentioner.lower()}" not in reply.lower():
|
||||
reply = f"@{mentioner} {reply}"
|
||||
reply = reply[:2000]
|
||||
textarea_sels = [
|
||||
".comment .reply-form textarea[name='content']",
|
||||
".reply-form textarea[name='content']",
|
||||
"form.comment-form textarea[name='content']",
|
||||
"textarea#comment-content",
|
||||
"textarea.emoji-picker-target",
|
||||
"textarea[name='content']",
|
||||
]
|
||||
typed = False
|
||||
for sel in textarea_sels:
|
||||
if await self._type_like_human(sel, reply):
|
||||
typed = True
|
||||
break
|
||||
if not typed:
|
||||
self._log("Mention reply: no textarea found")
|
||||
return False
|
||||
await b._idle(0.5, 1.5)
|
||||
submit_sels = [
|
||||
".reply-form button[type='submit']",
|
||||
"form.comment-form button[type='submit']",
|
||||
"button.btn-primary:has-text('Reply')",
|
||||
"button.btn-primary:has-text('Post')",
|
||||
"form[action*='/comments/create'] button[type='submit']",
|
||||
".comment-form button[type='submit']",
|
||||
]
|
||||
for sel in submit_sels:
|
||||
if await b.click(sel):
|
||||
self.state.comments_posted += 1
|
||||
self.state.replies_to_own_posts += 1
|
||||
if thread_id:
|
||||
self.state.thread_reply_counts[thread_id] = (
|
||||
self.state.thread_reply_counts.get(thread_id, 0) + 1
|
||||
)
|
||||
if len(self.state.thread_reply_counts) > 500:
|
||||
recent = list(self.state.thread_reply_counts.items())[-250:]
|
||||
self.state.thread_reply_counts = dict(recent)
|
||||
self._action("MENTION", f"replied to @{mentioner or '?'}: {reply[:60]}")
|
||||
await asyncio.sleep(2)
|
||||
return True
|
||||
self._log("Mention reply: submit failed")
|
||||
return False
|
||||
|
||||
async def _check_messages(self) -> bool:
|
||||
b = self.b
|
||||
await b.goto(f"{self.base_url}/messages")
|
||||
await b._idle()
|
||||
conv = b.page.locator(
|
||||
".messages-conversations a, .conversation-item, .message-item, .messages-list a"
|
||||
)
|
||||
if await conv.count() == 0:
|
||||
self._log("Messages: no conversations")
|
||||
return True
|
||||
try:
|
||||
await conv.first.click(timeout=3000)
|
||||
await b._idle(1.0, 2.0)
|
||||
except Exception as e:
|
||||
logger.debug("_check_messages open failed: %s", e)
|
||||
return True
|
||||
if random.random() < 0.7:
|
||||
return await self._compose_message(reply=True)
|
||||
self._log("Messages: read conversation")
|
||||
return True
|
||||
|
||||
async def _spoke_last_in_thread(self) -> bool:
|
||||
bubbles = self.b.page.locator(".messages-thread .message-bubble")
|
||||
count = await bubbles.count()
|
||||
if count == 0:
|
||||
return False
|
||||
try:
|
||||
classes = await bubbles.nth(count - 1).get_attribute("class") or ""
|
||||
except Exception as e:
|
||||
logger.debug("_spoke_last_in_thread read failed: %s", e)
|
||||
return False
|
||||
return "mine" in classes.split()
|
||||
|
||||
async def _compose_message(self, reply: bool = False) -> bool:
|
||||
b = self.b
|
||||
box = b.page.locator(
|
||||
"form[action*='/messages/send'] input[name='content'], "
|
||||
"input[name='content'][placeholder*='message'], "
|
||||
"form[action*='/messages/send'] textarea[name='content']"
|
||||
)
|
||||
if await box.count() == 0:
|
||||
self._log("Message: compose box not found")
|
||||
return False
|
||||
if await self._spoke_last_in_thread():
|
||||
self._log("Message: I spoke last; waiting for a reply (dialog, not monologue)")
|
||||
return False
|
||||
context = ""
|
||||
try:
|
||||
context = (
|
||||
await b.text(".messages-thread") or await b.text(".message-list") or ""
|
||||
)[:400]
|
||||
except Exception:
|
||||
context = ""
|
||||
text = await self._generate(self.llm.generate_dm, self.state.persona, context)
|
||||
if not text:
|
||||
return False
|
||||
text = text[:500]
|
||||
if not await self._type_like_human(
|
||||
"form[action*='/messages/send'] input[name='content'], input[name='content'], "
|
||||
"form[action*='/messages/send'] textarea[name='content']",
|
||||
text,
|
||||
):
|
||||
return False
|
||||
await b._idle(0.4, 1.0)
|
||||
if await b.click(
|
||||
"button.messages-send-btn, form[action*='/messages/send'] button[type='submit']"
|
||||
):
|
||||
self.state.messages_sent += 1
|
||||
self._action(
|
||||
"MESSAGE",
|
||||
f"{'replied' if reply else 'sent'} [{self.state.messages_sent}]: {text[:50]}",
|
||||
)
|
||||
await asyncio.sleep(2)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _send_message(self) -> bool:
|
||||
b = self.b
|
||||
curl = await b.url()
|
||||
if "/profile/" not in curl:
|
||||
return False
|
||||
btn = b.page.locator("a[href*='with_uid=']").first
|
||||
if await btn.count() == 0:
|
||||
return False
|
||||
href = await btn.get_attribute("href") or ""
|
||||
if "with_uid=" not in href:
|
||||
return False
|
||||
try:
|
||||
await b.goto((self.base_url + href) if href.startswith("/") else href)
|
||||
await b._idle(1.0, 2.0)
|
||||
except Exception as e:
|
||||
logger.debug("_send_message goto failed: %s", e)
|
||||
return False
|
||||
return await self._compose_message(reply=False)
|
||||
File diff suppressed because it is too large
Load Diff
52
devplacepy/services/devii/actions/catalog/__init__.py
Normal file
52
devplacepy/services/devii/actions/catalog/__init__.py
Normal file
@ -0,0 +1,52 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..spec import Action, Catalog
|
||||
from .admin import ADMIN_ACTIONS
|
||||
from .auth import AUTH_ACTIONS
|
||||
from .comments import COMMENTS_ACTIONS
|
||||
from .dbapi import DBAPI_ACTIONS
|
||||
from .engagement import ENGAGEMENT_ACTIONS
|
||||
from .game import GAME_ACTIONS
|
||||
from .gateway import GATEWAY_ACTIONS
|
||||
from .gists import GIST_ACTIONS
|
||||
from .issues import ISSUE_ACTIONS
|
||||
from .jobs import JOB_ACTIONS
|
||||
from .messages import MESSAGE_ACTIONS
|
||||
from .news import NEWS_ACTIONS
|
||||
from .notifications import NOTIFICATION_ACTIONS
|
||||
from .posts import POSTS_ACTIONS
|
||||
from .profile import PROFILE_ACTIONS
|
||||
from .project_files import PROJECT_FILE_ACTIONS
|
||||
from .projects import PROJECTS_ACTIONS
|
||||
from .social import SOCIAL_ACTIONS
|
||||
from .tools import TOOLS_ACTIONS
|
||||
from .uploads import UPLOAD_ACTIONS
|
||||
|
||||
ACTIONS: tuple[Action, ...] = (
|
||||
AUTH_ACTIONS
|
||||
+ POSTS_ACTIONS
|
||||
+ COMMENTS_ACTIONS
|
||||
+ PROJECTS_ACTIONS
|
||||
+ PROJECT_FILE_ACTIONS
|
||||
+ JOB_ACTIONS
|
||||
+ TOOLS_ACTIONS
|
||||
+ PROFILE_ACTIONS
|
||||
+ MESSAGE_ACTIONS
|
||||
+ NOTIFICATION_ACTIONS
|
||||
+ ENGAGEMENT_ACTIONS
|
||||
+ SOCIAL_ACTIONS
|
||||
+ ISSUE_ACTIONS
|
||||
+ GIST_ACTIONS
|
||||
+ NEWS_ACTIONS
|
||||
+ UPLOAD_ACTIONS
|
||||
+ ADMIN_ACTIONS
|
||||
+ DBAPI_ACTIONS
|
||||
+ GATEWAY_ACTIONS
|
||||
+ GAME_ACTIONS
|
||||
)
|
||||
|
||||
PLATFORM_CATALOG = Catalog(actions=ACTIONS)
|
||||
|
||||
__all__ = ["ACTIONS", "PLATFORM_CATALOG"]
|
||||
41
devplacepy/services/devii/actions/catalog/_shared.py
Normal file
41
devplacepy/services/devii/actions/catalog/_shared.py
Normal file
@ -0,0 +1,41 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..spec import Param
|
||||
|
||||
|
||||
def path(name: str, description: str, required: bool = True) -> Param:
|
||||
return Param(name=name, location="path", description=description, required=required)
|
||||
|
||||
|
||||
def query(name: str, description: str, required: bool = False) -> Param:
|
||||
return Param(
|
||||
name=name, location="query", description=description, required=required
|
||||
)
|
||||
|
||||
|
||||
def body(name: str, description: str, required: bool = False) -> Param:
|
||||
return Param(name=name, location="body", description=description, required=required)
|
||||
|
||||
|
||||
def upload(name: str, description: str) -> Param:
|
||||
return Param(name=name, location="file", description=description, required=True)
|
||||
|
||||
|
||||
def confirm() -> Param:
|
||||
return Param(
|
||||
name="confirm",
|
||||
location="body",
|
||||
description=(
|
||||
"Set to true ONLY after the user has explicitly confirmed this irreversible action. "
|
||||
"Leave it unset on the first call: the tool refuses and tells you to confirm with the "
|
||||
"user, and only a repeat call with confirm=true actually performs it."
|
||||
),
|
||||
required=False,
|
||||
type="boolean",
|
||||
)
|
||||
|
||||
|
||||
ATTACHMENTS = "Comma separated attachment uids returned by upload_file."
|
||||
TARGET_TYPE = "Target type, e.g. post, comment, project, gist."
|
||||
425
devplacepy/services/devii/actions/catalog/admin.py
Normal file
425
devplacepy/services/devii/actions/catalog/admin.py
Normal file
@ -0,0 +1,425 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..spec import Action
|
||||
from ._shared import body, confirm, path, query
|
||||
|
||||
|
||||
ADMIN_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="admin_overview",
|
||||
method="GET",
|
||||
path="/admin",
|
||||
summary="View the admin overview",
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="site_analytics",
|
||||
method="GET",
|
||||
path="/admin/analytics",
|
||||
summary="Site-wide aggregate analytics in one call (admin only)",
|
||||
description=(
|
||||
"Returns JSON: total members, active users in the last 24h/7d/30d, signed_in_now, "
|
||||
"new signups (24h/7d/30d), content totals (posts, comments, gists, projects, "
|
||||
"news), and top authors. Use this for any 'how many'/'how active'/count question "
|
||||
"instead of paging through admin_list_users. signed_in_now counts members holding an "
|
||||
"unexpired session (logged in within the session lifetime, default 7-30 days), NOT a "
|
||||
"real-time online count - see signed_in_definition in the response and never report it "
|
||||
"as 'currently online' or 'logged in right now'."
|
||||
),
|
||||
params=(query("top_n", "How many top authors to include (1-50)."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="ai_usage",
|
||||
method="GET",
|
||||
path="/admin/ai-usage/data",
|
||||
summary="AI gateway usage, cost, latency, and reliability metrics (admin only)",
|
||||
description=(
|
||||
"Returns JSON for a bounded window: request volume and throughput, token usage with "
|
||||
"averages and percentiles, latency (upstream, gateway overhead, queue wait, connect), "
|
||||
"error rates by category, cost in USD (per model, per caller, input vs output, projected "
|
||||
"monthly burn, caching savings), caller behavior, and an hourly breakdown. Use this for "
|
||||
"any question about AI spend, token consumption, gateway performance, or errors."
|
||||
),
|
||||
params=(
|
||||
query("hours", "Lookback window in hours (1-168, default 48)."),
|
||||
query("top_n", "How many rows in each top-N breakdown (default 10)."),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="audit_log",
|
||||
method="GET",
|
||||
path="/admin/audit-log",
|
||||
summary="Query the platform audit trail with filters (admin only)",
|
||||
description=(
|
||||
"Returns JSON: a paginated, newest-first list of audit events (every state-changing "
|
||||
"action on the platform, with actor, origin, target, old/new value, and result), plus "
|
||||
"pagination, the active filters, and an 'options' object listing every available value "
|
||||
"for each filter (category, event_key, actor_role, origin, result) so you can both "
|
||||
"filter and discover valid filter values from a single call. Use this for any audit, "
|
||||
"history, 'who did X', 'what changed', or 'show denied/failed actions' question. Filter "
|
||||
"by event_key (exact), category, actor_role, actor_uid, origin (web, api, devii, cli, "
|
||||
"service, scheduler), or result (success, failure, denied); narrow by date_from/date_to "
|
||||
"(ISO date bounds, inclusive) or free-text q over summary, event key, and target."
|
||||
),
|
||||
params=(
|
||||
query("page", "Page number (1-based)."),
|
||||
query("event_key", "Filter by exact event key (e.g. admin.setting.update)."),
|
||||
query("category", "Filter by category (auth, content, admin, container, ...)."),
|
||||
query("actor_role", "Filter by actor role at action time."),
|
||||
query("actor_uid", "Filter by acting user uid."),
|
||||
query("origin", "Filter by origin (web, api, devii, cli, service, scheduler)."),
|
||||
query("result", "Filter by result (success, failure, denied)."),
|
||||
query("q", "Free-text search over summary, event key, and target."),
|
||||
query("date_from", "ISO date lower bound (inclusive)."),
|
||||
query("date_to", "ISO date upper bound (inclusive)."),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="audit_event",
|
||||
method="GET",
|
||||
path="/admin/audit-log/{uid}",
|
||||
summary="Get one audit event with its related-object links (admin only)",
|
||||
description=(
|
||||
"Returns JSON: the full audit event row plus every related-object link (actor, target, "
|
||||
"parent, project, instance, setting, ...). Use after audit_log to inspect a single event "
|
||||
"in detail."
|
||||
),
|
||||
params=(path("uid", "Audit event uid."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="bot_monitor",
|
||||
method="GET",
|
||||
path="/admin/bots/data",
|
||||
summary="Live screenshot monitor of every running bot persona (admin only)",
|
||||
description=(
|
||||
"Returns JSON: one entry per running bot slot with its username, persona, current "
|
||||
"action and status text, last page url, frame age in seconds, whether it is active, "
|
||||
"and a frame_url to the latest low-quality screenshot. Use this to see what the bot "
|
||||
"fleet is doing right now. Frames live only in the worker running the Bots service."
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_list_users",
|
||||
method="GET",
|
||||
path="/admin/users",
|
||||
summary="List users for administration",
|
||||
params=(query("page", "Page number."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_set_user_role",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/role",
|
||||
summary="Set a user's role",
|
||||
params=(
|
||||
path("uid", "User uid."),
|
||||
body("role", "New role.", required=True),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_set_user_password",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/password",
|
||||
summary="Set a user's password",
|
||||
params=(
|
||||
path("uid", "User uid."),
|
||||
body("password", "New password.", required=True),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_toggle_user",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/toggle",
|
||||
summary="Toggle a user's active state",
|
||||
params=(path("uid", "User uid."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_get_settings",
|
||||
method="GET",
|
||||
path="/admin/settings",
|
||||
summary="View site settings",
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_save_settings",
|
||||
method="POST",
|
||||
path="/admin/settings",
|
||||
summary="Save site settings",
|
||||
params=(
|
||||
body("site_name", "Site name."),
|
||||
body("site_description", "Site description."),
|
||||
body("site_tagline", "Site tagline."),
|
||||
body("max_upload_size_mb", "Maximum upload size in megabytes."),
|
||||
body("allowed_file_types", "Allowed file types."),
|
||||
body("max_attachments_per_resource", "Maximum attachments per resource."),
|
||||
body("rate_limit_per_minute", "Rate limit per minute."),
|
||||
body("rate_limit_window_seconds", "Rate limit window in seconds."),
|
||||
body("session_max_age_days", "Session maximum age in days."),
|
||||
body("session_remember_days", "Remember-me duration in days."),
|
||||
body("registration_open", "Whether registration is open."),
|
||||
body("maintenance_mode", "Whether maintenance mode is on."),
|
||||
body("maintenance_message", "Maintenance message."),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_list_news",
|
||||
method="GET",
|
||||
path="/admin/news",
|
||||
summary="List news for administration",
|
||||
params=(query("page", "Page number."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_toggle_news",
|
||||
method="POST",
|
||||
path="/admin/news/{uid}/toggle",
|
||||
summary="Toggle a news article",
|
||||
params=(path("uid", "News uid."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_publish_news",
|
||||
method="POST",
|
||||
path="/admin/news/{uid}/publish",
|
||||
summary="Publish a news article",
|
||||
params=(path("uid", "News uid."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_landing_news",
|
||||
method="POST",
|
||||
path="/admin/news/{uid}/landing",
|
||||
summary="Set a news article as landing content",
|
||||
params=(path("uid", "News uid."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_delete_news",
|
||||
method="POST",
|
||||
path="/admin/news/{uid}/delete",
|
||||
summary="Delete a news article (admin only). Soft delete, confirmation required",
|
||||
params=(path("uid", "News uid."), confirm()),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_list_services",
|
||||
method="GET",
|
||||
path="/admin/services",
|
||||
summary="View managed services",
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_services_data",
|
||||
method="GET",
|
||||
path="/admin/services/data",
|
||||
summary="Get live status, metrics, and log tail for every background service",
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_service_status",
|
||||
method="GET",
|
||||
path="/admin/services/{name}/data",
|
||||
summary="Get live status, metrics, and log tail for one background service",
|
||||
params=(path("name", "Service name (e.g. news, bots, openai)."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_start_service",
|
||||
method="POST",
|
||||
path="/admin/services/{name}/start",
|
||||
summary="Start a managed service",
|
||||
params=(path("name", "Service name."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_stop_service",
|
||||
method="POST",
|
||||
path="/admin/services/{name}/stop",
|
||||
summary="Stop a managed service",
|
||||
params=(path("name", "Service name."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_run_service",
|
||||
method="POST",
|
||||
path="/admin/services/{name}/run",
|
||||
summary="Run a managed service once",
|
||||
params=(path("name", "Service name."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_clear_service_logs",
|
||||
method="POST",
|
||||
path="/admin/services/{name}/clear-logs",
|
||||
summary="Clear a managed service's logs",
|
||||
params=(path("name", "Service name."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_config_service",
|
||||
method="POST",
|
||||
path="/admin/services/{name}/config",
|
||||
summary="Update a managed service's configuration",
|
||||
params=(path("name", "Service name."),),
|
||||
freeform_body=True,
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_user_ai_usage",
|
||||
method="GET",
|
||||
path="/admin/users/{uid}/ai-usage",
|
||||
summary="Get a user's AI gateway usage (admin only)",
|
||||
description="Returns per-user AI usage data including token counts, cost, and request volume for the given lookback window.",
|
||||
params=(
|
||||
path("uid", "User uid."),
|
||||
query("hours", "Lookback window in hours (default 24)."),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_reset_user_ai_quota",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/reset-ai-quota",
|
||||
summary="Reset a user's AI quota (admin only)",
|
||||
description="Clears the AI quota ledger for a specific user, allowing them to use AI features again.",
|
||||
params=(path("uid", "User uid."), confirm()),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_media_purge",
|
||||
method="POST",
|
||||
path="/admin/media/{uid}/purge",
|
||||
summary="Permanently remove soft-deleted media (admin only)",
|
||||
description="Permanently deletes a soft-deleted attachment from the database and filesystem.",
|
||||
params=(path("uid", "Attachment uid to purge."), confirm()),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_reset_guest_ai_quota",
|
||||
method="POST",
|
||||
path="/admin/ai-quota/reset-guests",
|
||||
summary="Reset all guest AI quotas (admin only)",
|
||||
description="Clears the AI quota ledger for all guest sessions.",
|
||||
params=(confirm(),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="admin_reset_all_ai_quota",
|
||||
method="POST",
|
||||
path="/admin/ai-quota/reset-all",
|
||||
summary="Reset ALL AI quotas including member quotas (admin only)",
|
||||
description="Clears the AI quota ledger for every user and guest. Use with caution.",
|
||||
params=(confirm(),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="backups_overview",
|
||||
method="GET",
|
||||
path="/admin/backups/data",
|
||||
summary="Backup dashboard: storage usage, backups, and schedules (admin only)",
|
||||
description=(
|
||||
"Returns JSON: per-path storage usage (database, uploads, attachments, project files, "
|
||||
"keys, zips, deepsearch, container workspaces, backups), total data-directory size, total "
|
||||
"stored-backup size and count, disk usage (total/used/free/percent), every backup archive "
|
||||
"(target, status, size, file count, sha256 checksum, created/completed time, download_url), "
|
||||
"and every configured backup schedule. Use this for any question about backups, data "
|
||||
"footprint, disk space, or what is scheduled."
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="backup_run",
|
||||
method="POST",
|
||||
path="/admin/backups/run",
|
||||
summary="Start a backup of a data target (admin only)",
|
||||
description=(
|
||||
"Enqueues an async backup job and returns its job uid and status_url. Target is one of: "
|
||||
"database (consistent SQLite snapshot plus Devii databases), uploads (attachments and "
|
||||
"project files), keys (VAPID keys and config), or full (database, uploads, and keys in one "
|
||||
"archive). Poll backup_status with the returned uid until status is done, then read the "
|
||||
"download_url. The job runs off the request path and never blocks the server."
|
||||
),
|
||||
params=(
|
||||
body("target", "One of database, uploads, keys, full.", required=True),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="backup_status",
|
||||
method="GET",
|
||||
path="/admin/backups/jobs/{uid}",
|
||||
summary="Check the status of a backup job (admin only)",
|
||||
description=(
|
||||
"Returns JSON for one backup job: status (pending, running, done, failed), target, "
|
||||
"backup_uid, download_url (when done), sha256, archive size, file count, and timestamps. "
|
||||
"Poll this after backup_run."
|
||||
),
|
||||
params=(path("uid", "Backup job uid returned by backup_run."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="backup_delete",
|
||||
method="POST",
|
||||
path="/admin/backups/{uid}/delete",
|
||||
summary="Permanently delete a backup archive (admin only)",
|
||||
description=(
|
||||
"Removes a backup archive file and its record. This is irreversible and reclaims disk "
|
||||
"space. The uid is a backup uid (from backups_overview), not a job uid."
|
||||
),
|
||||
params=(path("uid", "Backup uid to delete."), confirm()),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="backup_schedule_create",
|
||||
method="POST",
|
||||
path="/admin/backups/schedules/create",
|
||||
summary="Create a recurring backup schedule (admin only)",
|
||||
description=(
|
||||
"Creates a schedule that fires backups automatically. kind is 'interval' (use every_seconds, "
|
||||
"minimum 60) or 'cron' (use a 5-field cron expression in 'minute hour dom month dow'). "
|
||||
"keep_last rotates older backups of this schedule, keeping only the newest N (0 keeps all). "
|
||||
"target is one of database, uploads, keys, full."
|
||||
),
|
||||
params=(
|
||||
body("name", "Human-readable schedule name.", required=True),
|
||||
body("target", "One of database, uploads, keys, full.", required=True),
|
||||
body("kind", "interval or cron.", required=True),
|
||||
body("every_seconds", "Seconds between runs when kind=interval (min 60)."),
|
||||
body("cron", "Cron expression when kind=cron, e.g. '0 3 * * *'."),
|
||||
body("keep_last", "Keep only the newest N backups of this schedule (0 = all)."),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="backup_schedule_delete",
|
||||
method="POST",
|
||||
path="/admin/backups/schedules/{uid}/delete",
|
||||
summary="Delete a backup schedule (admin only)",
|
||||
description=(
|
||||
"Removes a backup schedule so it stops firing. Existing backup archives are kept; only the "
|
||||
"schedule is deleted."
|
||||
),
|
||||
params=(path("uid", "Backup schedule uid."), confirm()),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="restore_media",
|
||||
method="POST",
|
||||
path="/media/{uid}/restore",
|
||||
summary="Restore a soft-deleted media attachment (admin only)",
|
||||
description="Restores a previously soft-deleted attachment, making it visible again.",
|
||||
params=(path("uid", "Attachment uid to restore."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
)
|
||||
72
devplacepy/services/devii/actions/catalog/auth.py
Normal file
72
devplacepy/services/devii/actions/catalog/auth.py
Normal file
@ -0,0 +1,72 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..spec import Action
|
||||
from ._shared import body, path
|
||||
|
||||
|
||||
AUTH_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="auth_status",
|
||||
method="GET",
|
||||
path="",
|
||||
summary="Report whether the current session is authenticated",
|
||||
handler="status",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="login",
|
||||
method="POST",
|
||||
path="/auth/login",
|
||||
summary="Authenticate with email and password and start a session",
|
||||
handler="login",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
body("email", "Account email address.", required=True),
|
||||
body("password", "Account password.", required=True),
|
||||
body("remember_me", "Keep the session alive longer ('on' or '')."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="logout",
|
||||
method="GET",
|
||||
path="/auth/logout",
|
||||
summary="End the current session",
|
||||
handler="logout",
|
||||
requires_auth=True,
|
||||
),
|
||||
Action(
|
||||
name="signup",
|
||||
method="POST",
|
||||
path="/auth/signup",
|
||||
summary="Create a new account",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
body("username", "Desired username.", required=True),
|
||||
body("email", "Email address.", required=True),
|
||||
body("password", "Password (minimum six characters).", required=True),
|
||||
body("confirm_password", "Password confirmation.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="forgot_password",
|
||||
method="POST",
|
||||
path="/auth/forgot-password",
|
||||
summary="Request a password reset email",
|
||||
requires_auth=False,
|
||||
params=(body("email", "Account email address.", required=True),),
|
||||
),
|
||||
Action(
|
||||
name="reset_password",
|
||||
method="POST",
|
||||
path="/auth/reset-password/{token}",
|
||||
summary="Reset a password using a reset token",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
path("token", "Reset token from the email link."),
|
||||
body("password", "New password.", required=True),
|
||||
body("confirm_password", "New password confirmation.", required=True),
|
||||
),
|
||||
),
|
||||
)
|
||||
41
devplacepy/services/devii/actions/catalog/comments.py
Normal file
41
devplacepy/services/devii/actions/catalog/comments.py
Normal file
@ -0,0 +1,41 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..spec import Action
|
||||
from ._shared import ATTACHMENTS, TARGET_TYPE, body, confirm, path
|
||||
|
||||
|
||||
COMMENTS_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="create_comment",
|
||||
method="POST",
|
||||
path="/comments/create",
|
||||
summary="Create a comment on a post or other target",
|
||||
params=(
|
||||
body("content", "Comment body.", required=True),
|
||||
body("post_uid", "Uid of the post being commented on."),
|
||||
body("target_uid", "Uid of the target when not a post."),
|
||||
body("target_type", TARGET_TYPE),
|
||||
body("parent_uid", "Parent comment uid for replies."),
|
||||
body("attachment_uids", ATTACHMENTS),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="edit_comment",
|
||||
method="POST",
|
||||
path="/comments/edit/{comment_uid}",
|
||||
summary="Edit the body of one of your own comments",
|
||||
params=(
|
||||
path("comment_uid", "Uid of the comment."),
|
||||
body("content", "New comment body, 3-1000 characters.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="delete_comment",
|
||||
method="POST",
|
||||
path="/comments/delete/{comment_uid}",
|
||||
summary="Delete a comment (your own, or any comment when you are an administrator). Soft delete, confirmation required",
|
||||
params=(path("comment_uid", "Uid of the comment."), confirm()),
|
||||
),
|
||||
)
|
||||
127
devplacepy/services/devii/actions/catalog/dbapi.py
Normal file
127
devplacepy/services/devii/actions/catalog/dbapi.py
Normal file
@ -0,0 +1,127 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..spec import Action, Param
|
||||
from ._shared import body, path, query
|
||||
|
||||
|
||||
DBAPI_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="db_list_tables",
|
||||
method="GET",
|
||||
path="/dbapi/tables",
|
||||
summary="List database tables exposed by the database API (primary administrator only)",
|
||||
description=(
|
||||
"Returns every table reachable through the database API with its row count and "
|
||||
"whether it uses soft deletes. Use this to discover what data exists before "
|
||||
"querying or designing a query."
|
||||
),
|
||||
params=(),
|
||||
requires_admin=True,
|
||||
requires_primary_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_table_schema",
|
||||
method="GET",
|
||||
path="/dbapi/{table}/schema",
|
||||
summary="Show a table's columns and types (primary administrator only)",
|
||||
description="Returns the column names, types, row count, and soft-delete flag for one table.",
|
||||
params=(path("table", "Table name (from db_list_tables)."),),
|
||||
requires_admin=True,
|
||||
requires_primary_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_list_rows",
|
||||
method="GET",
|
||||
path="/dbapi/{table}",
|
||||
summary="List rows of a table with keyset pagination (primary administrator only)",
|
||||
description=(
|
||||
"Browses rows newest-first. Soft-deleted rows are excluded unless include_deleted is "
|
||||
"true. For filtered or joined questions prefer db_query or db_design_query."
|
||||
),
|
||||
params=(
|
||||
path("table", "Table name."),
|
||||
query("limit", "Maximum rows (1-500, default 25)."),
|
||||
query("search", "Free-text search over common text columns."),
|
||||
query("before", "Keyset cursor: return rows older than this created_at/id value."),
|
||||
Param(
|
||||
name="include_deleted",
|
||||
location="query",
|
||||
description="Include soft-deleted rows.",
|
||||
required=False,
|
||||
type="boolean",
|
||||
),
|
||||
),
|
||||
requires_admin=True,
|
||||
requires_primary_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_get_row",
|
||||
method="GET",
|
||||
path="/dbapi/{table}/{key}/{value}",
|
||||
summary="Fetch one row by a key column (primary administrator only)",
|
||||
description="Returns a single row where key column equals value (key is usually 'uid').",
|
||||
params=(
|
||||
path("table", "Table name."),
|
||||
path("key", "Key column to match (usually 'uid')."),
|
||||
path("value", "Value of the key column."),
|
||||
),
|
||||
requires_admin=True,
|
||||
requires_primary_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_query",
|
||||
method="POST",
|
||||
path="/dbapi/query",
|
||||
summary="Run a read-only SQL SELECT and return rows (primary administrator only)",
|
||||
description=(
|
||||
"Executes a SINGLE validated SELECT statement read-only and returns the rows. Only "
|
||||
"SELECT is allowed; INSERT/UPDATE/DELETE/DDL are rejected. The database API is "
|
||||
"read-only and cannot change data in any way. The response may include a 'suspicious' "
|
||||
"list (e.g. a SELECT with no WHERE/JOIN/LIMIT that scans a whole table); when present, "
|
||||
"surface that warning to the user before trusting the results."
|
||||
),
|
||||
params=(
|
||||
body("sql", "A single SELECT statement.", required=True),
|
||||
body("dialect", "Optional source SQL dialect (default sqlite)."),
|
||||
),
|
||||
requires_admin=True,
|
||||
requires_primary_admin=True,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="db_design_query",
|
||||
method="POST",
|
||||
path="/dbapi/nl",
|
||||
summary="Design a SQL SELECT from a natural-language question (primary administrator only)",
|
||||
description=(
|
||||
"Turns a plain-language question about one table into a validated read-only SELECT. "
|
||||
"It auto-adds 'deleted_at IS NULL' for soft-delete tables unless apply_soft_delete is "
|
||||
"false. By default it only returns the SQL; pass execute=true to also run it read-only "
|
||||
"and return rows. Show the user the SQL and any 'suspicious' notes."
|
||||
),
|
||||
params=(
|
||||
body("question", "The natural-language request.", required=True),
|
||||
body("table", "Target table the question is about.", required=True),
|
||||
Param(
|
||||
name="apply_soft_delete",
|
||||
location="body",
|
||||
description="Add deleted_at IS NULL for soft-delete tables (default true).",
|
||||
required=False,
|
||||
type="boolean",
|
||||
),
|
||||
body("dialect", "Optional target SQL dialect (default sqlite)."),
|
||||
Param(
|
||||
name="execute",
|
||||
location="body",
|
||||
description="Also run the validated query read-only and return rows.",
|
||||
required=False,
|
||||
type="boolean",
|
||||
),
|
||||
),
|
||||
requires_admin=True,
|
||||
requires_primary_admin=True,
|
||||
read_only=True,
|
||||
),
|
||||
)
|
||||
80
devplacepy/services/devii/actions/catalog/engagement.py
Normal file
80
devplacepy/services/devii/actions/catalog/engagement.py
Normal file
@ -0,0 +1,80 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..spec import Action
|
||||
from ._shared import TARGET_TYPE, body, path, query
|
||||
|
||||
|
||||
ENGAGEMENT_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="vote",
|
||||
method="POST",
|
||||
path="/votes/{target_type}/{target_uid}",
|
||||
summary="Cast or toggle a vote on a target",
|
||||
description="Re-sending the same value removes the vote. Returns the net/up/down tally.",
|
||||
ajax=True,
|
||||
params=(
|
||||
path("target_type", TARGET_TYPE),
|
||||
path(
|
||||
"target_uid",
|
||||
"Uid of the target, copied from a listing response; do not invent it.",
|
||||
),
|
||||
body(
|
||||
"value",
|
||||
"Vote value: 1 to upvote, -1 to downvote (re-send to remove).",
|
||||
required=True,
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="react",
|
||||
method="POST",
|
||||
path="/reactions/{target_type}/{target_uid}",
|
||||
summary="Toggle an emoji reaction on a target",
|
||||
description="Re-sending the same emoji removes it. Returns reaction counts.",
|
||||
ajax=True,
|
||||
params=(
|
||||
path("target_type", TARGET_TYPE),
|
||||
path(
|
||||
"target_uid",
|
||||
"Uid of the target, copied from a listing response; do not invent it.",
|
||||
),
|
||||
body("emoji", "One of the allowed reaction emoji.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="list_bookmarks",
|
||||
method="GET",
|
||||
path="/bookmarks/saved",
|
||||
summary="List saved bookmarks",
|
||||
params=(query("before", "Pagination cursor."),),
|
||||
),
|
||||
Action(
|
||||
name="toggle_bookmark",
|
||||
method="POST",
|
||||
path="/bookmarks/{target_type}/{target_uid}",
|
||||
summary="Toggle a bookmark on a target",
|
||||
description="Re-sending removes the bookmark. Returns {saved: true|false}.",
|
||||
ajax=True,
|
||||
params=(
|
||||
path("target_type", TARGET_TYPE),
|
||||
path(
|
||||
"target_uid",
|
||||
"Uid of the target, copied from a listing response; do not invent it.",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="vote_poll",
|
||||
method="POST",
|
||||
path="/polls/{poll_uid}/vote",
|
||||
summary="Vote in a poll",
|
||||
description="Casts or changes your vote on a poll option. Returns the option tallies.",
|
||||
ajax=True,
|
||||
params=(
|
||||
path("poll_uid", "Poll uid."),
|
||||
body("option_uid", "Chosen option uid.", required=True),
|
||||
),
|
||||
),
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user