学生资料设置、头像与自动备份恢复。
首页卡片支持修改/删除;详情页设置 Tab 可维护学校、年级与头像;系统设置新增数据备份下载与恢复;备份默认存 /root/grade-archive-backups,详见 docs/BACKUP.md。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,9 @@ DATABASE_URL=postgresql://gradeapp:postgres@127.0.0.1:5432/student_archive
|
||||
SECRET_KEY=dev-secret-key-change-in-production
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:23566
|
||||
UPLOAD_DIR=uploads
|
||||
BACKUP_DIR=/root/grade-archive-backups
|
||||
BACKUP_RETENTION_DAYS=30
|
||||
AUTO_BACKUP_INTERVAL_HOURS=24
|
||||
API_PORT=23568
|
||||
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
OLLAMA_MODEL=qwen2.5:7b
|
||||
|
||||
@@ -8,6 +8,9 @@ class Settings(BaseSettings):
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
ALGORITHM: str = "HS256"
|
||||
UPLOAD_DIR: str = "uploads"
|
||||
BACKUP_DIR: str = "/root/grade-archive-backups"
|
||||
BACKUP_RETENTION_DAYS: int = 30
|
||||
AUTO_BACKUP_INTERVAL_HOURS: int = 24
|
||||
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024
|
||||
OLLAMA_BASE_URL: str = "http://127.0.0.1:11434"
|
||||
OLLAMA_MODEL: str = "qwen2.5:7b"
|
||||
|
||||
+20
-1
@@ -1,5 +1,8 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -8,7 +11,8 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base, SessionLocal, engine
|
||||
from app.routers import admin, auth, compositions, exams, export, settings as settings_router, students, subjects, wrong_questions
|
||||
from app.routers import admin, auth, backups, compositions, exams, export, settings as settings_router, students, subjects, wrong_questions
|
||||
from app.services import backup as backup_service
|
||||
from app.services import ocr as ocr_service
|
||||
from app.services.migrate import run_migrations
|
||||
from app.services.seed import seed_admin_and_settings, seed_subjects
|
||||
@@ -24,9 +28,21 @@ def resolve_frontend_dist() -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
def _auto_backup_loop() -> None:
|
||||
interval = max(settings.AUTO_BACKUP_INTERVAL_HOURS, 1) * 3600
|
||||
time.sleep(300)
|
||||
while True:
|
||||
try:
|
||||
backup_service.create_backup()
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception("自动备份失败")
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
||||
Path(settings.BACKUP_DIR).mkdir(parents=True, exist_ok=True)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
db = SessionLocal()
|
||||
@@ -36,6 +52,8 @@ async def lifespan(app: FastAPI):
|
||||
finally:
|
||||
db.close()
|
||||
ocr_service.warmup_ocr_engine()
|
||||
if settings.AUTO_BACKUP_INTERVAL_HOURS > 0:
|
||||
threading.Thread(target=_auto_backup_loop, daemon=True).start()
|
||||
yield
|
||||
|
||||
|
||||
@@ -53,6 +71,7 @@ app.add_middleware(
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(settings_router.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
app.include_router(backups.router, prefix="/api")
|
||||
app.include_router(students.router, prefix="/api")
|
||||
app.include_router(subjects.router, prefix="/api")
|
||||
app.include_router(exams.router, prefix="/api")
|
||||
|
||||
@@ -74,6 +74,8 @@ class Student(Base):
|
||||
)
|
||||
grade: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
class_name: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
school_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
avatar_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.core.deps import get_superuser
|
||||
from app.models.user import User
|
||||
from app.schemas import BackupInfoOut
|
||||
from app.services import backup as backup_service
|
||||
|
||||
router = APIRouter(prefix="/admin/backups", tags=["admin-backups"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[BackupInfoOut])
|
||||
def list_backups(_: User = Depends(get_superuser)):
|
||||
return backup_service.list_backups()
|
||||
|
||||
|
||||
@router.post("/run", response_model=BackupInfoOut)
|
||||
def run_backup(_: User = Depends(get_superuser)):
|
||||
try:
|
||||
path = backup_service.create_backup()
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"备份失败: {exc}",
|
||||
) from exc
|
||||
stat = path.stat()
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return BackupInfoOut(
|
||||
filename=path.name,
|
||||
size_bytes=stat.st_size,
|
||||
created_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{filename}/download")
|
||||
def download_backup(filename: str, _: User = Depends(get_superuser)):
|
||||
try:
|
||||
path = backup_service.resolve_backup_file(filename)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备份不存在") from exc
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/gzip",
|
||||
filename=filename,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_backup(file: UploadFile = File(...), _: User = Depends(get_superuser)):
|
||||
if not file.filename or not file.filename.endswith(".tar.gz"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .tar.gz 备份包")
|
||||
content = await file.read()
|
||||
if len(content) > 512 * 1024 * 1024:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="备份文件过大(最大 512MB)")
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="grade-archive-upload-"))
|
||||
archive = tmp / "restore.tar.gz"
|
||||
try:
|
||||
archive.write_bytes(content)
|
||||
backup_service.restore_backup(archive)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"恢复失败: {exc}",
|
||||
) from exc
|
||||
finally:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
return {"ok": True, "message": "数据已恢复,建议重启服务以确保缓存刷新"}
|
||||
@@ -1,28 +1,37 @@
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import Student, User
|
||||
from app.schemas import StudentCreate, StudentOut, StudentUpdate
|
||||
from app.services.student_access import get_student_for_user
|
||||
from app.services.student_avatar import delete_avatar_file, save_avatar
|
||||
|
||||
router = APIRouter(prefix="/students", tags=["students"])
|
||||
|
||||
|
||||
def _to_out(student: Student) -> StudentOut:
|
||||
return StudentOut.from_student(student)
|
||||
|
||||
|
||||
@router.get("", response_model=list[StudentOut])
|
||||
def list_students(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return (
|
||||
rows = (
|
||||
db.query(Student)
|
||||
.filter(Student.user_id == current_user.id)
|
||||
.order_by(Student.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [_to_out(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=StudentOut, status_code=status.HTTP_201_CREATED)
|
||||
@@ -35,7 +44,7 @@ def create_student(
|
||||
db.add(student)
|
||||
db.commit()
|
||||
db.refresh(student)
|
||||
return student
|
||||
return _to_out(student)
|
||||
|
||||
|
||||
@router.get("/{student_id}", response_model=StudentOut)
|
||||
@@ -44,7 +53,8 @@ def get_student(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return get_student_for_user(db, student_id, current_user.id)
|
||||
student = get_student_for_user(db, student_id, current_user.id)
|
||||
return _to_out(student)
|
||||
|
||||
|
||||
@router.patch("/{student_id}", response_model=StudentOut)
|
||||
@@ -59,7 +69,7 @@ def update_student(
|
||||
setattr(student, key, value)
|
||||
db.commit()
|
||||
db.refresh(student)
|
||||
return student
|
||||
return _to_out(student)
|
||||
|
||||
|
||||
@router.delete("/{student_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -69,5 +79,56 @@ def delete_student(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
student = get_student_for_user(db, student_id, current_user.id)
|
||||
delete_avatar_file(student.avatar_path)
|
||||
db.delete(student)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{student_id}/avatar", response_model=StudentOut)
|
||||
async def upload_avatar(
|
||||
student_id: uuid.UUID,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
student = get_student_for_user(db, student_id, current_user.id)
|
||||
content = await file.read()
|
||||
if len(content) > settings.MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件超过10MB限制")
|
||||
if not (file.content_type or "").startswith("image/"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传图片文件")
|
||||
|
||||
delete_avatar_file(student.avatar_path)
|
||||
student.avatar_path = save_avatar(str(current_user.id), str(student.id), content)
|
||||
db.commit()
|
||||
db.refresh(student)
|
||||
return _to_out(student)
|
||||
|
||||
|
||||
@router.delete("/{student_id}/avatar", response_model=StudentOut)
|
||||
def remove_avatar(
|
||||
student_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
student = get_student_for_user(db, student_id, current_user.id)
|
||||
delete_avatar_file(student.avatar_path)
|
||||
student.avatar_path = None
|
||||
db.commit()
|
||||
db.refresh(student)
|
||||
return _to_out(student)
|
||||
|
||||
|
||||
@router.get("/{student_id}/avatar")
|
||||
def get_avatar(
|
||||
student_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
student = get_student_for_user(db, student_id, current_user.id)
|
||||
if not student.avatar_path:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未设置头像")
|
||||
path = Path(settings.UPLOAD_DIR) / student.avatar_path
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="头像文件不存在")
|
||||
return FileResponse(path, media_type="image/jpeg")
|
||||
|
||||
@@ -136,6 +136,7 @@ class StudentCreate(BaseModel):
|
||||
school_level: SchoolLevelEnum = SchoolLevelEnum.junior_high
|
||||
grade: str | None = None
|
||||
class_name: str | None = None
|
||||
school_name: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class StudentUpdate(BaseModel):
|
||||
@@ -143,6 +144,7 @@ class StudentUpdate(BaseModel):
|
||||
school_level: SchoolLevelEnum | None = None
|
||||
grade: str | None = None
|
||||
class_name: str | None = None
|
||||
school_name: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class StudentOut(BaseModel):
|
||||
@@ -151,10 +153,25 @@ class StudentOut(BaseModel):
|
||||
school_level: SchoolLevelEnum
|
||||
grade: str | None
|
||||
class_name: str | None
|
||||
school_name: str | None
|
||||
has_avatar: bool = False
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@classmethod
|
||||
def from_student(cls, student) -> "StudentOut":
|
||||
return cls(
|
||||
id=student.id,
|
||||
name=student.name,
|
||||
school_level=SchoolLevelEnum(student.school_level.value),
|
||||
grade=student.grade,
|
||||
class_name=student.class_name,
|
||||
school_name=student.school_name,
|
||||
has_avatar=bool(getattr(student, "avatar_path", None)),
|
||||
created_at=student.created_at,
|
||||
)
|
||||
|
||||
|
||||
class SubjectOut(BaseModel):
|
||||
id: int
|
||||
@@ -335,3 +352,9 @@ class CompositionOut(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class BackupInfoOut(BaseModel):
|
||||
filename: str
|
||||
size_bytes: int
|
||||
created_at: datetime
|
||||
|
||||
@@ -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