Molodetz blogrol volgens DPP-template
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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()}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]))
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user