From 36729d38ca034f1ed8ca52bd4009a3ee72c730d0 Mon Sep 17 00:00:00 2001 From: jeffry <36665036+wangxin688@users.noreply.github.com> Date: Sat, 20 Jan 2024 15:27:59 +0000 Subject: [PATCH] feat(optimize): update settings --- .env_docker_example | 6 - .env_example | 8 + .gitignore | 1 + alembic.ini | 23 +-- alembic/env.py | 61 +++++--- .../2024_01_20_1433-a72f51adf94a_init_db.py | 145 ++++++++++++++++++ docker-compose.yaml | 6 +- pyproject.toml | 3 +- src/_types.py | 8 - src/app.py | 10 +- src/auth/api.py | 86 +++++------ src/auth/schemas.py | 27 ++-- src/db/__init__.py | 2 +- src/db/_types.py | 45 +----- src/db/base.py | 3 - src/db/mixins.py | 6 +- src/db/session.py | 1 - src/exceptions.py | 3 +- src/middlewares.py | 7 +- 19 files changed, 275 insertions(+), 176 deletions(-) delete mode 100644 .env_docker_example create mode 100644 alembic/versions/2024_01_20_1433-a72f51adf94a_init_db.py diff --git a/.env_docker_example b/.env_docker_example deleted file mode 100644 index 4571b82..0000000 --- a/.env_docker_example +++ /dev/null @@ -1,6 +0,0 @@ -DEFAULT_DB_PASSWORD=91fb8e9e009f5b9ce1854d947e6fe4a3 -DEFAULT_DB_USER=demo -DEFAULT_DB_NAME=demo -DEFAULT_DB_PORT=5432 - -REDIS_DEFAULT_PASSWORD=cfe1c2c4703abb205d71abdc07cc3f3d diff --git a/.env_example b/.env_example index 46a4fa1..4f754a5 100644 --- a/.env_example +++ b/.env_example @@ -7,3 +7,11 @@ SQLALCHEMY_DATABASE_URI=postgresql+asyncpg://demo:91fb8e9e009f5b9ce1854d947e6fe4 REDIS_DSN=redis://:cfe1c2c4703abb205d71abdc07cc3f3d@localhost:6379 APP_ENV=PRD + +# docker compose +DEFAULT_DB_PASSWORD=91fb8e9e009f5b9ce1854d947e6fe4a3 +DEFAULT_DB_USER=demo +DEFAULT_DB_NAME=demo +DEFAULT_DB_PORT=5432 + +REDIS_DEFAULT_PASS=cfe1c2c4703abb205d71abdc07cc3f3d diff --git a/.gitignore b/.gitignore index 8f6bf45..fb29b8a 100644 --- a/.gitignore +++ b/.gitignore @@ -159,3 +159,4 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ +data diff --git a/alembic.ini b/alembic.ini index c10d4ca..b660cf3 100644 --- a/alembic.ini +++ b/alembic.ini @@ -8,7 +8,7 @@ script_location = alembic # Uncomment the line below if you want the files to be prepended with date and time # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file # for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s # sys.path path, will be prepended to sys.path if present. # defaults to the current working directory. @@ -16,9 +16,9 @@ prepend_sys_path = . # timezone to use when rendering the date within the migration file # as well as the filename. -# If specified, requires the python>=3.9 or backports.zoneinfo library. -# Any required deps can installed by adding `alembic[tz]` to the pip requirements -# string value is passed to ZoneInfo() +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() # leave blank for localtime # timezone = @@ -36,10 +36,10 @@ prepend_sys_path = . # sourceless = false # version location specification; This defaults -# to alembic/versions. When using multiple version +# to migrations/versions. When using multiple version # directories, initial revisions must be specified with --version-path. # The path separator used here should be the separator specified by "version_path_separator" below. -# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions # version path separator; As mentioned above, this is the character used to split # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. @@ -51,11 +51,6 @@ prepend_sys_path = . # version_path_separator = space version_path_separator = os # Use os.pathsep. Default configuration used for new projects. -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - # the output encoding used when revision files # are written from script.py.mako # output_encoding = utf-8 @@ -74,12 +69,6 @@ sqlalchemy.url = driver://user:pass@localhost/dbname # black.entrypoint = black # black.options = -l 79 REVISION_SCRIPT_FILENAME -# lint with attempts to fix using "ruff" - use the exec runner, execute a binary -# hooks = ruff -# ruff.type = exec -# ruff.executable = %(here)s/.venv/bin/ruff -# ruff.options = --fix REVISION_SCRIPT_FILENAME - # Logging configuration [loggers] keys = root,sqlalchemy,alembic diff --git a/alembic/env.py b/alembic/env.py index aa22234..e9a8a0d 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,10 +1,12 @@ from logging.config import fileConfig -from alembic import context from sqlalchemy import engine_from_config -from sqlalchemy import pool +from sqlalchemy import pool, Connection +from sqlalchemy.ext.asyncio import AsyncEngine +import asyncio +from src import config as app_config +from alembic import context -from src.db import Base # noqa: F401 # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -12,14 +14,15 @@ # Interpret the config file for Python logging. # This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) +fileConfig(config.config_file_name) # PGH003 # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -target_metadata = None +from src.db import Base # noqa: E402 + +target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, # can be acquired: @@ -27,51 +30,61 @@ # ... etc. +def get_database_uri() -> str: + return app_config.settings.SQLALCHEMY_DATABASE_URI + + def run_migrations_offline() -> None: """Run migrations in 'offline' mode. - This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. - Calls to context.execute() here emit the given string to the script output. - """ - url = config.get_main_option("sqlalchemy.url") + url = get_database_uri() context.configure( url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + compare_type=True, + compare_server_default=True, ) with context.begin_transaction(): context.run_migrations() -def run_migrations_online() -> None: - """Run migrations in 'online' mode. +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. - """ - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, + configuration = config.get_section(config.config_ini_section) + assert configuration # noqa: S101 + configuration["sqlalchemy.url"] = get_database_uri() + connectable = AsyncEngine( + engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + future=True, + ) ) - - with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) - - with context.begin_transaction(): - context.run_migrations() + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) if context.is_offline_mode(): run_migrations_offline() else: - run_migrations_online() + asyncio.run(run_migrations_online()) diff --git a/alembic/versions/2024_01_20_1433-a72f51adf94a_init_db.py b/alembic/versions/2024_01_20_1433-a72f51adf94a_init_db.py new file mode 100644 index 0000000..0c586ff --- /dev/null +++ b/alembic/versions/2024_01_20_1433-a72f51adf94a_init_db.py @@ -0,0 +1,145 @@ +"""init db + +Revision ID: a72f51adf94a +Revises: +Create Date: 2024-01-20 14:33:41.546660 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a72f51adf94a" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "menu", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False, comment="the unique name of route"), + sa.Column("hidden", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("redirect", sa.String(), nullable=False, comment="redirect url for the route"), + sa.Column( + "hideChildrenInMenu", + sa.Boolean(), + server_default=sa.text("false"), + nullable=False, + comment="hide children in menu force or not", + ), + sa.Column("order", sa.Integer(), nullable=False), + sa.Column("title", sa.String(), nullable=False, comment="the title of the route, 面包屑"), + sa.Column("icon", sa.String(), nullable=True), + sa.Column( + "keepAlive", + sa.Boolean(), + server_default=sa.text("false"), + nullable=False, + comment="cache route, 开启multi-tab时为true", + ), + sa.Column( + "hiddenHeaderContent", + sa.Boolean(), + server_default=sa.text("false"), + nullable=False, + comment="隐藏pageheader页面带的面包屑和标题栏", + ), + sa.Column("permission", postgresql.ARRAY(sa.Integer(), dimensions=1), nullable=True), + sa.Column("parent_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["parent_id"], ["menu.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "permission", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("url", sa.String(), nullable=False), + sa.Column("method", sa.String(), nullable=False), + sa.Column("tag", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "role", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("slug", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_role_created_at"), "role", ["created_at"], unique=False) + op.create_table( + "group", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("role_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["role_id"], ["role.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_group_created_at"), "group", ["created_at"], unique=False) + op.create_table( + "role_menu", + sa.Column("role_id", sa.Integer(), nullable=False), + sa.Column("menu_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["menu_id"], ["menu.id"]), + sa.ForeignKeyConstraint(["role_id"], ["role.id"]), + sa.PrimaryKeyConstraint("role_id", "menu_id"), + ) + op.create_table( + "role_permission", + sa.Column("role_id", sa.Integer(), nullable=False), + sa.Column("permission_id", sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(["permission_id"], ["permission.id"]), + sa.ForeignKeyConstraint(["role_id"], ["role.id"]), + sa.PrimaryKeyConstraint("role_id", "permission_id"), + ) + op.create_table( + "user", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("email", sa.String(), nullable=True), + sa.Column("phone", sa.String(), nullable=True), + sa.Column("password", sa.String(), nullable=False), + sa.Column("avatar", sa.String(), nullable=True), + sa.Column("last_login", sa.DateTime(timezone=True), nullable=True), + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("group_id", sa.Integer(), nullable=False), + sa.Column("role_id", sa.Integer(), nullable=False), + sa.Column("auth_info", postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["group_id"], ["group.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["role_id"], ["role.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + sa.UniqueConstraint("phone"), + ) + op.create_index(op.f("ix_user_created_at"), "user", ["created_at"], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_user_created_at"), table_name="user") + op.drop_table("user") + op.drop_table("role_permission") + op.drop_table("role_menu") + op.drop_index(op.f("ix_group_created_at"), table_name="group") + op.drop_table("group") + op.drop_index(op.f("ix_role_created_at"), table_name="role") + op.drop_table("role") + op.drop_table("permission") + op.drop_table("menu") + # ### end Alembic commands ### diff --git a/docker-compose.yaml b/docker-compose.yaml index f772f8f..1342ec4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,11 +9,11 @@ services: - ./data/redis:/var/lib/redis command: redis-server --requirepass ${REDIS_DEFAULT_PASS} # --notify-keyspace-events Ex env_file: - - .env_docker + - .env postgres: image: postgres:latest ports: - - "{$DEFAULT_DB_PORT}:5432" + - "${DEFAULT_DB_PORT}:5432" volumes: - ./data/postgres:/var/lib/postgresql/data environment: @@ -21,4 +21,4 @@ services: - POSTGRES_USER=${DEFAULT_DB_USER} - POSTGRES_DB=${DEFAULT_DB_NAME} env_file: - - .env_docker + - .env diff --git a/pyproject.toml b/pyproject.toml index e8cb7b1..02b2042 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ dependencies = [ "alembic>=1.13.1", "sqlalchemy>=2.0.25", "pydantic-settings>=2.1.0", - "pydantic-extra-types>=2.3.0", "uvicorn[standard]>=0.25.0", "pyjwt>=2.8.0", "pandas>=2.1.4", @@ -70,6 +69,8 @@ fixable = ["ALL"] "deps.py" = ["B008"] "src/internal/api.py" = ["ARG001"] "src/auth/schemas.py" = ["N815"] # frontend menu +"alembic/*.py" = ["INP001", "UP007"] +"__init__.py" = ["F403"] [tool.ruff.flake8-bugbear] extend-immutable-calls=[ diff --git a/src/_types.py b/src/_types.py index 77551d0..c270f85 100644 --- a/src/_types.py +++ b/src/_types.py @@ -31,19 +31,11 @@ class AuditTimeBase(BaseModel): updated_at: datetime | None = None -class ResultT(BaseModel): - data: T - - class ListT(BaseModel, Generic[T]): count: int results: T | None = None -class ListResultT(BaseModel, Generic[T]): - data: ListT - - class AppStrEnum(str, Enum): def __str__(self) -> str: return str.__str__(self) diff --git a/src/app.py b/src/app.py index 98b1e96..fe63d09 100644 --- a/src/app.py +++ b/src/app.py @@ -41,9 +41,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: ARG001 version=settings.VERSION, summary=settings.DESCRIPTION, description=openapi_description, - docs_url=None, - redoc_url=None, - swagger_ui_init_oauth={}, lifespan=lifespan, ) app.include_router(router, prefix="/api") @@ -55,7 +52,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: ARG001 CORSMiddleware, allow_origins=settings.BACKEND_CORS, allow_credentials=True, - all_methods=settings.BACKEND_CORS, - allow_headers=settings.BACKEND_CORS, + allow_methods=["*"], + allow_headers=["*"], ) return app + + +app = create_app() diff --git a/src/auth/api.py b/src/auth/api.py index d838993..807917e 100644 --- a/src/auth/api.py +++ b/src/auth/api.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import selectinload from src import errors -from src._types import IdResponse, ListResultT, ListT, ResultT +from src._types import IdResponse, ListT from src.auth import schemas from src.auth.models import Group, Permission, Role, User from src.auth.services import group_dto, role_dto, user_dto @@ -20,46 +20,46 @@ class UserCbv: user: User = Depends(auth) session: AsyncSession = Depends(get_session) - @router.post("/users", operation_id="e0fe80d5-cbe0-4c2c-9eff-57e80ecba522") - async def create_user(self, user: schemas.UserCreate) -> ResultT[IdResponse]: + @router.post("/users", operation_id="5091fff6-1adc-4a22-8a8c-ef0107122df7", summary="Create new user/创建新用户") + async def create_user(self, user: schemas.UserCreate) -> IdResponse: new_user = await user_dto.create(self.session, user) result = await user_dto.commit(self.session, new_user) - return ResultT(data=IdResponse(id=result.id)) + return IdResponse(id=result.id) - @router.get("/users/{id}", operation_id="8057d614-150f-42ee-984c-d0af35796da3") - async def get_user(self, id: int) -> ResultT[schemas.UserDetail]: + @router.get("/users/{id}", operation_id="276a8c69-2f5c-40d5-91c4-d0ddd1c24766") + async def get_user(self, id: int) -> schemas.UserDetail: db_user = await user_dto.get_one_or_404( self.session, id, selectinload(User.role).load_only(Role.id, Role.name), selectinload(User.group).load_only(Group.id, Group.name), ) - return ResultT(data=schemas.UserDetail.model_validate(db_user)) + return schemas.UserDetail.model_validate(db_user) - @router.get("/users", operation_id="c5f793b1-7adf-4b4e-a498-732b0fa7d758") - async def get_users(self, query: schemas.UserQuery) -> ListResultT[list[schemas.UserDetail]]: + @router.get("/users", operation_id="2485e2a2-4d81-4601-a6fd-c633b23ce5fc") + async def get_users(self, query: schemas.UserQuery) -> ListT[list[schemas.UserDetail]]: count, results = await user_dto.list_and_count( self.session, query, selectinload(User.role).load_only(Role.id, Role.name), selectinload(User.group).load_only(Group.id, Group.name), ) - return ListResultT(data=ListT(count=count, results=results)) + return ListT(count=count, results=[schemas.UserDetail.model_validate(r) for r in results]) - @router.put("/users/{id}", operation_id="2fda2e00-ad86-4296-a1d4-c7f02366b52e") - async def update_user(self, id: int, user: schemas.UserUpdate) -> ResultT[IdResponse]: + @router.put("/users/{id}", operation_id="ea0078b9-7f16-4b55-9264-fa7ba48737a9") + async def update_user(self, id: int, user: schemas.UserUpdate) -> IdResponse: update_user = user.model_dump(exclude_unset=True) if "password" in update_user and update_user["password"] is None: raise GenerError(errors.ERR_10006, status_code=status.HTTP_406_NOT_ACCEPTABLE) db_user = await user_dto.get_one_or_404(self.session, id) await user_dto.update(self.session, db_user, user) - return ResultT(data=IdResponse(id=id)) + return IdResponse(id=id) - @router.delete("/users/{id}", operation_id="c4e9e0e8-6b0c-4f6f-9e6c-8d9f9f9f9f9f") - async def delete_user(self, id: int) -> ResultT[IdResponse]: + @router.delete("/users/{id}", operation_id="78e48ceb-d7cf-46fe-bf9e-d04958aade7d") + async def delete_user(self, id: int) -> IdResponse: db_user = await user_dto.get_one_or_404(self.session, id) await user_dto.delete(self.session, db_user) - return ResultT(data=IdResponse(id=id)) + return IdResponse(id=id) @cbv(router) @@ -67,39 +67,39 @@ class GroupCbv: user: User = Depends(auth) session: AsyncSession = Depends(get_session) - @router.post("/groups", operation_id="e0fe80d5-cbe0-4c2c-9eff-57e80ecba522") - async def create_group(self, group: schemas.GroupCreate) -> ResultT[IdResponse]: + @router.post("/groups", operation_id="9e3e639d-c694-467d-9209-717b038cf267") + async def create_group(self, group: schemas.GroupCreate) -> IdResponse: if not group.user_ids: new_group = await group_dto.create(self.session, group) else: users = (await self.session.scalars(select(User).where(User.id.in_(group.user_ids)))).all() new_group = await group_dto.create_with_users(self.session, group, users) - return ResultT(data=IdResponse(id=new_group.id)) + return IdResponse(id=new_group.id) - @router.get("/groups/{id}", operation_id="8057d614-150f-42ee-984c-d0af35796da3") - async def get_group(self, id: int) -> ResultT[schemas.GroupDetail]: + @router.get("/groups/{id}", operation_id="00327087-9443-4d24-8d04-e396e3244744") + async def get_group(self, id: int) -> schemas.GroupDetail: db_group = await group_dto.get_one_or_404(self.session, id, undefer_load=True) - return ResultT(data=schemas.GroupDetail.model_validate(db_group)) + return schemas.GroupDetail.model_validate(db_group) - @router.get("/groups", operation_id="c5f793b1-7adf-4b4e-a498-732b0fa7d758") - async def get_groups(self, query: schemas.GroupQuery) -> ListResultT[list[schemas.GroupDetail]]: + @router.get("/groups", operation_id="a1d1f8f1-4d4d-4fab-868b-3f977df26e05") + async def get_groups(self, query: schemas.GroupQuery) -> ListT[list[schemas.GroupDetail]]: count, results = await group_dto.list_and_count(self.session, query) - return ListResultT(data=ListT(count=count, results=results)) + return ListT(count=count, results=[schemas.GroupDetail.model_validate(r) for r in results]) - @router.put("/groups/{id}", operation_id="2fda2e00-ad86-4296-a1d4-c7f02366b52e") - async def update_group(self, id: int, group: schemas.GroupUpdate) -> ResultT[IdResponse]: + @router.put("/groups/{id}", operation_id="3d5badd1-665c-49f8-85c4-6f6d7f3a1b2a") + async def update_group(self, id: int, group: schemas.GroupUpdate) -> IdResponse: db_group = await group_dto.get_one_or_404(self.session, id, selectinload(Group.user)) update_group = group.model_dump(exclude_unset=True) if "user_ids" in update_group: db_group = await group_dto.update_relationship_field(self.session, db_group, User, "user", group.user_ids) await group_dto.update(self.session, db_group, group, excludes={"user_ids"}) - return ResultT(data=IdResponse(id=id)) + return IdResponse(id=id) - @router.delete("/groups/{id}", operation_id="c4e9e0e8-6b0c-4f6f-9e6c-8d9f9f9f9f9f") - async def delete_group(self, id: int) -> ResultT[IdResponse]: + @router.delete("/groups/{id}", operation_id="e16830da-2973-4369-8e75-da9b4174ab72") + async def delete_group(self, id: int) -> IdResponse: db_group = await group_dto.get_one_or_404(self.session, id) await group_dto.delete(self.session, db_group) - return ResultT(data=IdResponse(id=id)) + return IdResponse(id=id) @cbv(router) @@ -107,8 +107,8 @@ class RoleCbv: user: User = Depends(auth) session: AsyncSession = Depends(get_session) - @router.post("/roles", operation_id="e0fe80d5-cbe0-4c2c-9eff-57e80ecba522") - async def create_role(self, role: schemas.RoleCreate) -> ResultT[IdResponse]: + @router.post("/roles", operation_id="a18a152b-e9e9-4128-b8be-8a8e9c842abb") + async def create_role(self, role: schemas.RoleCreate) -> IdResponse: if not role.permission_ids: new_role = await role_dto.create(self.session, role) else: @@ -116,30 +116,30 @@ async def create_role(self, role: schemas.RoleCreate) -> ResultT[IdResponse]: await self.session.scalars(select(Permission).where(Permission.id.in_(role.permission_ids))) ).all() new_role = await role_dto.create_with_permissions(self.session, role, permissions) - return ResultT(data=IdResponse(id=new_role.id)) + return IdResponse(id=new_role.id) - @router.get("/roles/{id}", operation_id="8057d614-150f-42ee-984c-d0af35796da3") - async def get_role(self, id: int) -> ResultT[schemas.RoleDetail]: + @router.get("/roles/{id}", operation_id="2b45f59a-77a1-45d4-bf43-94373da517e3") + async def get_role(self, id: int) -> schemas.RoleDetail: db_role = await role_dto.get_one_or_404(self.session, id, selectinload(Role.permission), undefer_load=True) - return ResultT(data=schemas.RoleDetail.model_validate(db_role)) + return schemas.RoleDetail.model_validate(db_role) @router.get("/roles", operation_id="c5f793b1-7adf-4b4e-a498-732b0fa7d758") - async def get_roles(self, query: schemas.RoleQuery) -> ListResultT[list[schemas.RoleList]]: + async def get_roles(self, query: schemas.RoleQuery) -> ListT[list[schemas.RoleList]]: count, results = await role_dto.list_and_count(self.session, query) - return ListResultT(data=ListT(count=count, results=results)) + return ListT(count=count, results=[schemas.RoleList.model_validate(r) for r in results]) @router.put("/roles/{id}", operation_id="2fda2e00-ad86-4296-a1d4-c7f02366b52e") - async def update_role(self, id: int, role: schemas.RoleUpdate) -> ResultT[IdResponse]: + async def update_role(self, id: int, role: schemas.RoleUpdate) -> IdResponse: db_role = await role_dto.get_one_or_404(self.session, id, selectinload(Role.permission)) if "permission_ids" in role.model_dump(exclude_unset=True): db_role = await role_dto.update_relationship_field( self.session, db_role, Permission, "permission", role.permission_ids ) await role_dto.update(self.session, db_role, role, excludes={"permission_ids"}) - return ResultT(data=IdResponse(id=id)) + return IdResponse(id=id) @router.delete("/roles/{id}", operation_id="c4e9e0e8-6b0c-4f6f-9e6c-8d9f9f9f9f9f") - async def delete_role(self, id: int) -> ResultT[IdResponse]: + async def delete_role(self, id: int) -> IdResponse: db_role = await role_dto.get_one_or_404(self.session, id) await role_dto.delete(self.session, db_role) - return ResultT(data=IdResponse(id=id)) + return IdResponse(id=id) diff --git a/src/auth/schemas.py b/src/auth/schemas.py index 3baa6cf..9464e81 100644 --- a/src/auth/schemas.py +++ b/src/auth/schemas.py @@ -1,7 +1,6 @@ from datetime import datetime from uuid import UUID -from pydantic import EmailStr from pydantic_extra_types.phone_numbers import PhoneNumber from src._types import AuditTimeBase, BaseModel, QueryParams @@ -39,7 +38,7 @@ class PermissionQuery(QueryParams): class UserBase(BaseModel): name: str - email: EmailStr | None = None + email: str | None = None phone: PhoneNumber | None = None avatar: str | None = None @@ -76,7 +75,7 @@ class MenuBase(BaseModel): hideChildrenInMenu: bool order: int title: str - icon: str | None + icon: str | None = None keepAlive: bool hiddenHeaderContent: bool permission: list[int] @@ -88,17 +87,17 @@ class MenuCreate(MenuBase): class MenuUpdate(BaseModel): - name: str | None - hidden: bool | None - redirect: str | None - hideChildrenInMenu: bool | None - order: int | None - title: str | None - icon: str | None - keepAlive: bool | None - hiddenHeaderContent: bool | None - permission: list[int] | None - parent_id: int | None + name: str | None = None + hidden: bool | None = None + redirect: str | None = None + hideChildrenInMenu: bool | None = None + order: int | None = None + title: str | None = None + icon: str | None = None + keepAlive: bool | None = None + hiddenHeaderContent: bool | None = None + permission: list[int] | None = None + parent_id: int | None = None class MenuQuery(QueryParams): diff --git a/src/db/__init__.py b/src/db/__init__.py index 6f501d4..680ad64 100644 --- a/src/db/__init__.py +++ b/src/db/__init__.py @@ -1,4 +1,4 @@ -from src.auth.models import Group, Permission, Role, RolePermission, User # noqa: F401 +from src.auth.models import * from src.db.base import Base diff --git a/src/db/_types.py b/src/db/_types.py index 8e18ab5..cd523fb 100644 --- a/src/db/_types.py +++ b/src/db/_types.py @@ -1,13 +1,13 @@ import uuid from datetime import date, datetime -from typing import Annotated, Any +from typing import Annotated -from sqlalchemy import CHAR, Boolean, Date, DateTime, Dialect, Integer, String, func, type_coerce +from sqlalchemy import Boolean, Date, DateTime, Integer, String, func, type_coerce from sqlalchemy.dialects.postgresql import BYTEA, UUID from sqlalchemy.orm import mapped_column from sqlalchemy.sql import expression from sqlalchemy.sql.elements import BindParameter, ColumnElement -from sqlalchemy.types import TypeDecorator, TypeEngine +from sqlalchemy.types import TypeDecorator from src.config import settings @@ -21,49 +21,14 @@ def __init__(self, secret_key: str | None = settings.SECRET_KEY) -> None: self.secret = secret_key def bind_expression(self, bind_value: BindParameter) -> ColumnElement | None: - bind_value = type_coerce(bind_value, String) + bind_value = type_coerce(bind_value, String) # type: ignore # noqa: PGH003 return func.pgp_sym_encrypt(bind_value, self.secret) def column_expression(self, column: ColumnElement) -> ColumnElement | None: return func.pgp_sym_decrypt(column, self.secret) -class GUID(TypeDecorator): - """Platform-independent GUID type. - - Uses PostgreSQL's UUID type or MSSQL's UNIQUEIDENTIFIER, - otherwise uses CHAR(32), storing as stringified hex values. - - """ - - impl = CHAR - cache_ok = True - - _default_type = CHAR(32) - - def load_dialect_impl(self, dialect: Dialect) -> TypeEngine[UUID] | TypeEngine[str]: - if dialect.name == "postgresql": - return dialect.type_descriptor(UUID()) - return dialect.type_descriptor(self._default_type) - - def process_bind_param(self, value: Any, dialect: Dialect) -> str | None: - if value is None: - return value - if dialect.name == "postgresql": - return str(value) - if not isinstance(value, uuid.UUID): - value = uuid.UUID(value) - return self._uuid_as_str(value) - - def process_result_value(self, value: Any, dialect: Dialect) -> UUID | None: # noqa: ARG002 - if value is None: - return value - if not isinstance(value, uuid.UUID): - value = uuid.UUID(value) - return value - - -uuid_pk = Annotated[UUID, mapped_column(GUID, primary_key=True)] +uuid_pk = Annotated[uuid.UUID, mapped_column(UUID(as_uuid=True), default=uuid.uuid4, primary_key=True)] int_pk = Annotated[int, mapped_column(Integer, primary_key=True)] bool_true = Annotated[bool, mapped_column(Boolean, server_default=expression.true())] bool_false = Annotated[bool, mapped_column(Boolean, server_default=expression.false())] diff --git a/src/db/base.py b/src/db/base.py index 056337c..7302cf5 100644 --- a/src/db/base.py +++ b/src/db/base.py @@ -1,18 +1,15 @@ from typing import Any, ClassVar -from uuid import UUID from fastapi.encoders import jsonable_encoder from sqlalchemy.orm import DeclarativeBase from src._types import VisibleName -from src.db._types import GUID class Base(DeclarativeBase): __visible_name__: ClassVar[VisibleName] = {"en_US": "base", "zh_CN": "base"} __search_fields__: ClassVar[set[str]] = set() __i18n_fields__: ClassVar[set[str]] = set() - type_annotation_map: ClassVar = {UUID: GUID} def dict(self, exclude: set[str] | None = None, native_dict: bool = False) -> dict[str, Any]: """Return dict representation of model.""" diff --git a/src/db/mixins.py b/src/db/mixins.py index 602e0cb..97b228a 100644 --- a/src/db/mixins.py +++ b/src/db/mixins.py @@ -3,14 +3,14 @@ from fastapi.encoders import jsonable_encoder from sqlalchemy import DateTime, ForeignKey, Integer, String, event, func, insert, inspect -from sqlalchemy.dialects.postgresql import JSON +from sqlalchemy.dialects.postgresql import JSON, UUID from sqlalchemy.engine import Connection from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import Mapped, Mapper, class_mapper, mapped_column, relationship from sqlalchemy.orm.attributes import get_history from src.context import auth_user_ctx, orm_diff_ctx, request_id_ctx -from src.db._types import GUID, int_pk +from src.db._types import int_pk from src.db.base import Base if TYPE_CHECKING: @@ -75,7 +75,7 @@ def audit_log(cls) -> Mapped[list["AuditLog"]]: { "__tablename__": f"{cls.__tablename__}_audit_log", "parent_id": mapped_column( - GUID, + UUID, ForeignKey(f"{cls.__tablename__}.id", ondelete="SET NULL"), nullable=True, ), diff --git a/src/db/session.py b/src/db/session.py index 1b7b576..ac98444 100644 --- a/src/db/session.py +++ b/src/db/session.py @@ -10,7 +10,6 @@ url=settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True, future=True, - connection_args={"server_settings": {"jit": "off"}}, pool_size=settings.DATABASE_POOL_SIZE, max_overflow=settings.DATABASE_POOL_MAX_OVERFLOW, ) diff --git a/src/exceptions.py b/src/exceptions.py index 44d1d2d..e4c3766 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -1,5 +1,6 @@ -from ipaddress import UUID, IPv4Address, IPv4Interface, IPv4Network, IPv6Address, IPv6Interface, IPv6Network +from ipaddress import IPv4Address, IPv4Interface, IPv4Network, IPv6Address, IPv6Interface, IPv6Network from typing import Any +from uuid import UUID from fastapi import status diff --git a/src/middlewares.py b/src/middlewares.py index 6d3b7ed..dd19410 100644 --- a/src/middlewares.py +++ b/src/middlewares.py @@ -16,14 +16,9 @@ from src.i18n import ACCEPTED_LANGUAGES -def _get_default_id() -> str: - return str(uuid.uuid4()) - - @dataclass class RequestMiddleware(BaseHTTPMiddleware): app: ASGIApp - get_default_id_func = _get_default_id dispatch_func: Callable = field(init=False) csv_mime: str = "text/csv" time_header = "x-request-time" @@ -35,7 +30,7 @@ def __post_init__(self) -> None: async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: start_time = time.time() - request_id = request.headers.get(request_id_ctx.name, self.get_default_id_func()) + request_id = str(uuid.uuid4()) request_id_ctx.set(request_id) language = request.headers.get(locale_ctx.name, locale_ctx.get()) if language not in ACCEPTED_LANGUAGES: