diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab08ef6..105f4f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,8 @@ jobs: python -m pip install --upgrade pip pip install -e ".[test]" - - name: Run CLI tests with pytest + - name: Run test suite with pytest env: PYTHONIOENCODING: utf-8 run: | - python -m pytest tests/test_cli.py -v + python -m pytest -v diff --git "a/docs/magic-dash-pro/\344\272\214\346\254\241\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/magic-dash-pro/\344\272\214\346\254\241\345\274\200\345\217\221\346\214\207\345\215\227.md" index 26f3b91..1eb3615 100644 --- "a/docs/magic-dash-pro/\344\272\214\346\254\241\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/magic-dash-pro/\344\272\214\346\254\241\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -157,6 +157,8 @@ roles = { 切换数据库类型时,先修改`DatabaseConfig.database_type`和对应连接配置,再确认目标数据库已创建。 +内置模型会在`Peewee`、`SQLAlchemy`和`SQLModel`实现之间保持一致的上层类型契约。以登录日志为例,`LoginLogs.add_log()`的`login_datetime`参数应传入原生`datetime`对象,`LoginLogs.get_logs()`返回记录中的`login_datetime`也始终为`datetime`对象。日期时间的字符串格式化应留在页面展示、文件导出或接口序列化层处理,不应放在模型层,以免不同ORM返回不同类型。 + ## 登录与安全能力 常见安全增强点: diff --git a/magic_dash/templates/magic-dash-pro-fastapi/callbacks/login_c.py b/magic_dash/templates/magic-dash-pro-fastapi/callbacks/login_c.py index f61aba8..12d1b04 100644 --- a/magic_dash/templates/magic-dash-pro-fastapi/callbacks/login_c.py +++ b/magic_dash/templates/magic-dash-pro-fastapi/callbacks/login_c.py @@ -164,7 +164,7 @@ def handle_login( browser=browser_info, os=os_info, status="用户不存在", - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) return [ @@ -199,7 +199,7 @@ def handle_login( browser=browser_info, os=os_info, status="密码错误", - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) return [ @@ -221,7 +221,7 @@ def handle_login( browser=browser_info, os=os_info, status="登录成功", - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) return [None] * 4 @@ -744,7 +744,7 @@ def handle_login_user_email_submit(nClicks, email, verification_code): ), os="{} {}".format(user_agent.os.family, user_agent.os.version_string), status=log_status, - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) # 提前终止当前无输出回调 return @@ -767,7 +767,7 @@ def handle_login_user_email_submit(nClicks, email, verification_code): ), os="{} {}".format(user_agent.os.family, user_agent.os.version_string), status="邮箱登录成功", - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) @@ -841,7 +841,7 @@ def reject_login( browser=browser_info, os=os_info, status=log_status, - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) try: @@ -927,5 +927,5 @@ def reject_login( browser=browser_info, os=os_info, status="OTP登录成功", - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) diff --git a/magic_dash/templates/magic-dash-pro-fastapi/models/_peewee/logs.py b/magic_dash/templates/magic-dash-pro-fastapi/models/_peewee/logs.py index 5ea3fb6..9201b2e 100644 --- a/magic_dash/templates/magic-dash-pro-fastapi/models/_peewee/logs.py +++ b/magic_dash/templates/magic-dash-pro-fastapi/models/_peewee/logs.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import List, Literal from peewee import AutoField, CharField, DateTimeField @@ -82,7 +83,7 @@ def add_log( browser: str, os: str, status: str, - login_datetime: str, + login_datetime: datetime, ): """添加日志记录""" diff --git a/magic_dash/templates/magic-dash-pro-fastapi/models/_sqlalchemy/logs.py b/magic_dash/templates/magic-dash-pro-fastapi/models/_sqlalchemy/logs.py index e0d6025..4c08d2d 100644 --- a/magic_dash/templates/magic-dash-pro-fastapi/models/_sqlalchemy/logs.py +++ b/magic_dash/templates/magic-dash-pro-fastapi/models/_sqlalchemy/logs.py @@ -61,16 +61,9 @@ def add_log( browser: str, os: str, status: str, - login_datetime: str, + login_datetime: datetime, ): with session_scope() as session: - # 应用回调中传入的是格式化后的字符串,这里统一转换为datetime入库 - if isinstance(login_datetime, str): - login_datetime = datetime.strptime( - login_datetime, - "%Y-%m-%d %H:%M:%S", - ) - session.add( cls( user_name=user_name, @@ -108,12 +101,7 @@ def columns(cls): @classmethod def to_dict(cls, record): - result = object_to_dict(record, cls.columns()) - if isinstance(result["login_datetime"], datetime): - result["login_datetime"] = result["login_datetime"].strftime( - "%Y-%m-%d %H:%M:%S" - ) - return result + return object_to_dict(record, cls.columns()) # 保持Peewee旧实现的导入时建表行为,避免日志页首次访问时报表不存在 diff --git a/magic_dash/templates/magic-dash-pro-fastapi/models/_sqlmodel/logs.py b/magic_dash/templates/magic-dash-pro-fastapi/models/_sqlmodel/logs.py index 1371f90..ebe5be7 100644 --- a/magic_dash/templates/magic-dash-pro-fastapi/models/_sqlmodel/logs.py +++ b/magic_dash/templates/magic-dash-pro-fastapi/models/_sqlmodel/logs.py @@ -73,16 +73,9 @@ def add_log( browser: str, os: str, status: str, - login_datetime: str, + login_datetime: datetime, ): with session_scope() as session: - # 应用回调中传入的是格式化后的字符串,这里统一转换为datetime入库 - if isinstance(login_datetime, str): - login_datetime = datetime.strptime( - login_datetime, - "%Y-%m-%d %H:%M:%S", - ) - session.add( cls( user_name=user_name, @@ -120,12 +113,7 @@ def columns(cls): @classmethod def to_dict(cls, record): - result = object_to_dict(record, cls.columns()) - if isinstance(result["login_datetime"], datetime): - result["login_datetime"] = result["login_datetime"].strftime( - "%Y-%m-%d %H:%M:%S" - ) - return result + return object_to_dict(record, cls.columns()) # 保持Peewee旧实现的导入时建表行为,避免日志页首次访问时报表不存在 diff --git a/magic_dash/templates/magic-dash-pro/callbacks/login_c.py b/magic_dash/templates/magic-dash-pro/callbacks/login_c.py index f3b8a21..42851a2 100644 --- a/magic_dash/templates/magic-dash-pro/callbacks/login_c.py +++ b/magic_dash/templates/magic-dash-pro/callbacks/login_c.py @@ -169,7 +169,7 @@ def handle_login( browser=browser_info, os=os_info, status="用户不存在", - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) return [ @@ -204,7 +204,7 @@ def handle_login( browser=browser_info, os=os_info, status="密码错误", - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) return [ @@ -226,7 +226,7 @@ def handle_login( browser=browser_info, os=os_info, status="登录成功", - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) return [None] * 4 @@ -749,7 +749,7 @@ def handle_login_user_email_submit(nClicks, email, verification_code): ), os="{} {}".format(user_agent.os.family, user_agent.os.version_string), status=log_status, - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) # 提前终止当前无输出回调 return @@ -772,7 +772,7 @@ def handle_login_user_email_submit(nClicks, email, verification_code): ), os="{} {}".format(user_agent.os.family, user_agent.os.version_string), status="邮箱登录成功", - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) @@ -846,7 +846,7 @@ def reject_login( browser=browser_info, os=os_info, status=log_status, - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) try: @@ -932,5 +932,5 @@ def reject_login( browser=browser_info, os=os_info, status="OTP登录成功", - login_datetime=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + login_datetime=datetime.now(), ) diff --git a/magic_dash/templates/magic-dash-pro/models/_peewee/logs.py b/magic_dash/templates/magic-dash-pro/models/_peewee/logs.py index 5ea3fb6..9201b2e 100644 --- a/magic_dash/templates/magic-dash-pro/models/_peewee/logs.py +++ b/magic_dash/templates/magic-dash-pro/models/_peewee/logs.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import List, Literal from peewee import AutoField, CharField, DateTimeField @@ -82,7 +83,7 @@ def add_log( browser: str, os: str, status: str, - login_datetime: str, + login_datetime: datetime, ): """添加日志记录""" diff --git a/magic_dash/templates/magic-dash-pro/models/_sqlalchemy/logs.py b/magic_dash/templates/magic-dash-pro/models/_sqlalchemy/logs.py index e0d6025..4c08d2d 100644 --- a/magic_dash/templates/magic-dash-pro/models/_sqlalchemy/logs.py +++ b/magic_dash/templates/magic-dash-pro/models/_sqlalchemy/logs.py @@ -61,16 +61,9 @@ def add_log( browser: str, os: str, status: str, - login_datetime: str, + login_datetime: datetime, ): with session_scope() as session: - # 应用回调中传入的是格式化后的字符串,这里统一转换为datetime入库 - if isinstance(login_datetime, str): - login_datetime = datetime.strptime( - login_datetime, - "%Y-%m-%d %H:%M:%S", - ) - session.add( cls( user_name=user_name, @@ -108,12 +101,7 @@ def columns(cls): @classmethod def to_dict(cls, record): - result = object_to_dict(record, cls.columns()) - if isinstance(result["login_datetime"], datetime): - result["login_datetime"] = result["login_datetime"].strftime( - "%Y-%m-%d %H:%M:%S" - ) - return result + return object_to_dict(record, cls.columns()) # 保持Peewee旧实现的导入时建表行为,避免日志页首次访问时报表不存在 diff --git a/magic_dash/templates/magic-dash-pro/models/_sqlmodel/logs.py b/magic_dash/templates/magic-dash-pro/models/_sqlmodel/logs.py index 1371f90..ebe5be7 100644 --- a/magic_dash/templates/magic-dash-pro/models/_sqlmodel/logs.py +++ b/magic_dash/templates/magic-dash-pro/models/_sqlmodel/logs.py @@ -73,16 +73,9 @@ def add_log( browser: str, os: str, status: str, - login_datetime: str, + login_datetime: datetime, ): with session_scope() as session: - # 应用回调中传入的是格式化后的字符串,这里统一转换为datetime入库 - if isinstance(login_datetime, str): - login_datetime = datetime.strptime( - login_datetime, - "%Y-%m-%d %H:%M:%S", - ) - session.add( cls( user_name=user_name, @@ -120,12 +113,7 @@ def columns(cls): @classmethod def to_dict(cls, record): - result = object_to_dict(record, cls.columns()) - if isinstance(result["login_datetime"], datetime): - result["login_datetime"] = result["login_datetime"].strftime( - "%Y-%m-%d %H:%M:%S" - ) - return result + return object_to_dict(record, cls.columns()) # 保持Peewee旧实现的导入时建表行为,避免日志页首次访问时报表不存在 diff --git a/setup.py b/setup.py index 2f8b0af..722a8af 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,11 @@ def get_version(): "peewee>=4.0.0", "SQLAlchemy>=2.0.0", "sqlmodel>=0.0.27", + "cryptography", + "dash[fastapi]>=4.2.0,<5.0.0", + "fastapi-login", + "httpx", + "user_agents", ], }, entry_points={ diff --git a/tests/_magic_dash_pro_orm_contract.py b/tests/_magic_dash_pro_orm_contract.py new file mode 100644 index 0000000..c312e46 --- /dev/null +++ b/tests/_magic_dash_pro_orm_contract.py @@ -0,0 +1,96 @@ +"""Run one magic-dash-pro template/ORM contract check in an isolated process.""" + +import importlib +import os +import sys +from datetime import datetime +from pathlib import Path + + +def check_model_api_contract(template_root: Path, orm_engine: str, workdir: Path): + os.chdir(workdir) + sys.path.insert(0, str(template_root)) + + engine_package = f"models._{orm_engine}" + models = importlib.import_module(engine_package) + Users = importlib.import_module(f"{engine_package}.users").Users + Departments = importlib.import_module(f"{engine_package}.departments").Departments + LoginLogs = importlib.import_module(f"{engine_package}.logs").LoginLogs + EmailVerifications = importlib.import_module( + f"{engine_package}.email_verifications" + ).EmailVerifications + OtpCredentials = importlib.import_module( + f"{engine_package}.otp_credentials" + ).OtpCredentials + UserPermissionGroups = importlib.import_module( + f"{engine_package}.user_permission_groups" + ).UserPermissionGroups + + try: + models.create_tables( + [ + Users, + Departments, + LoginLogs, + EmailVerifications, + OtpCredentials, + UserPermissionGroups, + ] + ) + + Departments.add_department("dept-1", "研发部") + assert Departments.get_department("dept-1").department_name == "研发部" + assert Departments.get_all_departments()[0]["department_id"] == "dept-1" + + assert UserPermissionGroups.get_all_permission_groups() == [] + Users.add_user( + "user-1", + "admin", + "password-hash", + user_email="admin@example.com", + department_id="dept-1", + user_role="normal", + ) + assert Users.get_user("user-1").user_name == "admin" + assert Users.get_user_by_email("admin@example.com").user_id == "user-1" + assert ( + Users.get_all_users(with_department_name=True)[0]["department_name"] + == "研发部" + ) + + login_datetime = datetime(2026, 7, 6, 12, 0, 0) + LoginLogs.add_log( + "admin", + "user-1", + "127.0.0.1", + "Chrome", + "Windows", + "登录成功", + login_datetime, + ) + assert LoginLogs.get_count() == 1 + login_log = LoginLogs.get_logs()[0] + assert login_log["user_name"] == "admin" + assert isinstance(login_log["login_datetime"], datetime) + assert login_log["login_datetime"] == login_datetime + + verification, remaining_seconds, previous_verification = ( + EmailVerifications.issue_verification("admin@example.com", 60) + ) + assert verification.verification_code.isdigit() + assert remaining_seconds == 0 + assert previous_verification is None + + credential = OtpCredentials.enable_credential("user-1", "secret") + assert credential.is_enabled + assert OtpCredentials.has_enabled_otp("user-1") + finally: + models.db.close() + + +if __name__ == "__main__": + check_model_api_contract( + template_root=Path(sys.argv[1]).resolve(), + orm_engine=sys.argv[2], + workdir=Path(sys.argv[3]).resolve(), + ) diff --git a/tests/test_magic_dash_pro_orm_engines.py b/tests/test_magic_dash_pro_orm_engines.py index 8664f86..9404814 100644 --- a/tests/test_magic_dash_pro_orm_engines.py +++ b/tests/test_magic_dash_pro_orm_engines.py @@ -1,101 +1,39 @@ -import importlib +import subprocess import sys from pathlib import Path import pytest -TEMPLATE_ROOT = ( - Path(__file__).resolve().parents[1] / "magic_dash" / "templates" / "magic-dash-pro" -) - +TEMPLATES_ROOT = Path(__file__).resolve().parents[1] / "magic_dash" / "templates" +CONTRACT_RUNNER = Path(__file__).with_name("_magic_dash_pro_orm_contract.py") -def clear_template_modules(): - for module_name in list(sys.modules): - if module_name == "models" or module_name.startswith("models."): - sys.modules.pop(module_name) - elif module_name == "configs" or module_name.startswith("configs."): - sys.modules.pop(module_name) - -@pytest.mark.parametrize("orm_engine", ["sqlalchemy", "sqlmodel"]) -def test_magic_dash_pro_alternative_engine_keeps_model_api_contract( +@pytest.mark.parametrize( + "template_name", + ["magic-dash-pro", "magic-dash-pro-fastapi"], +) +@pytest.mark.parametrize("orm_engine", ["peewee", "sqlalchemy", "sqlmodel"]) +def test_magic_dash_pro_engine_keeps_model_api_contract( tmp_path, - monkeypatch, + template_name, orm_engine, ): - clear_template_modules() - monkeypatch.chdir(tmp_path) - monkeypatch.syspath_prepend(str(TEMPLATE_ROOT)) - - engine_package = f"models._{orm_engine}" - models = importlib.import_module(engine_package) - Users = importlib.import_module(f"{engine_package}.users").Users - Departments = importlib.import_module(f"{engine_package}.departments").Departments - LoginLogs = importlib.import_module(f"{engine_package}.logs").LoginLogs - EmailVerifications = importlib.import_module( - f"{engine_package}.email_verifications" - ).EmailVerifications - OtpCredentials = importlib.import_module( - f"{engine_package}.otp_credentials" - ).OtpCredentials - UserPermissionGroups = importlib.import_module( - f"{engine_package}.user_permission_groups" - ).UserPermissionGroups - - models.create_tables( + result = subprocess.run( [ - Users, - Departments, - LoginLogs, - EmailVerifications, - OtpCredentials, - UserPermissionGroups, - ] - ) - - Departments.add_department("dept-1", "研发部") - assert Departments.get_department("dept-1").department_name == "研发部" - assert Departments.get_all_departments()[0]["department_id"] == "dept-1" - - assert UserPermissionGroups.get_all_permission_groups() == [] - Users.add_user( - "user-1", - "admin", - "password-hash", - user_email="admin@example.com", - department_id="dept-1", - user_role="normal", - ) - assert Users.get_user("user-1").user_name == "admin" - assert Users.get_user_by_email("admin@example.com").user_id == "user-1" - assert ( - Users.get_all_users(with_department_name=True)[0]["department_name"] == "研发部" + sys.executable, + str(CONTRACT_RUNNER), + str(TEMPLATES_ROOT / template_name), + orm_engine, + str(tmp_path), + ], + capture_output=True, + text=True, + check=False, ) - LoginLogs.add_log( - "admin", - "user-1", - "127.0.0.1", - "Chrome", - "Windows", - "登录成功", - "2026-07-06 12:00:00", + assert result.returncode == 0, ( + f"{template_name}/{orm_engine} contract check failed\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" ) - assert LoginLogs.get_count() == 1 - assert LoginLogs.get_logs()[0]["user_name"] == "admin" - assert LoginLogs.get_logs()[0]["login_datetime"] == "2026-07-06 12:00:00" - - verification, remaining_seconds, previous_verification = ( - EmailVerifications.issue_verification("admin@example.com", 60) - ) - assert verification.verification_code.isdigit() - assert remaining_seconds == 0 - assert previous_verification is None - - credential = OtpCredentials.enable_credential("user-1", "secret") - assert credential.is_enabled - assert OtpCredentials.has_enabled_otp("user-1") - - models.db.close() - clear_template_modules()