feat: add certificate management module with image upload
- Add Certificate + CertificateCategory models with full CRUD API - Image upload via base64 data URL stored in Text column - Certificate fields: title, issuer, issue_date, expiry_date, image, description - Frontend: card grid with category sidebar filter, create/edit dialog - Include certificates in data backup/export - Fix hasPhaseParent optimization in GoalDetailPage Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ from app.models.habit import HabitGroup, Habit, HabitCheckin
|
||||
from app.models.anniversary import AnniversaryCategory, Anniversary
|
||||
from app.models.goal import Goal, GoalStep, GoalReview, goal_tasks
|
||||
from app.models.sync_settings import SyncSettings
|
||||
from app.models.certificate import Certificate, CertificateCategory
|
||||
|
||||
__all__ = [
|
||||
"Task", "Category", "Tag", "task_tags", "UserSettings",
|
||||
@@ -13,4 +14,5 @@ __all__ = [
|
||||
"AnniversaryCategory", "Anniversary",
|
||||
"Goal", "GoalStep", "GoalReview", "goal_tasks",
|
||||
"SyncSettings",
|
||||
"Certificate", "CertificateCategory",
|
||||
]
|
||||
|
||||
43
api/app/models/certificate.py
Normal file
43
api/app/models/certificate.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import uuid as _uuid
|
||||
from sqlalchemy import Column, Integer, String, Text, Date, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
from app.utils.datetime import utcnow
|
||||
|
||||
|
||||
class CertificateCategory(Base):
|
||||
"""证书分类模型"""
|
||||
__tablename__ = "certificate_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
name = Column(String(50), nullable=False)
|
||||
icon = Column(String(50), default="medal")
|
||||
color = Column(String(20), default="#FFB7C5")
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
sync_version = Column(Integer, default=1)
|
||||
|
||||
certificates = relationship("Certificate", back_populates="category")
|
||||
|
||||
|
||||
class Certificate(Base):
|
||||
"""证书模型"""
|
||||
__tablename__ = "certificates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
category_id = Column(Integer, ForeignKey("certificate_categories.id"), nullable=True)
|
||||
image = Column(Text, nullable=True) # base64 data URL
|
||||
issuer = Column(String(200), nullable=True) # 来源/颁发机构
|
||||
issue_date = Column(Date, nullable=True)
|
||||
expiry_date = Column(Date, nullable=True) # null = 永久有效
|
||||
description = Column(Text, nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
sync_version = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
category = relationship("CertificateCategory", back_populates="certificates")
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals, sync, backup
|
||||
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals, sync, backup, certificates
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -13,3 +13,4 @@ api_router.include_router(anniversaries.router)
|
||||
api_router.include_router(goals.router)
|
||||
api_router.include_router(sync.router)
|
||||
api_router.include_router(backup.router)
|
||||
api_router.include_router(certificates.router)
|
||||
|
||||
@@ -16,8 +16,8 @@ router = APIRouter(prefix="/api/backup", tags=["备份"])
|
||||
# 导出顺序:按依赖关系(无 FK 的先导出)
|
||||
EXPORT_TABLES = [
|
||||
"categories", "tags", "user_settings", "sync_settings",
|
||||
"habit_groups", "anniversary_categories",
|
||||
"goals", "tasks", "habits", "anniversaries",
|
||||
"habit_groups", "anniversary_categories", "certificate_categories",
|
||||
"goals", "tasks", "habits", "anniversaries", "certificates",
|
||||
"goal_steps", "goal_reviews", "habit_checkins",
|
||||
"task_tags", "goal_tasks",
|
||||
]
|
||||
@@ -27,17 +27,17 @@ TRUNCATE_ORDER = [
|
||||
"task_tags", "goal_tasks",
|
||||
"habit_checkins",
|
||||
"goal_reviews", "goal_steps",
|
||||
"tasks", "habits", "anniversaries",
|
||||
"tasks", "habits", "anniversaries", "certificates",
|
||||
"goals", "categories", "tags",
|
||||
"habit_groups", "anniversary_categories",
|
||||
"habit_groups", "anniversary_categories", "certificate_categories",
|
||||
"user_settings", "sync_settings",
|
||||
]
|
||||
|
||||
# 导入时的插入顺序:父表先插
|
||||
INSERT_ORDER = [
|
||||
"categories", "tags", "user_settings", "sync_settings",
|
||||
"habit_groups", "anniversary_categories",
|
||||
"goals", "tasks", "habits", "anniversaries",
|
||||
"habit_groups", "anniversary_categories", "certificate_categories",
|
||||
"goals", "tasks", "habits", "anniversaries", "certificates",
|
||||
"goal_steps", "goal_reviews", "habit_checkins",
|
||||
"task_tags", "goal_tasks",
|
||||
]
|
||||
|
||||
167
api/app/routers/certificates.py
Normal file
167
api/app/routers/certificates.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""证书路由"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.certificate import Certificate, CertificateCategory
|
||||
from app.schemas.certificate import (
|
||||
CertificateCreate, CertificateUpdate,
|
||||
CertificateListResponse, CertificateDetailResponse,
|
||||
CertificateCategoryCreate, CertificateCategoryUpdate, CertificateCategoryResponse,
|
||||
)
|
||||
from app.schemas.common import DeleteResponse
|
||||
from app.utils.crud import get_or_404
|
||||
from app.utils.datetime import utcnow
|
||||
from app.utils.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["证书"])
|
||||
|
||||
|
||||
# ============ 证书分类 API ============
|
||||
|
||||
@router.get("/certificate-categories", response_model=List[CertificateCategoryResponse])
|
||||
def get_categories(db: Session = Depends(get_db)):
|
||||
try:
|
||||
categories = db.query(CertificateCategory).order_by(
|
||||
CertificateCategory.sort_order.asc(),
|
||||
CertificateCategory.id.asc()
|
||||
).all()
|
||||
return categories
|
||||
except Exception as e:
|
||||
logger.error(f"获取证书分类列表失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="获取证书分类列表失败")
|
||||
|
||||
|
||||
@router.post("/certificate-categories", response_model=CertificateCategoryResponse, status_code=201)
|
||||
def create_category(data: CertificateCategoryCreate, db: Session = Depends(get_db)):
|
||||
try:
|
||||
cat = CertificateCategory(**data.model_dump())
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
logger.info(f"创建证书分类成功: id={cat.id}, name={cat.name}")
|
||||
return cat
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"创建证书分类失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="创建证书分类失败")
|
||||
|
||||
|
||||
@router.put("/certificate-categories/{category_id}", response_model=CertificateCategoryResponse)
|
||||
def update_category(category_id: int, data: CertificateCategoryUpdate, db: Session = Depends(get_db)):
|
||||
try:
|
||||
cat = get_or_404(db, CertificateCategory, category_id, "证书分类")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(cat, field, value)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
logger.info(f"更新证书分类成功: id={category_id}")
|
||||
return cat
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"更新证书分类失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="更新证书分类失败")
|
||||
|
||||
|
||||
@router.delete("/certificate-categories/{category_id}")
|
||||
def delete_category(category_id: int, db: Session = Depends(get_db)):
|
||||
try:
|
||||
cat = get_or_404(db, CertificateCategory, category_id, "证书分类")
|
||||
certs = db.query(Certificate).filter(Certificate.category_id == category_id).all()
|
||||
for c in certs:
|
||||
c.category_id = None
|
||||
db.delete(cat)
|
||||
db.commit()
|
||||
logger.info(f"删除证书分类成功: id={category_id}")
|
||||
return DeleteResponse(message="证书分类删除成功")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"删除证书分类失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="删除证书分类失败")
|
||||
|
||||
|
||||
# ============ 证书 API ============
|
||||
|
||||
@router.get("/certificates", response_model=List[CertificateListResponse])
|
||||
def get_certificates(
|
||||
category_id: Optional[int] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
query = db.query(Certificate)
|
||||
if category_id is not None:
|
||||
query = query.filter(Certificate.category_id == category_id)
|
||||
certs = query.order_by(Certificate.sort_order.asc(), Certificate.created_at.desc()).all()
|
||||
return certs
|
||||
except Exception as e:
|
||||
logger.error(f"获取证书列表失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="获取证书列表失败")
|
||||
|
||||
|
||||
@router.post("/certificates", response_model=CertificateDetailResponse, status_code=201)
|
||||
def create_certificate(data: CertificateCreate, db: Session = Depends(get_db)):
|
||||
try:
|
||||
cert = Certificate(**data.model_dump())
|
||||
db.add(cert)
|
||||
db.commit()
|
||||
db.refresh(cert)
|
||||
logger.info(f"创建证书成功: id={cert.id}, title={cert.title}")
|
||||
return cert
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"创建证书失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="创建证书失败")
|
||||
|
||||
|
||||
@router.get("/certificates/{cert_id}", response_model=CertificateDetailResponse)
|
||||
def get_certificate(cert_id: int, db: Session = Depends(get_db)):
|
||||
try:
|
||||
return get_or_404(db, Certificate, cert_id, "证书")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取证书失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="获取证书失败")
|
||||
|
||||
|
||||
@router.put("/certificates/{cert_id}", response_model=CertificateDetailResponse)
|
||||
def update_certificate(cert_id: int, data: CertificateUpdate, db: Session = Depends(get_db)):
|
||||
try:
|
||||
cert = get_or_404(db, Certificate, cert_id, "证书")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if value is not None or field in data.clearable_fields:
|
||||
setattr(cert, field, value)
|
||||
cert.updated_at = utcnow()
|
||||
db.commit()
|
||||
db.refresh(cert)
|
||||
logger.info(f"更新证书成功: id={cert_id}")
|
||||
return cert
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"更新证书失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="更新证书失败")
|
||||
|
||||
|
||||
@router.delete("/certificates/{cert_id}")
|
||||
def delete_certificate(cert_id: int, db: Session = Depends(get_db)):
|
||||
try:
|
||||
cert = get_or_404(db, Certificate, cert_id, "证书")
|
||||
db.delete(cert)
|
||||
db.commit()
|
||||
logger.info(f"删除证书成功: id={cert_id}")
|
||||
return DeleteResponse(message="证书删除成功")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"删除证书失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="删除证书失败")
|
||||
79
api/app/schemas/certificate.py
Normal file
79
api/app/schemas/certificate.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""证书 Schema"""
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from datetime import date, datetime
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
# ============ 证书分类 Schema ============
|
||||
|
||||
class CertificateCategoryBase(BaseModel):
|
||||
name: str = Field(..., max_length=50)
|
||||
icon: str = Field(default="medal", max_length=50)
|
||||
color: str = Field(default="#FFB7C5", max_length=20)
|
||||
sort_order: int = Field(default=0)
|
||||
|
||||
|
||||
class CertificateCategoryCreate(CertificateCategoryBase):
|
||||
pass
|
||||
|
||||
|
||||
class CertificateCategoryUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, max_length=50)
|
||||
icon: Optional[str] = Field(None, max_length=50)
|
||||
color: Optional[str] = Field(None, max_length=20)
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class CertificateCategoryResponse(CertificateCategoryBase):
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ 证书 Schema ============
|
||||
|
||||
class CertificateBase(BaseModel):
|
||||
title: str = Field(..., max_length=200)
|
||||
category_id: Optional[int] = None
|
||||
image: Optional[str] = None
|
||||
issuer: Optional[str] = Field(None, max_length=200)
|
||||
issue_date: Optional[date] = None
|
||||
expiry_date: Optional[date] = None
|
||||
description: Optional[str] = None
|
||||
sort_order: int = Field(default=0)
|
||||
|
||||
|
||||
class CertificateCreate(CertificateBase):
|
||||
pass
|
||||
|
||||
|
||||
class CertificateUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, max_length=200)
|
||||
category_id: Optional[int] = None
|
||||
image: Optional[str] = None
|
||||
issuer: Optional[str] = Field(None, max_length=200)
|
||||
issue_date: Optional[date] = None
|
||||
expiry_date: Optional[date] = None
|
||||
description: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
@property
|
||||
def clearable_fields(self) -> set:
|
||||
return {"description", "category_id", "image", "issuer", "issue_date", "expiry_date"}
|
||||
|
||||
|
||||
class CertificateListResponse(CertificateBase):
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
category: Optional[CertificateCategoryResponse] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CertificateDetailResponse(CertificateListResponse):
|
||||
pass
|
||||
Reference in New Issue
Block a user