Big changes

This commit is contained in:
Justin Visser 2026-02-21 17:33:05 +01:00
parent 4240ad38e2
commit e3f0d63fec
99 changed files with 8804 additions and 1731 deletions

View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
from __future__ import annotations
import ast
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SETTINGS_PATH = ROOT / "app" / "settings.py"
ENV_EXAMPLE_PATH = ROOT / ".env.example"
SETTINGS_ENV_HELPERS = {"_env_bool", "_env_int", "_env_float", "_env_str"}
ALLOWED_ENV_EXAMPLE_EXTRAS = {
"POSTGRES_DB",
"POSTGRES_USER",
"POSTGRES_PASSWORD",
"TEST_DATABASE_URL",
"SCHOLARR_IMAGE",
"APP_HOST",
"APP_PORT",
"APP_HOST_PORT",
"APP_RELOAD",
"FRONTEND_HOST_PORT",
"CHOKIDAR_USEPOLLING",
"VITE_DEV_API_PROXY_TARGET",
"MIGRATE_ON_START",
"BOOTSTRAP_ADMIN_ON_START",
"BOOTSTRAP_ADMIN_EMAIL",
"BOOTSTRAP_ADMIN_PASSWORD",
"BOOTSTRAP_ADMIN_FORCE_PASSWORD",
"DB_WAIT_TIMEOUT_SECONDS",
"DB_WAIT_INTERVAL_SECONDS",
}
def _call_name(node: ast.AST) -> str | None:
if not isinstance(node, ast.Call):
return None
func = node.func
if isinstance(func, ast.Name):
return func.id
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
return f"{func.value.id}.{func.attr}"
return None
def _first_str_arg(node: ast.Call) -> str | None:
if not node.args:
return None
first = node.args[0]
if isinstance(first, ast.Constant) and isinstance(first.value, str):
return first.value
return None
def _settings_env_names() -> set[str]:
tree = ast.parse(SETTINGS_PATH.read_text(encoding="utf-8"))
names: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
call_name = _call_name(node)
if call_name is None:
continue
if call_name != "os.getenv" and call_name not in SETTINGS_ENV_HELPERS:
continue
env_name = _first_str_arg(node)
if not env_name:
continue
names.add(env_name)
return names
def _env_example_names() -> tuple[set[str], set[str]]:
names: set[str] = set()
duplicates: set[str] = set()
for raw_line in ENV_EXAMPLE_PATH.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key = line.split("=", 1)[0].strip()
if not key:
continue
if key in names:
duplicates.add(key)
names.add(key)
return names, duplicates
def main() -> int:
settings_names = _settings_env_names()
env_names, duplicate_names = _env_example_names()
missing = sorted(settings_names - env_names)
unknown = sorted(env_names - settings_names - ALLOWED_ENV_EXAMPLE_EXTRAS)
duplicates = sorted(duplicate_names)
if missing or unknown or duplicates:
print("Environment contract check failed.")
if missing:
print("Missing from .env.example (referenced in app/settings.py):")
for name in missing:
print(f"- {name}")
if unknown:
print("Unknown keys in .env.example (not in app/settings.py or allowlist):")
for name in unknown:
print(f"- {name}")
if duplicates:
print("Duplicate keys in .env.example:")
for name in duplicates:
print(f"- {name}")
return 1
print(
"Environment contract check passed: "
f"{len(settings_names)} settings keys + {len(ALLOWED_ENV_EXAMPLE_EXTRAS)} allowed extras."
)
return 0
if __name__ == "__main__":
sys.exit(main())

63
scripts/db/backup_full.sh Executable file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
project_root="$(cd "${script_dir}/../.." && pwd)"
usage() {
cat <<'USAGE'
Usage: scripts/db/backup_full.sh [--plain]
Creates a PostgreSQL logical backup from the running `db` compose service.
Options:
--plain Write a plain SQL dump instead of custom-format dump.
-h, --help Show this help.
Environment:
BACKUP_DIR Destination directory (default: <repo>/backups)
BACKUP_PREFIX File prefix (default: scholarr)
USE_DEV_COMPOSE Set to 1 to include docker-compose.dev.yml
USAGE
}
format="custom"
while (($#)); do
case "$1" in
--plain)
format="plain"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
compose_cmd=(docker compose -f "${project_root}/docker-compose.yml")
if [[ "${USE_DEV_COMPOSE:-0}" == "1" && -f "${project_root}/docker-compose.dev.yml" ]]; then
compose_cmd+=(-f "${project_root}/docker-compose.dev.yml")
fi
backup_dir="${BACKUP_DIR:-${project_root}/backups}"
backup_prefix="${BACKUP_PREFIX:-scholarr}"
mkdir -p "${backup_dir}"
timestamp="$(date -u +"%Y%m%dT%H%M%SZ")"
if [[ "${format}" == "plain" ]]; then
backup_file="${backup_dir}/${backup_prefix}_${timestamp}.sql"
dump_cmd='pg_dump --no-owner --no-acl -U "$POSTGRES_USER" "$POSTGRES_DB"'
else
backup_file="${backup_dir}/${backup_prefix}_${timestamp}.dump"
dump_cmd='pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" "$POSTGRES_DB"'
fi
"${compose_cmd[@]}" exec -T db sh -lc "${dump_cmd}" > "${backup_file}"
echo "Backup created: ${backup_file}"

50
scripts/db/check_integrity.py Executable file
View file

@ -0,0 +1,50 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import asyncio
import json
from app.db.session import get_session_factory
from app.services.domains.dbops import collect_integrity_report
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run database integrity checks.")
parser.add_argument(
"--strict-warnings",
action="store_true",
help="Return non-zero exit code if any warning is present.",
)
return parser
async def _run() -> dict:
session_factory = get_session_factory()
async with session_factory() as db_session:
return await collect_integrity_report(db_session)
def _exit_code(report: dict, *, strict_warnings: bool) -> int:
if report.get("status") == "failed":
return 1
if strict_warnings and report.get("warnings"):
return 2
return 0
def main() -> int:
args = build_parser().parse_args()
try:
report = asyncio.run(_run())
except Exception as exc:
print(json.dumps({"status": "failed", "error": str(exc)}, indent=2))
return 1
print(json.dumps(report, indent=2))
return _exit_code(report, strict_warnings=args.strict_warnings)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import asyncio
import json
from app.db.session import get_session_factory
from app.services.domains.dbops import run_publication_link_repair
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Repair scholar-publication links for a user with audit logging.",
)
parser.add_argument("--user-id", type=int, required=True, help="Target user ID.")
parser.add_argument(
"--scholar-profile-id",
type=int,
action="append",
default=[],
help="Optional scholar_profile_id filter. Repeat for multiple values.",
)
parser.add_argument("--apply", action="store_true", help="Apply changes. Default is dry-run.")
parser.add_argument(
"--gc-orphan-publications",
action="store_true",
help="Delete publications with zero links after cleanup.",
)
parser.add_argument(
"--requested-by",
default="",
help="Operator identifier for audit logs (email/name/ticket).",
)
return parser
async def _run(args: argparse.Namespace) -> dict:
session_factory = get_session_factory()
async with session_factory() as db_session:
return await run_publication_link_repair(
db_session,
user_id=args.user_id,
scholar_profile_ids=args.scholar_profile_id,
dry_run=not args.apply,
gc_orphan_publications=args.gc_orphan_publications,
requested_by=args.requested_by,
)
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
result = asyncio.run(_run(args))
except Exception as exc:
print(json.dumps({"status": "failed", "error": str(exc)}, indent=2))
return 1
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

79
scripts/db/restore_dump.sh Executable file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
project_root="$(cd "${script_dir}/../.." && pwd)"
usage() {
cat <<'USAGE'
Usage: scripts/db/restore_dump.sh --file <path> [--wipe-public]
Restores a PostgreSQL dump into the running `db` compose service.
Options:
--file <path> Required. Backup file (.dump custom format or .sql plain).
--wipe-public Drop and recreate public schema before restore.
-h, --help Show this help.
Environment:
USE_DEV_COMPOSE Set to 1 to include docker-compose.dev.yml
USAGE
}
dump_file=""
wipe_public="0"
while (($#)); do
case "$1" in
--file)
dump_file="${2:-}"
shift 2
;;
--wipe-public)
wipe_public="1"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "${dump_file}" ]]; then
echo "--file is required" >&2
usage >&2
exit 2
fi
if [[ ! -f "${dump_file}" ]]; then
echo "Dump file not found: ${dump_file}" >&2
exit 2
fi
compose_cmd=(docker compose -f "${project_root}/docker-compose.yml")
if [[ "${USE_DEV_COMPOSE:-0}" == "1" && -f "${project_root}/docker-compose.dev.yml" ]]; then
compose_cmd+=(-f "${project_root}/docker-compose.dev.yml")
fi
if [[ "${wipe_public}" == "1" ]]; then
"${compose_cmd[@]}" exec -T db sh -lc 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" "$POSTGRES_DB" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"'
fi
case "${dump_file}" in
*.dump)
cat "${dump_file}" | "${compose_cmd[@]}" exec -T db sh -lc 'pg_restore --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB" -'
;;
*.sql)
cat "${dump_file}" | "${compose_cmd[@]}" exec -T db sh -lc 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" "$POSTGRES_DB"'
;;
*)
echo "Unsupported file extension. Expected .dump or .sql" >&2
exit 2
;;
esac
echo "Restore completed: ${dump_file}"