Molodetz blogrol volgens DPP-template

This commit is contained in:
2026-10-05 09:36:20 +02:00
commit f290a4c766
277 changed files with 15758 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
# retoor <retoor@molodetz.nl>
from molodetz.database.audit import count_audit, insert_audit, list_audit, prune_audit
from molodetz.database.backups import (
delete_backup_rows,
enqueue_backup,
get_backup,
list_backups,
pending_backups,
update_backup,
)
from molodetz.database.core import (
db,
generate_uid,
make_combined_slug,
now_iso,
parse_iso,
refresh_snapshot,
resolve_by_slug,
shard_path,
table_exists,
)
from molodetz.database.init_db import init_db
from molodetz.database.joins import (
count_open_join_requests,
get_join_request,
insert_join_request,
list_join_requests,
update_join_request,
)
from molodetz.database.media import count_media, media_by_kind, upsert_media
from molodetz.database.notifications import insert_notification, list_notifications, mark_all_read, unread_count
from molodetz.database.pagination import build_pagination, paginate
from molodetz.database.posts import (
all_posts,
count_published,
get_post,
get_post_by_slug,
insert_post,
post_counts_by_author,
published_posts,
update_post_row,
)
from molodetz.database.services_state import ensure_service_state, get_service_state, write_service_state
from molodetz.database.sessions import (
create_session,
end_all_sessions_for,
end_session,
get_session,
hard_delete_session,
prune_expired_sessions,
)
from molodetz.database.settings import (
all_settings,
bump_cache_version,
get_int_setting,
get_setting,
set_setting,
sync_local_cache,
)
from molodetz.database.soft_delete import (
count_deleted,
list_deleted,
purge,
purge_event,
restore,
restore_event,
soft_delete,
soft_delete_in,
)
from molodetz.database.users import (
admin_uids_ordered,
count_users,
create_user,
get_user,
get_user_by_api_key,
get_user_by_email,
get_user_by_username,
invalidate_admins_cache,
list_users,
public_people,
set_role,
update_user,
users_by_uids,
)
from molodetz.database.visits import daily_totals, flush_visits, top_paths
__all__ = [
"all_posts",
"all_settings",
"admin_uids_ordered",
"build_pagination",
"bump_cache_version",
"count_audit",
"count_deleted",
"count_media",
"count_open_join_requests",
"count_published",
"count_users",
"create_session",
"create_user",
"daily_totals",
"db",
"delete_backup_rows",
"end_all_sessions_for",
"end_session",
"enqueue_backup",
"ensure_service_state",
"flush_visits",
"generate_uid",
"get_backup",
"get_int_setting",
"get_join_request",
"get_post",
"get_post_by_slug",
"get_service_state",
"get_session",
"get_setting",
"get_user",
"get_user_by_api_key",
"get_user_by_email",
"get_user_by_username",
"hard_delete_session",
"init_db",
"insert_audit",
"insert_join_request",
"insert_notification",
"insert_post",
"invalidate_admins_cache",
"list_audit",
"list_backups",
"list_deleted",
"list_join_requests",
"list_notifications",
"list_users",
"make_combined_slug",
"mark_all_read",
"media_by_kind",
"now_iso",
"paginate",
"parse_iso",
"pending_backups",
"post_counts_by_author",
"prune_audit",
"prune_expired_sessions",
"public_people",
"published_posts",
"purge",
"purge_event",
"refresh_snapshot",
"resolve_by_slug",
"restore",
"restore_event",
"set_role",
"set_setting",
"shard_path",
"soft_delete",
"soft_delete_in",
"sync_local_cache",
"table_exists",
"top_paths",
"unread_count",
"update_backup",
"update_join_request",
"update_post_row",
"update_user",
"upsert_media",
"users_by_uids",
"write_service_state",
]
+37
View File
@@ -0,0 +1,37 @@
# retoor <retoor@molodetz.nl>
from molodetz.database.core import db
def insert_audit(row, links):
db["audit_log"].insert(row)
for link in links:
db["audit_links"].insert(link)
def list_audit(limit=200, category=None):
if "audit_log" not in db.tables:
return []
criteria = {}
if category:
criteria["category"] = category
return list(db["audit_log"].find(order_by=["-created_at"], _limit=limit, **criteria))
def count_audit():
if "audit_log" not in db.tables:
return 0
return db["audit_log"].count()
def prune_audit(before_iso, dry_run=False):
if "audit_log" not in db.tables:
return 0
rows = list(db.query("SELECT uid FROM audit_log WHERE created_at < :before", before=before_iso))
if rows and not dry_run:
with db:
db.query(
"DELETE FROM audit_links WHERE audit_uid IN (SELECT uid FROM audit_log WHERE created_at < :before)",
before=before_iso,
)
db.query("DELETE FROM audit_log WHERE created_at < :before", before=before_iso)
return len(rows)
+47
View File
@@ -0,0 +1,47 @@
# retoor <retoor@molodetz.nl>
from molodetz.database.core import db, generate_uid, now_iso
def enqueue_backup(target, requested_by):
row = {
"uid": generate_uid(),
"target": target,
"status": "pending",
"requested_by": requested_by,
"directory": None,
"stored_name": None,
"size_bytes": None,
"error": None,
"created_at": now_iso(),
"finished_at": None,
}
db["backups"].insert(row)
return row
def pending_backups():
if "backups" not in db.tables:
return []
return list(db["backups"].find(status="pending", order_by=["uid"]))
def list_backups(limit=100):
if "backups" not in db.tables:
return []
return list(db["backups"].find(order_by=["-created_at"], _limit=limit))
def get_backup(uid):
if "backups" not in db.tables:
return None
return db["backups"].find_one(uid=uid)
def update_backup(uid, **fields):
fields["uid"] = uid
db["backups"].update(fields, ["uid"])
def delete_backup_rows(uids):
for uid in uids:
db["backups"].delete(uid=uid)
+116
View File
@@ -0,0 +1,116 @@
# retoor <retoor@molodetz.nl>
import logging
import re
from datetime import datetime, timezone
import dataset
import sqlalchemy
import uuid_utils
from sqlalchemy.pool import NullPool
from molodetz import config
logger = logging.getLogger(__name__)
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",
]
config.ensure_data_dirs()
db = dataset.connect(
config.DATABASE_URL,
engine_kwargs={
"connect_args": {"timeout": 30, "check_same_thread": False},
"poolclass": NullPool,
},
on_connect_statements=ON_CONNECT_STATEMENTS,
)
def generate_uid():
return str(uuid_utils.uuid7())
def now_iso():
return datetime.now(timezone.utc).isoformat()
def parse_iso(value):
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def refresh_snapshot():
try:
if getattr(db.local, "tx", None):
return
conn = db.executable
if conn.in_transaction():
conn.commit()
except Exception as exc:
logger.warning("refresh_snapshot failed: %s", exc)
def table_exists(name):
return name in db.tables
def in_clause(values, prefix="v"):
params = {f"{prefix}{index}": value for index, value in enumerate(values)}
placeholders = ", ".join(f":{key}" for key in params)
return placeholders, params
def atomic_update(sql, **params):
with db:
result = db.executable.execute(sqlalchemy.text(sql), params)
return result.rowcount
def run_write(sql, **params):
with db:
db.query(sql, **params)
def slugify(text):
lowered = (text or "").lower()
replaced = re.sub(r"[^a-z0-9-]", "-", lowered)
return re.sub(r"-+", "-", replaced).strip("-")
def make_combined_slug(text, uid):
tail = uid.replace("-", "")[-12:]
slug = slugify(text)
return f"{slug}-{tail}" if slug else tail
def shard_path(uid):
tail = uid.replace("-", "")
return f"{tail[-2:]}/{tail[-4:-2]}"
def resolve_by_slug(table_name, slug, include_deleted=False):
if table_name not in db.tables:
return None
table = db[table_name]
for key in ("slug", "uid"):
criteria = {key: slug}
if not include_deleted:
criteria["deleted_at"] = None
row = table.find_one(**criteria)
if row:
return row
return None
+233
View File
@@ -0,0 +1,233 @@
# retoor <retoor@molodetz.nl>
import logging
from molodetz import config
from molodetz.constants import SETTINGS_DEFAULTS
from molodetz.database.core import db, generate_uid, now_iso
logger = logging.getLogger(__name__)
TABLE_EXAMPLES = {
"users": {
"uid": "x",
"username": "x",
"email": "x@x.nl",
"password_hash": "x",
"role": "Member",
"api_key": "x",
"bio": "",
"avatar_seed": None,
"is_active": True,
"last_seen_at": None,
"suspended_until": None,
"suspend_reason": None,
"terms_version": "1",
"created_at": now_iso(),
"updated_at": now_iso(),
"deleted_at": None,
"deleted_by": None,
},
"sessions": {
"uid": "x",
"token": "x",
"user_uid": "x",
"created_at": now_iso(),
"expires_at": now_iso(),
"deleted_at": None,
"deleted_by": None,
},
"posts": {
"uid": "x",
"slug": "x",
"title": "x",
"body": "x",
"topic": "rol",
"status": "draft",
"is_placeholder": False,
"user_uid": "x",
"published_at": None,
"created_at": now_iso(),
"updated_at": now_iso(),
"deleted_at": None,
"deleted_by": None,
},
"join_requests": {
"uid": "x",
"name": "x",
"contact": "x",
"repo_url": None,
"message": "",
"status": "open",
"ip_hash": None,
"link_title": None,
"link_checked_at": None,
"link_status": None,
"created_at": now_iso(),
"updated_at": now_iso(),
"deleted_at": None,
"deleted_by": None,
},
"media_items": {
"uid": "x",
"filename": "x.jpg",
"kind": "flyer",
"caption": "",
"position": 0,
"width": 0,
"height": 0,
"phash": None,
"thumb_directory": None,
"thumb_name": None,
"created_at": now_iso(),
"updated_at": now_iso(),
"deleted_at": None,
"deleted_by": None,
},
"notifications": {
"uid": "x",
"user_uid": "x",
"kind": "system.maintenance",
"message": "x",
"url": "/",
"read_at": None,
"created_at": now_iso(),
"deleted_at": None,
"deleted_by": None,
},
"site_settings": {"uid": "setting_x", "key": "x", "value": "x", "updated_at": now_iso()},
"cache_state": {"name": "settings", "version": 0, "updated_at": now_iso()},
"service_state": {
"name": "backup",
"status": "stopped",
"last_run": None,
"next_run": None,
"started_at": None,
"heartbeat": None,
"logs": "[]",
"metrics": "{}",
"updated_at": now_iso(),
},
"audit_log": {
"uid": "x",
"event_key": "auth.login",
"category": "auth",
"actor_kind": "user",
"actor_uid": None,
"origin": "web",
"result": "ok",
"ip": None,
"user_agent": None,
"message": None,
"payload": "{}",
"created_at": now_iso(),
},
"audit_links": {"audit_uid": "x", "target_type": "user", "target_uid": "x"},
"visit_stats": {"day": "2026-01-01", "path": "/", "status": 200, "hits": 0},
"backups": {
"uid": "x",
"target": "database",
"status": "pending",
"requested_by": "x",
"directory": None,
"stored_name": None,
"size_bytes": None,
"error": None,
"created_at": now_iso(),
"finished_at": None,
},
}
def _ensure_table(name, example):
table = db.get_table(name)
for column, value in example.items():
table.create_column_by_example(column, value)
return table
def _index(sql):
try:
with db:
db.query(sql)
except Exception as exc:
logger.warning("index ensure failed: %s (%s)", sql, exc)
def _seed_settings():
if "site_settings" not in db.tables:
return
for key, value in SETTINGS_DEFAULTS.items():
uid = f"default_{key}"
if db["site_settings"].find_one(uid=uid) is None:
db["site_settings"].insert({"uid": uid, "key": key, "value": value, "updated_at": now_iso()})
def _seed_notification_key():
path = config.NOTIFICATION_KEY_FILE
if not path.exists():
path.write_text(generate_uid(), encoding="utf-8")
path.chmod(0o600)
def _backfill_api_keys():
if "users" not in db.tables:
return
with db:
db.query("UPDATE users SET api_key = :key WHERE api_key IS NULL OR api_key = ''", key=generate_uid())
def init_db():
for name, example in TABLE_EXAMPLES.items():
_ensure_table(name, example)
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uid ON users(uid)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key)")
_index("CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at)")
_index("CREATE INDEX IF NOT EXISTS idx_users_trash ON users(deleted_at) WHERE deleted_at IS NOT NULL")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_uid ON sessions(uid)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)")
_index("CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_uid, created_at)")
_index("CREATE INDEX IF NOT EXISTS idx_sessions_trash ON sessions(deleted_at) WHERE deleted_at IS NOT NULL")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_uid ON posts(uid)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)")
_index(
"CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(topic, published_at) "
"WHERE deleted_at IS NULL AND status = 'published'"
)
_index("CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(user_uid, created_at)")
_index("CREATE INDEX IF NOT EXISTS idx_posts_trash ON posts(deleted_at) WHERE deleted_at IS NOT NULL")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_join_requests_uid ON join_requests(uid)")
_index("CREATE INDEX IF NOT EXISTS idx_join_requests_status ON join_requests(status, created_at)")
_index("CREATE INDEX IF NOT EXISTS idx_join_requests_trash ON join_requests(deleted_at) WHERE deleted_at IS NOT NULL")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_media_items_uid ON media_items(uid)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_media_items_filename ON media_items(filename)")
_index("CREATE INDEX IF NOT EXISTS idx_media_items_kind ON media_items(kind, position)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_notifications_uid ON notifications(uid)")
_index("CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_uid, created_at)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_site_settings_uid ON site_settings(uid)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_site_settings_key ON site_settings(key)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_cache_state_name ON cache_state(name)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_service_state_name ON service_state(name)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_uid ON audit_log(uid)")
_index("CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at)")
_index("CREATE INDEX IF NOT EXISTS idx_audit_links_audit ON audit_links(audit_uid)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_visit_stats_day_path ON visit_stats(day, path, status)")
_index("CREATE UNIQUE INDEX IF NOT EXISTS idx_backups_uid ON backups(uid)")
_seed_settings()
_seed_notification_key()
_backfill_api_keys()
with db:
has_stats = next(iter(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='sqlite_stat1'")), None)
if has_stats is None:
db.query("ANALYZE")
db.query("PRAGMA optimize")
+47
View File
@@ -0,0 +1,47 @@
# retoor <retoor@molodetz.nl>
from molodetz.database.core import db, generate_uid, now_iso
def insert_join_request(name, contact, repo_url, message, ip_hash):
row = {
"uid": generate_uid(),
"name": name,
"contact": contact,
"repo_url": repo_url or None,
"message": message or "",
"status": "open",
"ip_hash": ip_hash,
"link_title": None,
"link_checked_at": None,
"link_status": None,
"created_at": now_iso(),
"updated_at": now_iso(),
"deleted_at": None,
"deleted_by": None,
}
db["join_requests"].insert(row)
return row
def list_join_requests(limit=500):
if "join_requests" not in db.tables:
return []
return list(db["join_requests"].find(deleted_at=None, order_by=["-created_at"], _limit=limit))
def get_join_request(uid):
if "join_requests" not in db.tables:
return None
return db["join_requests"].find_one(uid=uid, deleted_at=None)
def update_join_request(uid, **fields):
fields["uid"] = uid
fields["updated_at"] = now_iso()
db["join_requests"].update(fields, ["uid"])
def count_open_join_requests():
if "join_requests" not in db.tables:
return 0
return db["join_requests"].count(deleted_at=None, status="open")
+45
View File
@@ -0,0 +1,45 @@
# retoor <retoor@molodetz.nl>
from molodetz.database.core import db, generate_uid, now_iso
def media_by_kind(kind):
if "media_items" not in db.tables:
return []
return list(db["media_items"].find(kind=kind, deleted_at=None, order_by=["position", "id"]))
def get_media_by_file(filename):
if "media_items" not in db.tables:
return None
return db["media_items"].find_one(filename=filename)
def upsert_media(filename, kind, caption, position, width, height, phash, thumb_directory, thumb_name):
existing = get_media_by_file(filename)
fields = {
"filename": filename,
"kind": kind,
"caption": caption,
"position": position,
"width": width,
"height": height,
"phash": phash,
"thumb_directory": thumb_directory,
"thumb_name": thumb_name,
"updated_at": now_iso(),
}
if existing:
fields["id"] = existing["id"]
db["media_items"].update(fields, ["id"])
return existing["uid"]
fields.update({"uid": generate_uid(), "created_at": now_iso(), "deleted_at": None, "deleted_by": None})
db["media_items"].insert(fields)
return fields["uid"]
def count_media(kind=None):
if "media_items" not in db.tables:
return 0
if kind:
return db["media_items"].count(kind=kind, deleted_at=None)
return db["media_items"].count(deleted_at=None)
+55
View File
@@ -0,0 +1,55 @@
# retoor <retoor@molodetz.nl>
from molodetz.cache import TTLCache
from molodetz import config
from molodetz.database.core import db, generate_uid, now_iso
_unread_cache = TTLCache(ttl=config.UNREAD_TTL, max_size=1000)
def insert_notification(user_uid, kind, message, url):
row = {
"uid": generate_uid(),
"user_uid": user_uid,
"kind": kind,
"message": message,
"url": url,
"read_at": None,
"created_at": now_iso(),
"deleted_at": None,
"deleted_by": None,
}
db["notifications"].insert(row)
_unread_cache.pop(user_uid)
return row
def list_notifications(user_uid, limit=100):
if "notifications" not in db.tables:
return []
return list(db["notifications"].find(user_uid=user_uid, deleted_at=None, order_by=["-created_at"], _limit=limit))
def unread_count(user_uid):
if not user_uid:
return 0
cached = _unread_cache.get(user_uid)
if cached is not None:
return cached
count = 0
if "notifications" in db.tables:
count = db["notifications"].count(user_uid=user_uid, deleted_at=None, read_at=None)
_unread_cache.set(user_uid, count)
return count
def mark_all_read(user_uid):
if "notifications" not in db.tables:
return
with db:
db.query(
"UPDATE notifications SET read_at = :stamp WHERE user_uid = :user_uid "
"AND read_at IS NULL AND deleted_at IS NULL",
stamp=now_iso(),
user_uid=user_uid,
)
_unread_cache.pop(user_uid)
+36
View File
@@ -0,0 +1,36 @@
# retoor <retoor@molodetz.nl>
import math
from molodetz.database.core import db
def paginate(table_name, before=None, order="desc", cursor_field="created_at", limit=25, **filters):
if table_name not in db.tables:
return [], None
table = db[table_name]
criteria = dict(filters)
if "deleted_at" not in criteria:
criteria["deleted_at"] = None
if before:
criteria[cursor_field] = {"<": before} if order == "desc" else {">": before}
order_by = [f"-{cursor_field}"] if order == "desc" else [cursor_field]
rows = list(table.find(_limit=limit + 1, order_by=order_by, **criteria))
next_cursor = None
if len(rows) > limit:
rows = rows[:limit]
next_cursor = rows[-1][cursor_field]
return rows, next_cursor
def build_pagination(page, total, per_page):
pages = max(1, math.ceil(total / per_page)) if per_page else 1
page = min(max(1, page), pages)
return {
"page": page,
"pages": pages,
"total": total,
"per_page": per_page,
"offset": (page - 1) * per_page,
"has_prev": page > 1,
"has_next": page < pages,
}
+74
View File
@@ -0,0 +1,74 @@
# retoor <retoor@molodetz.nl>
from molodetz.database.core import db, generate_uid, in_clause, make_combined_slug, now_iso, resolve_by_slug
from molodetz.database.pagination import paginate
def insert_post(user_uid, title, body, topic, status="draft", is_placeholder=False, created_at=None):
uid = generate_uid()
stamp = created_at or now_iso()
row = {
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"body": body,
"topic": topic,
"status": status,
"is_placeholder": bool(is_placeholder),
"user_uid": user_uid,
"published_at": stamp if status == "published" else None,
"created_at": stamp,
"updated_at": stamp,
"deleted_at": None,
"deleted_by": None,
}
db["posts"].insert(row)
return row
def update_post_row(uid, **fields):
fields["uid"] = uid
db["posts"].update(fields, ["uid"])
def get_post(uid):
if "posts" not in db.tables:
return None
return db["posts"].find_one(uid=uid, deleted_at=None)
def get_post_by_slug(slug):
return resolve_by_slug("posts", slug)
def published_posts(topic=None, before=None, limit=25, user_uid=None):
filters = {"status": "published"}
if topic:
filters["topic"] = topic
if user_uid:
filters["user_uid"] = user_uid
return paginate("posts", before=before, cursor_field="published_at", limit=limit, **filters)
def all_posts(limit=200):
if "posts" not in db.tables:
return []
return list(db["posts"].find(deleted_at=None, order_by=["-created_at"], _limit=limit))
def count_published():
if "posts" not in db.tables:
return 0
return db["posts"].count(deleted_at=None, status="published")
def post_counts_by_author(user_uids):
user_uids = [uid for uid in set(user_uids) if uid]
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 n FROM posts WHERE user_uid IN ({placeholders}) "
"AND deleted_at IS NULL AND status = 'published' GROUP BY user_uid",
**params,
)
return {row["user_uid"]: int(row["n"]) for row in rows}
+38
View File
@@ -0,0 +1,38 @@
# retoor <retoor@molodetz.nl>
import json
from molodetz.database.core import db, now_iso
def get_service_state(name):
if "service_state" not in db.tables:
return None
return db["service_state"].find_one(name=name)
def ensure_service_state(name):
row = get_service_state(name)
if row:
return row["id"]
return db["service_state"].insert(
{
"name": name,
"status": "stopped",
"last_run": None,
"next_run": None,
"started_at": None,
"heartbeat": None,
"logs": "[]",
"metrics": "{}",
"updated_at": now_iso(),
}
)
def write_service_state(row_id, **fields):
for key in ("logs", "metrics"):
if key in fields and not isinstance(fields[key], str):
fields[key] = json.dumps(fields[key])
fields["id"] = row_id
fields["updated_at"] = now_iso()
db["service_state"].update(fields, ["id"])
+66
View File
@@ -0,0 +1,66 @@
# retoor <retoor@molodetz.nl>
import secrets
from datetime import datetime, timedelta, timezone
from molodetz.database.core import db, generate_uid, now_iso
def create_session(user_uid, max_age_seconds):
token = secrets.token_hex(32)
expires = datetime.now(timezone.utc) + timedelta(seconds=max_age_seconds)
db["sessions"].insert(
{
"uid": generate_uid(),
"token": token,
"user_uid": user_uid,
"created_at": now_iso(),
"expires_at": expires.isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return token
def get_session(token):
if not token or "sessions" not in db.tables:
return None
return db["sessions"].find_one(token=token, deleted_at=None)
def hard_delete_session(session_id):
db["sessions"].delete(id=session_id)
def end_session(token, actor):
if "sessions" not in db.tables:
return
row = db["sessions"].find_one(token=token, deleted_at=None)
if row:
db["sessions"].update({"id": row["id"], "deleted_at": now_iso(), "deleted_by": actor}, ["id"])
def prune_expired_sessions(dry_run=False):
if "sessions" not in db.tables:
return 0
stamp = now_iso()
rows = list(db.query("SELECT id FROM sessions WHERE expires_at < :stamp", stamp=stamp))
if not dry_run and rows:
with db:
db.query("DELETE FROM sessions WHERE expires_at < :stamp", stamp=stamp)
return len(rows)
def end_all_sessions_for(user_uid, actor):
if "sessions" not in db.tables:
return 0
stamp = now_iso()
with db:
db.query(
"UPDATE sessions SET deleted_at = :stamp, deleted_by = :actor "
"WHERE user_uid = :user_uid AND deleted_at IS NULL",
stamp=stamp,
actor=actor,
user_uid=user_uid,
)
return 1
+89
View File
@@ -0,0 +1,89 @@
# retoor <retoor@molodetz.nl>
import logging
from molodetz.cache import TTLCache
from molodetz.constants import SETTINGS_DEFAULTS
from molodetz.database.core import db, now_iso
logger = logging.getLogger(__name__)
_settings_cache = TTLCache(ttl=60, max_size=100)
_version_cache = TTLCache(ttl=1, max_size=100)
_local_versions = {}
def read_cache_version(name):
cached = _version_cache.get(name)
if cached is not None:
return cached
try:
if "cache_state" not in db.tables:
return 0
row = db["cache_state"].find_one(name=name)
version = int(row["version"]) if row else 0
except Exception as exc:
logger.warning("cache version read failed for %s: %s", name, exc)
return 0
_version_cache.set(name, version)
return version
def sync_local_cache(name, cache):
version = read_cache_version(name)
if _local_versions.get(name) != version:
cache.clear()
_local_versions[name] = version
def bump_cache_version(name, cache=None):
try:
with db:
db.query(
"INSERT INTO cache_state (name, version, updated_at) VALUES (:name, 1, :stamp) "
"ON CONFLICT(name) DO UPDATE SET version = version + 1, updated_at = excluded.updated_at",
name=name,
stamp=now_iso(),
)
except Exception as exc:
logger.warning("cache version bump failed for %s: %s", name, exc)
_version_cache.pop(name)
if cache is not None:
cache.clear()
_local_versions.pop(name, None)
def get_setting(key, default=None):
if default is None:
default = SETTINGS_DEFAULTS.get(key, "")
sync_local_cache("settings", _settings_cache)
cached = _settings_cache.get(key)
if cached is not None:
return cached
value = default
if "site_settings" in db.tables:
row = db["site_settings"].find_one(uid=f"setting_{key}")
if row is None:
row = db["site_settings"].find_one(uid=f"default_{key}")
if row is not None and row.get("value") is not None:
value = str(row["value"])
_settings_cache.set(key, value)
return value
def get_int_setting(key, default):
try:
return int(get_setting(key, str(default)))
except (TypeError, ValueError):
return default
def set_setting(key, value):
db["site_settings"].upsert(
{"uid": f"setting_{key}", "key": key, "value": str(value), "updated_at": now_iso()},
["uid"],
)
bump_cache_version("settings", _settings_cache)
def all_settings():
return {key: get_setting(key, default) for key, default in SETTINGS_DEFAULTS.items()}
+104
View File
@@ -0,0 +1,104 @@
# retoor <retoor@molodetz.nl>
from molodetz.constants import SOFT_DELETE_TABLES
from molodetz.database.core import db, in_clause, now_iso
def _check(table_name):
if table_name not in SOFT_DELETE_TABLES:
raise ValueError(f"{table_name} is not soft-deletable")
def soft_delete(table_name, actor, stamp=None, **criteria):
_check(table_name)
if table_name not in db.tables:
return 0
stamp = stamp or now_iso()
table = db[table_name]
count = 0
for row in list(table.find(deleted_at=None, **criteria)):
table.update({"id": row["id"], "deleted_at": stamp, "deleted_by": actor}, ["id"])
count += 1
return count
def soft_delete_in(table_name, column, values, actor, stamp=None):
_check(table_name)
if not values or table_name not in db.tables:
return 0
stamp = stamp or now_iso()
placeholders, params = in_clause(list(values))
with db:
db.query(
f"UPDATE {table_name} SET deleted_at = :stamp, deleted_by = :actor "
f"WHERE {column} IN ({placeholders}) AND deleted_at IS NULL",
stamp=stamp,
actor=actor,
**params,
)
return len(values)
def restore(table_name, uid):
_check(table_name)
if table_name not in db.tables:
return False
row = db[table_name].find_one(uid=uid)
if not row or row.get("deleted_at") is None:
return False
db[table_name].update({"id": row["id"], "deleted_at": None, "deleted_by": None}, ["id"])
return True
def purge(table_name, uid):
_check(table_name)
if table_name not in db.tables:
return False
return bool(db[table_name].delete(uid=uid))
def list_deleted(table_name, limit=200):
_check(table_name)
if table_name not in db.tables:
return []
return list(
db.query(
f"SELECT * FROM {table_name} WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT :limit",
limit=limit,
)
)
def count_deleted(table_name):
_check(table_name)
if table_name not in db.tables:
return 0
row = next(iter(db.query(f"SELECT COUNT(*) AS n FROM {table_name} WHERE deleted_at IS NOT NULL")), None)
return int(row["n"]) if row else 0
def restore_event(stamp):
restored = []
tables = set(db.tables)
for table_name in SOFT_DELETE_TABLES:
if table_name not in tables:
continue
with db:
db.query(
f"UPDATE {table_name} SET deleted_at = NULL, deleted_by = NULL WHERE deleted_at = :stamp",
stamp=stamp,
)
restored.append(table_name)
return restored
def purge_event(stamp):
purged = []
tables = set(db.tables)
for table_name in SOFT_DELETE_TABLES:
if table_name not in tables:
continue
rows = list(db[table_name].find(deleted_at=stamp))
if rows:
db[table_name].delete(deleted_at=stamp)
purged.append((table_name, rows))
return purged
+58
View File
@@ -0,0 +1,58 @@
# retoor <retoor@molodetz.nl>
import ast
from pathlib import Path
import sqlglot
from sqlglot.errors import ParseError
from molodetz import config
SQL_PREFIXES = ("SELECT ", "INSERT ", "UPDATE ", "DELETE ", "CREATE ", "PRAGMA ", "WITH ", "ANALYZE")
def _joined_text(node):
parts = []
for value in node.values:
if isinstance(value, ast.Constant):
parts.append(str(value.value))
else:
parts.append("lint_placeholder")
return "".join(parts)
def _literal_sql(node):
if isinstance(node, ast.JoinedStr):
raw = _joined_text(node)
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
raw = node.value
else:
return None
text = " ".join(raw.split())
return text if text.upper().startswith(SQL_PREFIXES) else None
def collect_sql(root=None):
root = Path(root or config.PKG_ROOT)
found = []
for path in sorted(root.rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
inner = {id(value) for node in ast.walk(tree) if isinstance(node, ast.JoinedStr) for value in node.values}
for node in ast.walk(tree):
if id(node) in inner:
continue
sql = _literal_sql(node)
if sql:
found.append((path, node.lineno, sql))
return found
def lint(root=None):
problems = []
for path, line, sql in collect_sql(root):
if sql.upper().startswith(("PRAGMA", "ANALYZE")):
continue
try:
sqlglot.parse_one(sql, read="sqlite")
except ParseError as exc:
problems.append(f"{path}:{line}: {str(exc).splitlines()[0]}")
return problems
+120
View File
@@ -0,0 +1,120 @@
# retoor <retoor@molodetz.nl>
from molodetz.cache import TTLCache
from molodetz.constants import ROLE_ADMIN, ROLE_MEMBER
from molodetz.database.core import db, generate_uid, in_clause, now_iso
from molodetz.database.settings import bump_cache_version, get_setting, sync_local_cache
_admins_cache = TTLCache(ttl=300, max_size=4)
def users_table():
return db.get_table("users")
def get_user(uid):
if not uid or "users" not in db.tables:
return None
return db["users"].find_one(uid=uid, deleted_at=None)
def get_user_by_username(username):
if not username or "users" not in db.tables:
return None
return db["users"].find_one(username=username, deleted_at=None)
def get_user_by_email(email):
if not email or "users" not in db.tables:
return None
return db["users"].find_one(email=email.lower(), deleted_at=None)
def get_user_by_api_key(api_key):
if not api_key or "users" not in db.tables:
return None
return db["users"].find_one(api_key=api_key, deleted_at=None)
def users_by_uids(uids):
uids = [uid for uid in set(uids) if uid]
if not uids or "users" not in db.tables:
return {}
placeholders, params = in_clause(uids)
rows = db.query(
f"SELECT * FROM users WHERE uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
return {row["uid"]: dict(row) for row in rows}
def count_users():
if "users" not in db.tables:
return 0
return db["users"].count(deleted_at=None)
def create_user(username, email, password_hash, role=None, bio="", created_at=None):
role = role or (ROLE_ADMIN if count_users() == 0 else ROLE_MEMBER)
uid = generate_uid()
row = {
"uid": uid,
"username": username,
"email": (email or "").lower(),
"password_hash": password_hash,
"role": role,
"api_key": generate_uid(),
"bio": bio,
"avatar_seed": None,
"is_active": True,
"last_seen_at": None,
"suspended_until": None,
"suspend_reason": None,
"terms_version": get_setting("terms_version", "1"),
"created_at": created_at or now_iso(),
"updated_at": now_iso(),
"deleted_at": None,
"deleted_by": None,
}
db["users"].insert(row)
invalidate_admins_cache()
return row
def update_user(uid, **fields):
fields["uid"] = uid
fields["updated_at"] = now_iso()
db["users"].update(fields, ["uid"])
def set_role(uid, role):
update_user(uid, role=role)
invalidate_admins_cache()
def invalidate_admins_cache():
bump_cache_version("admins", _admins_cache)
def admin_uids_ordered():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("ordered")
if cached is not None:
return cached
if "users" not in db.tables:
return []
rows = db["users"].find(role=ROLE_ADMIN, deleted_at=None, order_by=["created_at", "id"])
ordered = [row["uid"] for row in rows]
_admins_cache.set("ordered", ordered)
return ordered
def list_users(limit=200):
if "users" not in db.tables:
return []
return list(db["users"].find(deleted_at=None, order_by=["created_at"], _limit=limit))
def public_people():
if "users" not in db.tables:
return []
return list(db["users"].find(deleted_at=None, is_active=True, order_by=["created_at"]))
+39
View File
@@ -0,0 +1,39 @@
# retoor <retoor@molodetz.nl>
from molodetz.database.core import db
def flush_visits(day, counts):
if not counts:
return
with db:
for (path, status), hits in counts.items():
db.query(
"INSERT INTO visit_stats (day, path, status, hits) VALUES (:day, :path, :status, :hits) "
"ON CONFLICT(day, path, status) DO UPDATE SET hits = hits + excluded.hits",
day=day,
path=path,
status=status,
hits=hits,
)
def top_paths(limit=25):
if "visit_stats" not in db.tables:
return []
return list(
db.query(
"SELECT path, SUM(hits) AS hits FROM visit_stats GROUP BY path ORDER BY hits DESC LIMIT :limit",
limit=limit,
)
)
def daily_totals(limit=30):
if "visit_stats" not in db.tables:
return []
return list(
db.query(
"SELECT day, SUM(hits) AS hits FROM visit_stats GROUP BY day ORDER BY day DESC LIMIT :limit",
limit=limit,
)
)