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

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}"