学生资料设置、头像与自动备份恢复。
首页卡片支持修改/删除;详情页设置 Tab 可维护学校、年级与头像;系统设置新增数据备份下载与恢复;备份默认存 /root/grade-archive-backups,详见 docs/BACKUP.md。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BACKUP_NAME_PATTERN = re.compile(r"^grade-archive_\d{8}_\d{6}\.tar\.gz$")
|
||||
APP_VERSION = "1.0.0"
|
||||
|
||||
|
||||
def backup_dir() -> Path:
|
||||
path = Path(settings.BACKUP_DIR)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _pg_params() -> dict[str, str]:
|
||||
parsed = urlparse(settings.DATABASE_URL)
|
||||
return {
|
||||
"host": parsed.hostname or "127.0.0.1",
|
||||
"port": str(parsed.port or 5432),
|
||||
"user": parsed.username or "postgres",
|
||||
"password": parsed.password or "",
|
||||
"dbname": parsed.path.lstrip("/") or "student_archive",
|
||||
}
|
||||
|
||||
|
||||
def _dump_database(dest: Path) -> None:
|
||||
pg = _pg_params()
|
||||
env = {**os.environ, "PGPASSWORD": pg["password"]}
|
||||
cmd = [
|
||||
"pg_dump",
|
||||
"-h",
|
||||
pg["host"],
|
||||
"-p",
|
||||
pg["port"],
|
||||
"-U",
|
||||
pg["user"],
|
||||
"-d",
|
||||
pg["dbname"],
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
"--clean",
|
||||
"--if-exists",
|
||||
]
|
||||
with dest.open("w", encoding="utf-8") as out:
|
||||
subprocess.run(cmd, check=True, env=env, stdout=out)
|
||||
|
||||
|
||||
def create_backup() -> Path:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
archive_path = backup_dir() / f"grade-archive_{timestamp}.tar.gz"
|
||||
work = Path(tempfile.mkdtemp(prefix="grade-archive-backup-"))
|
||||
try:
|
||||
db_file = work / "database.sql"
|
||||
_dump_database(db_file)
|
||||
manifest = {
|
||||
"app": "secondary-school-grade-archive",
|
||||
"version": APP_VERSION,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"database": pg["dbname"] if (pg := _pg_params()) else "student_archive",
|
||||
}
|
||||
manifest_file = work / "manifest.json"
|
||||
manifest_file.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
uploads_dir = Path(settings.UPLOAD_DIR)
|
||||
with tarfile.open(archive_path, "w:gz") as tar:
|
||||
tar.add(db_file, arcname="database.sql")
|
||||
tar.add(manifest_file, arcname="manifest.json")
|
||||
if uploads_dir.is_dir():
|
||||
tar.add(uploads_dir, arcname="uploads")
|
||||
|
||||
cleanup_old_backups()
|
||||
logger.info("Backup created: %s", archive_path)
|
||||
return archive_path
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
def cleanup_old_backups() -> None:
|
||||
if settings.BACKUP_RETENTION_DAYS <= 0:
|
||||
return
|
||||
cutoff = datetime.now().timestamp() - settings.BACKUP_RETENTION_DAYS * 86400
|
||||
for item in backup_dir().glob("grade-archive_*.tar.gz"):
|
||||
if item.stat().st_mtime < cutoff:
|
||||
item.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def list_backups() -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for path in sorted(backup_dir().glob("grade-archive_*.tar.gz"), reverse=True):
|
||||
stat = path.stat()
|
||||
items.append(
|
||||
{
|
||||
"filename": path.name,
|
||||
"size_bytes": stat.st_size,
|
||||
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def resolve_backup_file(filename: str) -> Path:
|
||||
if not BACKUP_NAME_PATTERN.match(filename):
|
||||
raise ValueError("无效的备份文件名")
|
||||
path = backup_dir() / filename
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(filename)
|
||||
return path
|
||||
|
||||
|
||||
def restore_backup(archive_path: Path) -> None:
|
||||
work = Path(tempfile.mkdtemp(prefix="grade-archive-restore-"))
|
||||
try:
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
tar.extractall(work)
|
||||
|
||||
db_file = work / "database.sql"
|
||||
if not db_file.is_file():
|
||||
raise ValueError("备份包缺少 database.sql")
|
||||
|
||||
pg = _pg_params()
|
||||
env = {**os.environ, "PGPASSWORD": pg["password"]}
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"psql",
|
||||
"-h",
|
||||
pg["host"],
|
||||
"-p",
|
||||
pg["port"],
|
||||
"-U",
|
||||
pg["user"],
|
||||
"-d",
|
||||
pg["dbname"],
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-f",
|
||||
str(db_file),
|
||||
],
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
uploads_src = work / "uploads"
|
||||
uploads_dest = Path(settings.UPLOAD_DIR)
|
||||
if uploads_src.is_dir():
|
||||
if uploads_dest.exists():
|
||||
shutil.rmtree(uploads_dest)
|
||||
shutil.copytree(uploads_src, uploads_dest)
|
||||
else:
|
||||
uploads_dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info("Backup restored from %s", archive_path)
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
@@ -101,3 +101,14 @@ def run_migrations() -> None:
|
||||
"ALTER TABLE system_settings ADD COLUMN ai_review_enabled BOOLEAN NOT NULL DEFAULT TRUE"
|
||||
)
|
||||
)
|
||||
|
||||
student_columns = {col["name"] for col in inspector.get_columns("students")}
|
||||
student_alters: list[str] = []
|
||||
if "school_name" not in student_columns:
|
||||
student_alters.append("ADD COLUMN school_name VARCHAR(128)")
|
||||
if "avatar_path" not in student_columns:
|
||||
student_alters.append("ADD COLUMN avatar_path VARCHAR(512)")
|
||||
if student_alters:
|
||||
with engine.begin() as conn:
|
||||
for clause in student_alters:
|
||||
conn.execute(text(f"ALTER TABLE students {clause}"))
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def avatar_rel_path(user_id: str, student_id: str) -> str:
|
||||
return f"{user_id}/avatars/{student_id}.jpg"
|
||||
|
||||
|
||||
def save_avatar(user_id: str, student_id: str, content: bytes) -> str:
|
||||
rel = avatar_rel_path(user_id, student_id)
|
||||
full = Path(settings.UPLOAD_DIR) / rel
|
||||
full.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
image = Image.open(BytesIO(content))
|
||||
if image.mode not in ("RGB", "L"):
|
||||
image = image.convert("RGB")
|
||||
image.thumbnail((256, 256), Image.Resampling.LANCZOS)
|
||||
image.save(full, format="JPEG", quality=85, optimize=True)
|
||||
return rel
|
||||
|
||||
|
||||
def delete_avatar_file(avatar_path: str | None) -> None:
|
||||
if not avatar_path:
|
||||
return
|
||||
full = Path(settings.UPLOAD_DIR) / avatar_path
|
||||
if full.is_file():
|
||||
full.unlink()
|
||||
Reference in New Issue
Block a user