This commit is contained in:
2026-07-09 02:52:54 +02:00
parent 818568c609
commit 48bb6c2ec2
95 changed files with 6115 additions and 267 deletions
+1 -1
View File
@@ -180,7 +180,7 @@ Site settings are seeded on startup (`site_settings` table):
| `customization_js_enabled` | `"1"` | When `"0"`, custom CSS still serves but custom JS is suppressed |
| `extra_head` | `""` | Raw HTML emitted verbatim into every page `<head>` by `templating.extra_head_tag()`; site-wide trusted-admin input, not sanitized |
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`). The **Custom Code** key `extra_head` is the sole key in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea removes it (the default loop skips empty values).
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` is the sole key in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea removes it (the default loop skips empty values).
The seed block in `database.py` is guarded by `if "site_settings" in tables:` - on a brand-new DB the table does not exist yet (dataset creates tables lazily on first insert), so none of these rows are written until the table exists. Correct runtime behavior therefore relies on every consumer passing the production default to `get_setting`/`get_int_setting`, not on the seed.
+18 -1
View File
@@ -8,7 +8,24 @@ from .relations import _relations_cache, get_user_relations, get_blocked_uids, g
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 .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, AWARD_USAGE_KEY, add_award_usage, get_award_usage
from .awards import (
AWARDS_PER_PAGE,
award_display_hours,
award_give_cooldown_hours,
award_receive_cooldown_hours,
award_is_prominent,
can_give_award,
can_receive_award,
count_published_awards,
enrich_award,
get_prominent_award,
get_user_awards,
has_giver_cooldown,
has_receiver_cooldown,
recompute_user_award_stats,
revoke_award,
)
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
+193
View File
@@ -0,0 +1,193 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from devplacepy.config import (
AWARD_DISPLAY_HOURS_DEFAULT,
AWARD_GIVE_COOLDOWN_HOURS_DEFAULT,
AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT,
)
from .core import db
from .pagination import build_pagination
from .settings import get_int_setting
from .core import get_table, _now_iso
from .users import get_users_by_uids
from .content import resolve_by_slug
from .soft_delete import soft_delete, soft_delete_in
AWARDS_PER_PAGE = 12
def _awards_table():
return get_table("awards")
def award_give_cooldown_hours() -> int:
return max(1, get_int_setting("award_give_cooldown_hours", AWARD_GIVE_COOLDOWN_HOURS_DEFAULT))
def award_receive_cooldown_hours() -> int:
return max(
1, get_int_setting("award_receive_cooldown_hours", AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT)
)
def award_display_hours() -> int:
return max(1, get_int_setting("award_display_hours", AWARD_DISPLAY_HOURS_DEFAULT))
def _cooldown_cutoff(hours: int) -> str:
return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
def has_giver_cooldown(giver_uid: str) -> bool:
if not giver_uid or "awards" not in db.tables:
return False
cutoff = _cooldown_cutoff(award_give_cooldown_hours())
row = _awards_table().find_one(
giver_uid=giver_uid, deleted_at=None, created_at={">=": cutoff}
)
return row is not None
def has_receiver_cooldown(receiver_uid: str) -> bool:
if not receiver_uid or "awards" not in db.tables:
return False
cutoff = _cooldown_cutoff(award_receive_cooldown_hours())
row = _awards_table().find_one(
receiver_uid=receiver_uid, deleted_at=None, created_at={">=": cutoff}
)
return row is not None
def can_receive_award(receiver_uid: str) -> bool:
return not has_receiver_cooldown(receiver_uid)
def can_give_award(giver_uid: str, receiver_uid: str) -> bool:
if not giver_uid or not receiver_uid or giver_uid == receiver_uid:
return False
return not has_giver_cooldown(giver_uid) and not has_receiver_cooldown(receiver_uid)
def _published_filter():
return {"deleted_at": None, "generated_at": {">": ""}}
def count_published_awards(receiver_uid: str) -> int:
if not receiver_uid or "awards" not in db.tables:
return 0
return _awards_table().count(receiver_uid=receiver_uid, **_published_filter())
def _latest_published(receiver_uid: str):
if not receiver_uid or "awards" not in db.tables:
return None
rows = list(
_awards_table().find(
receiver_uid=receiver_uid,
deleted_at=None,
generated_at={">": ""},
order_by=["-generated_at"],
_limit=1,
)
)
return rows[0] if rows else None
def recompute_user_award_stats(receiver_uid: str) -> None:
if not receiver_uid or "users" not in db.tables:
return
count = count_published_awards(receiver_uid)
latest = _latest_published(receiver_uid)
users = get_table("users")
payload = {
"uid": receiver_uid,
"award_count": count,
"last_award_at": latest.get("generated_at") if latest else None,
"last_award_slug": latest.get("slug") if latest else None,
"last_award_uid": latest.get("uid") if latest else None,
}
users.update(payload, ["uid"])
def award_is_prominent(user: dict | None) -> bool:
if not user or not user.get("last_award_at") or not user.get("last_award_uid"):
return False
award = resolve_by_slug(_awards_table(), user["last_award_uid"])
if not award or not award.get("generated_at"):
return False
try:
published = datetime.fromisoformat(award["generated_at"])
if published.tzinfo is None:
published = published.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
return False
window = timedelta(hours=award_display_hours())
return datetime.now(timezone.utc) - published <= window
def enrich_award(row: dict, givers: dict | None = None) -> dict:
item = dict(row)
giver_uid = row.get("giver_uid", "")
giver = (givers or {}).get(giver_uid) or get_users_by_uids([giver_uid]).get(giver_uid)
item["giver"] = giver
item["image_url"] = f"/awards/{row.get('slug', '')}/256"
item["thumb_url"] = f"/awards/{row.get('slug', '')}/64"
return item
def get_user_awards(receiver_uid: str, page: int = 1, per_page: int = AWARDS_PER_PAGE):
if not receiver_uid or "awards" not in db.tables:
return [], build_pagination(page, 0, per_page)
table = _awards_table()
total = table.count(receiver_uid=receiver_uid, **_published_filter())
offset = max(0, (page - 1) * per_page)
rows = list(
table.find(
receiver_uid=receiver_uid,
deleted_at=None,
generated_at={">": ""},
order_by=["-generated_at"],
_limit=per_page,
_offset=offset,
)
)
giver_uids = [row.get("giver_uid") for row in rows if row.get("giver_uid")]
givers = get_users_by_uids(giver_uids)
items = [enrich_award(row, givers) for row in rows]
return items, build_pagination(page, total, per_page)
def get_prominent_award(profile_user: dict) -> dict | None:
if not award_is_prominent(profile_user):
return None
award = resolve_by_slug(_awards_table(), profile_user.get("last_award_uid", ""))
if not award:
return None
return enrich_award(award)
def revoke_award(award_uid: str, admin_uid: str) -> dict | None:
table = _awards_table()
row = table.find_one(uid=award_uid)
if not row or row.get("deleted_at"):
return None
stamp = _now_iso()
attachment_uids = [
uid
for uid in (
row.get("attachment_uid_512"),
row.get("attachment_uid_256"),
row.get("attachment_uid_64"),
)
if uid
]
soft_delete("awards", admin_uid, stamp=stamp, uid=award_uid)
from devplacepy.attachments import soft_delete_attachments_for
soft_delete_attachments_for("award", [award_uid], admin_uid)
if attachment_uids:
soft_delete_in("attachments", "uid", attachment_uids, admin_uid, stamp=stamp)
recompute_user_award_stats(row.get("receiver_uid", ""))
return row
+6
View File
@@ -40,6 +40,12 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
comment.get("target_uid") or comment.get("post_uid", ""),
)
return f"{parent_url}#comment-{target_uid}"
if target_type == "award":
award = resolve_by_slug(get_table("awards"), target_uid)
if award:
receiver = get_table("users").find_one(uid=award.get("receiver_uid", ""))
if receiver:
return f"/profile/{receiver['username']}?tab=awards#award-{award.get('slug', '')}"
return "/feed"
+23 -22
View File
@@ -61,10 +61,11 @@ 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)"
)
with db:
db.query(
"CREATE TABLE IF NOT EXISTS cache_state "
"(name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)"
)
_cache_state_ready = True
@@ -74,14 +75,15 @@ def get_cache_version(name: str) -> int:
return cached
try:
_ensure_cache_state()
row = next(
iter(
db.query(
"SELECT version FROM cache_state WHERE name = :name", name=name
)
),
None,
)
with db:
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}")
@@ -93,16 +95,15 @@ def get_cache_version(name: str) -> int:
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()
with db:
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,
)
_cache_version_cache.pop(name)
except Exception as e:
logger.warning(f"Could not bump cache version {name}: {e}")
+1
View File
@@ -17,6 +17,7 @@ NOTIFICATION_TYPES = [
{"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"},
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
]
+146
View File
@@ -658,6 +658,67 @@ def init_db():
except Exception as e:
logger.warning(f"Could not create unique index on seo_usage: {e}")
award_usage = get_table("award_usage")
for column, example in (
("user_uid", ""),
("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),
("updated_at", ""),
):
if not award_usage.has_column(column):
award_usage.create_column_by_example(column, example)
try:
if "award_usage" in db.tables:
db.query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_award_usage_user "
"ON award_usage (user_uid)"
)
except Exception as e:
logger.warning(f"Could not create unique index on award_usage: {e}")
awards = get_table("awards")
for column, example in (
("uid", ""),
("slug", ""),
("description", ""),
("giver_uid", ""),
("receiver_uid", ""),
("attachment_uid_512", ""),
("attachment_uid_256", ""),
("attachment_uid_64", ""),
("generated_at", ""),
("created_at", ""),
("job_uid", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not awards.has_column(column):
awards.create_column_by_example(column, example)
try:
if "awards" in db.tables:
db.query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_awards_slug ON awards (slug)"
)
db.query(
"CREATE INDEX IF NOT EXISTS idx_awards_receiver_created "
"ON awards (receiver_uid, created_at)"
)
db.query(
"CREATE INDEX IF NOT EXISTS idx_awards_giver_created "
"ON awards (giver_uid, created_at)"
)
db.query(
"CREATE INDEX IF NOT EXISTS idx_awards_generated "
"ON awards (receiver_uid, generated_at)"
)
except Exception as e:
logger.warning(f"Could not create awards indexes: {e}")
seo_metadata = get_table("seo_metadata")
for column, example in (
("uid", ""),
@@ -1155,7 +1216,9 @@ def init_db():
"customization_enabled": "1",
"customization_js_enabled": "1",
"audit_log_retention_days": "90",
"statistics_tracking_enabled": "1",
"docs_search_mode": "agent",
"outbound_proxy_url": "",
}
for key, value in operational_defaults.items():
existing = db["site_settings"].find_one(key=key)
@@ -1164,6 +1227,72 @@ def init_db():
{"uid": f"default_{key}", "key": key, "value": value}
)
with db:
db.query(
"CREATE TABLE IF NOT EXISTS visit_stats_hourly ("
"bucket_start TEXT NOT NULL, "
"page_group TEXT NOT NULL, "
"referrer_group TEXT NOT NULL, "
"views INTEGER NOT NULL DEFAULT 0, "
"member_views INTEGER NOT NULL DEFAULT 0, "
"guest_views INTEGER NOT NULL DEFAULT 0)"
)
db.query(
"CREATE TABLE IF NOT EXISTS visit_unique_slots ("
"bucket_start TEXT NOT NULL, "
"visitor_hash TEXT NOT NULL, "
"page_group TEXT NOT NULL, "
"user_uid TEXT)"
)
_index(db, "visit_stats_hourly", "idx_visit_hourly_bucket", ["bucket_start"])
_index(
db,
"visit_stats_hourly",
"idx_visit_hourly_page_time",
["page_group", "bucket_start"],
)
_index(
db,
"visit_stats_hourly",
"idx_visit_hourly_ref_time",
["referrer_group", "bucket_start"],
)
_index(
db,
"visit_stats_hourly",
"idx_visit_hourly_unique_row",
["bucket_start", "page_group", "referrer_group"],
unique=True,
)
_index(db, "visit_unique_slots", "idx_visit_unique_bucket", ["bucket_start"])
_index(
db,
"visit_unique_slots",
"idx_visit_unique_hash",
["bucket_start", "visitor_hash"],
)
_index(
db,
"visit_unique_slots",
"idx_visit_unique_row",
["bucket_start", "visitor_hash", "page_group"],
unique=True,
)
_index(db, "issue_tickets", "idx_issue_tickets_created", ["created_at"])
_index(db, "devii_turns", "idx_devii_turns_started", ["started_at"])
_index(db, "instance_events", "idx_instance_events_created", ["created_at"])
_index(db, "game_steals", "idx_game_steals_stolen_at", ["stolen_at"])
_index(db, "messages", "idx_messages_created_at", ["created_at"])
_index(db, "notifications", "idx_notifications_created", ["created_at"])
_index(db, "follows", "idx_follows_created_at", ["created_at"])
_index(db, "reactions", "idx_reactions_created_at", ["created_at"])
_index(db, "votes", "idx_votes_created_at", ["created_at"])
_index(db, "bookmarks", "idx_bookmarks_created_at", ["created_at"])
_index(db, "badges", "idx_badges_created_at", ["created_at"])
_index(db, "audit_log", "idx_audit_result_created", ["result", "created_at"])
_index(db, "jobs", "idx_jobs_created_at", ["created_at"])
_index(db, "attachments", "idx_attachments_created_at", ["created_at"])
_backfill_gamification()
backfill_api_keys()
migrate_ai_gateway_settings()
@@ -1210,6 +1339,15 @@ def migrate_ai_gateway_settings() -> None:
if get_setting("bot_model", "") == "deepseek-chat":
set_setting("bot_model", "molodetz")
logger.info("Migrated bot_model to molodetz")
from devplacepy.services.openai_gateway.routing import (
migrate_retired_image_gateway,
seed_default_deepseek_routes,
seed_default_image_routes,
)
seed_default_deepseek_routes()
seed_default_image_routes()
migrate_retired_image_gateway()
def backfill_api_keys() -> int:
@@ -1240,6 +1378,14 @@ def backfill_api_keys() -> int:
users.create_column_by_example("avatar_seed", "")
if not users.has_column("last_seen"):
users.create_column_by_example("last_seen", "")
if not users.has_column("award_count"):
users.create_column_by_example("award_count", 0)
if not users.has_column("last_award_at"):
users.create_column_by_example("last_award_at", "")
if not users.has_column("last_award_slug"):
users.create_column_by_example("last_award_slug", "")
if not users.has_column("last_award_uid"):
users.create_column_by_example("last_award_uid", "")
with db:
db.query(
"UPDATE users SET ai_modifier_enabled = 1 WHERE ai_modifier_enabled IS NULL"
+1
View File
@@ -41,6 +41,7 @@ SOFT_DELETE_TABLES = [
"email_accounts",
"user_relations",
"seo_metadata",
"awards",
]
+11
View File
@@ -126,3 +126,14 @@ def add_seo_usage(totals: dict) -> None:
def get_seo_usage() -> dict:
return _get_usage("seo_usage", SEO_USAGE_KEY)
AWARD_USAGE_KEY = "award"
def add_award_usage(totals: dict) -> None:
_add_usage("award_usage", AWARD_USAGE_KEY, totals)
def get_award_usage() -> dict:
return _get_usage("award_usage", AWARD_USAGE_KEY)