feat: 统一生产初始化流程并保护系统管理员
This commit is contained in:
@@ -9,6 +9,9 @@ WORKDIR /code
|
||||
COPY requirements.txt /code/requirements.txt
|
||||
RUN pip install --upgrade pip && pip install -r /code/requirements.txt
|
||||
|
||||
COPY alembic.ini /code/alembic.ini
|
||||
COPY alembic /code/alembic
|
||||
COPY app /code/app
|
||||
COPY scripts /code/scripts
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -51,6 +51,16 @@ async def update_user(
|
||||
db_user = await user_crud.get_by_id(db, user_id)
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
if user_crud.is_protected_admin_user(db_user):
|
||||
if user_in.email is not None and user_in.email != db_user.email:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员邮箱不允许修改")
|
||||
if user_in.role is not None and user_in.role != "ADMIN":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员角色不允许修改")
|
||||
requested_status = user_in.status
|
||||
if user_in.is_active is not None:
|
||||
requested_status = "ACTIVE" if user_in.is_active else "DISABLED"
|
||||
if requested_status is not None and requested_status != "ACTIVE":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员不允许停用")
|
||||
if db_user.role.value == "ADMIN":
|
||||
requested_role = user_in.role
|
||||
requested_status = user_in.status
|
||||
@@ -75,6 +85,8 @@ async def delete_user(
|
||||
db_user = await user_crud.get_by_id(db, user_id)
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
if user_crud.is_protected_admin_user(db_user):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员账号不允许删除")
|
||||
if db_user.id == current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许删除自己")
|
||||
if db_user.role.value == "ADMIN" and db_user.status.value == "ACTIVE":
|
||||
|
||||
@@ -4,6 +4,11 @@ from typing import Literal
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
PROTECTED_ADMIN_EMAIL = "admin@huapont.cn"
|
||||
PROTECTED_ADMIN_DEFAULT_PASSWORD = "admin123"
|
||||
PROTECTED_ADMIN_FULL_NAME = "System Admin"
|
||||
PROTECTED_ADMIN_DEPARTMENT = "SYSTEM"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
@@ -107,6 +107,7 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
|
||||
from app.models.finance_contract import FinanceContract
|
||||
from app.models.finance_special import FinanceSpecial
|
||||
from app.models.contract_fee import ContractFee
|
||||
from app.models.contract_fee_payment import ContractFeePayment
|
||||
from app.models.special_expense import SpecialExpense
|
||||
from app.models.milestone import Milestone
|
||||
from app.models.document import Document
|
||||
@@ -157,6 +158,13 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
|
||||
|
||||
# 9. 删除财务相关
|
||||
await db.execute(sa_delete(SpecialExpense).where(SpecialExpense.project_id == study_id))
|
||||
await db.execute(
|
||||
sa_delete(ContractFeePayment).where(
|
||||
ContractFeePayment.contract_fee_id.in_(
|
||||
select(ContractFee.id).where(ContractFee.project_id == study_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
await db.execute(sa_delete(ContractFee).where(ContractFee.project_id == study_id))
|
||||
await db.execute(sa_delete(FinanceSpecial).where(FinanceSpecial.study_id == study_id))
|
||||
await db.execute(sa_delete(FinanceContract).where(FinanceContract.study_id == study_id))
|
||||
|
||||
@@ -4,6 +4,12 @@ from typing import Sequence
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import (
|
||||
PROTECTED_ADMIN_DEFAULT_PASSWORD,
|
||||
PROTECTED_ADMIN_DEPARTMENT,
|
||||
PROTECTED_ADMIN_EMAIL,
|
||||
PROTECTED_ADMIN_FULL_NAME,
|
||||
)
|
||||
from app.core.security import hash_password
|
||||
from app.models.study_member import StudyMember
|
||||
from app.models.user import User, UserRole, UserStatus
|
||||
@@ -15,6 +21,14 @@ async def get_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def is_protected_admin_email(email: str | None) -> bool:
|
||||
return (email or "").strip().lower() == PROTECTED_ADMIN_EMAIL
|
||||
|
||||
|
||||
def is_protected_admin_user(user: User | None) -> bool:
|
||||
return user is not None and is_protected_admin_email(user.email)
|
||||
|
||||
|
||||
async def get_by_id(db: AsyncSession, user_id: uuid.UUID) -> User | None:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
@@ -90,17 +104,17 @@ async def list_users_by_status(
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def ensure_admin_exists(db: AsyncSession, *, default_password: str = "admin123") -> None:
|
||||
result = await db.execute(select(User).where(User.role == UserRole.ADMIN))
|
||||
async def ensure_admin_exists(db: AsyncSession, *, default_password: str = PROTECTED_ADMIN_DEFAULT_PASSWORD) -> None:
|
||||
result = await db.execute(select(User).where(User.email == PROTECTED_ADMIN_EMAIL))
|
||||
admin = result.scalar_one_or_none()
|
||||
if admin:
|
||||
return
|
||||
new_admin = User(
|
||||
email="admin@example.com",
|
||||
email=PROTECTED_ADMIN_EMAIL,
|
||||
password_hash=hash_password(default_password),
|
||||
full_name="System Admin",
|
||||
full_name=PROTECTED_ADMIN_FULL_NAME,
|
||||
role=UserRole.ADMIN,
|
||||
department="SYSTEM",
|
||||
department=PROTECTED_ADMIN_DEPARTMENT,
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
db.add(new_admin)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from app.core.config import PROTECTED_ADMIN_EMAIL # noqa: E402
|
||||
from app.crud.user import ensure_admin_exists # noqa: E402
|
||||
from app.db.session import SessionLocal # noqa: E402
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||
check=True,
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
|
||||
|
||||
async def seed_protected_admin() -> None:
|
||||
async with SessionLocal() as session:
|
||||
await ensure_admin_exists(session)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Running Alembic migrations...")
|
||||
run_migrations()
|
||||
print(f"Ensuring protected admin exists: {PROTECTED_ADMIN_EMAIL}")
|
||||
asyncio.run(seed_protected_admin())
|
||||
print("Production initialization complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
import pytest
|
||||
|
||||
from app.crud import user as user_crud
|
||||
from app.models.user import UserRole, UserStatus
|
||||
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, existing_admin=None):
|
||||
self.existing_admin = existing_admin
|
||||
self.added = []
|
||||
self.commit_count = 0
|
||||
|
||||
async def execute(self, _stmt):
|
||||
return _ScalarResult(self.existing_admin)
|
||||
|
||||
def add(self, obj):
|
||||
self.added.append(obj)
|
||||
|
||||
async def commit(self):
|
||||
self.commit_count += 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_admin_exists_creates_protected_admin():
|
||||
session = _FakeSession()
|
||||
|
||||
await user_crud.ensure_admin_exists(session)
|
||||
|
||||
assert len(session.added) == 1
|
||||
admin = session.added[0]
|
||||
assert admin.email == "admin@huapont.cn"
|
||||
assert admin.role == UserRole.ADMIN
|
||||
assert admin.status == UserStatus.ACTIVE
|
||||
assert session.commit_count == 1
|
||||
@@ -0,0 +1,107 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1 import users as users_api
|
||||
from app.models.user import User, UserRole, UserStatus
|
||||
from app.schemas.user import UserRead, UserUpdate
|
||||
|
||||
|
||||
def _make_user(
|
||||
*,
|
||||
email: str,
|
||||
role: UserRole = UserRole.ADMIN,
|
||||
status: UserStatus = UserStatus.ACTIVE,
|
||||
) -> User:
|
||||
return User(
|
||||
id=uuid.uuid4(),
|
||||
email=email,
|
||||
password_hash="hashed",
|
||||
full_name="User",
|
||||
department="SYSTEM",
|
||||
role=role,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user_blocks_protected_admin(monkeypatch):
|
||||
current_user = _make_user(email="other-admin@huapont.cn")
|
||||
protected_admin = _make_user(email="admin@huapont.cn")
|
||||
|
||||
async def fake_get_by_id(_db, _user_id):
|
||||
return protected_admin
|
||||
|
||||
async def fake_count_active_admins(_db):
|
||||
return 2
|
||||
|
||||
async def fake_user_has_memberships(_db, _user_id):
|
||||
return False
|
||||
|
||||
async def fake_delete_user(_db, _user):
|
||||
raise AssertionError("protected admin should not be deleted")
|
||||
|
||||
monkeypatch.setattr(users_api.user_crud, "get_by_id", fake_get_by_id)
|
||||
monkeypatch.setattr(users_api.user_crud, "count_active_admins", fake_count_active_admins)
|
||||
monkeypatch.setattr(users_api.member_crud, "user_has_memberships", fake_user_has_memberships)
|
||||
monkeypatch.setattr(users_api.user_crud, "delete_user", fake_delete_user)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await users_api.delete_user(protected_admin.id, db=object(), current_user=current_user)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_blocks_protected_admin_email_change(monkeypatch):
|
||||
protected_admin = _make_user(email="admin@huapont.cn")
|
||||
|
||||
async def fake_get_by_id(_db, _user_id):
|
||||
return protected_admin
|
||||
|
||||
async def fake_count_active_admins(_db):
|
||||
return 2
|
||||
|
||||
async def fake_update_user(_db, _user, _payload):
|
||||
raise AssertionError("protected admin email should not be changed")
|
||||
|
||||
monkeypatch.setattr(users_api.user_crud, "get_by_id", fake_get_by_id)
|
||||
monkeypatch.setattr(users_api.user_crud, "count_active_admins", fake_count_active_admins)
|
||||
monkeypatch.setattr(users_api.user_crud, "update_user", fake_update_user)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await users_api.update_user(
|
||||
protected_admin.id,
|
||||
UserUpdate(email="renamed@huapont.cn"),
|
||||
db=object(),
|
||||
current_user=protected_admin,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_allows_protected_admin_password_change(monkeypatch):
|
||||
protected_admin = _make_user(email="admin@huapont.cn")
|
||||
captured_payload = {}
|
||||
|
||||
async def fake_get_by_id(_db, _user_id):
|
||||
return protected_admin
|
||||
|
||||
async def fake_update_user(_db, _user, payload):
|
||||
captured_payload["password"] = payload.password
|
||||
return _user
|
||||
|
||||
monkeypatch.setattr(users_api.user_crud, "get_by_id", fake_get_by_id)
|
||||
monkeypatch.setattr(users_api.user_crud, "update_user", fake_update_user)
|
||||
|
||||
result = await users_api.update_user(
|
||||
protected_admin.id,
|
||||
UserUpdate(password="Password123"),
|
||||
db=object(),
|
||||
current_user=protected_admin,
|
||||
)
|
||||
|
||||
assert isinstance(result, UserRead | User)
|
||||
assert captured_payload["password"] == "Password123"
|
||||
@@ -0,0 +1,31 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.crud import study as study_crud
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
def __init__(self):
|
||||
self.tables = []
|
||||
self.committed = False
|
||||
|
||||
async def execute(self, stmt):
|
||||
table = getattr(getattr(stmt, "table", None), "name", None)
|
||||
if table:
|
||||
self.tables.append(table)
|
||||
|
||||
async def commit(self):
|
||||
self.committed = True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_study_removes_contract_fee_payments_before_contract_fees():
|
||||
db = _FakeDb()
|
||||
|
||||
await study_crud.delete(db, uuid.uuid4())
|
||||
|
||||
assert "contract_fee_payments" in db.tables
|
||||
assert "contract_fees" in db.tables
|
||||
assert db.tables.index("contract_fee_payments") < db.tables.index("contract_fees")
|
||||
assert db.committed is True
|
||||
Reference in New Issue
Block a user