diff --git a/.gitignore b/.gitignore index b468f1c..5ccd8aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,9 @@ -# Byte-compiled / optimized / DLL files +``` +# Python __pycache__/ -*.py[codz] +*.py[cod] *$py.class - -# C extensions *.so - -# Distribution / packaging .Python build/ develop-eggs/ @@ -20,204 +17,51 @@ parts/ sdist/ var/ wheels/ -share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -*.lcov -.hypothesis/ -.pytest_cache/ -cover/ +# Virtual environments +venv/ +ENV/ +env/ +.venv/ +.env/ -# Translations -*.mo -*.pot +# IDE +.vscode/ +.idea/ +*.swp +*.swo -# Django stuff: +# Logs *.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -# Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -# poetry.lock -# poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -# pdm.lock -# pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -# pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi/* -!.pixi/config.toml -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule* -celerybeat.pid - -# Redis -*.rdb -*.aof -*.pid - -# RabbitMQ -mnesia/ -rabbitmq/ -rabbitmq-data/ - -# ActiveMQ -activemq-data/ - -# SageMath parsed files -*.sage.py - -# Environments +# Environment variables .env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ +.env.local +*.env.* -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site +# Coverage reports +.coverage +htmlcov/ +.coverage.* +.coverage.xml +coverage.xml -# mypy +# Testing +.pytest_cache/ .mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# 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/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ -# Temporary file for partial code execution -tempCodeRunnerFile.py - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ +# Database +*.sqlite +*.db -# Streamlit -.streamlit/secrets.toml +# Alembic +alembic/versions/*.py -# Isolated development worktrees -.worktrees/ +# OS +.DS_Store +Thumbs.db +``` \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py index e7ba38e..b768594 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -36,10 +36,7 @@ UserHasRoleModel, # noqa: F401 ) from src.modules.todo.infrastructure.models.todo_model import TodoModel # noqa: F401 -from src.modules.user.infrastructure.models.refresh_token_model import ( - UserSessionModel, # noqa: F401 -) -from src.modules.user.infrastructure.models.user_model import UserModel # noqa: F401 +from src.modules.user.infrastructure import models as user_models # noqa: F401 from src.shared.database.model import Base settings = get_settings() diff --git a/src/modules/authorization/infrastructure/models/permission_model.py b/src/modules/authorization/infrastructure/models/permission_model.py index 31c0457..50ed645 100644 --- a/src/modules/authorization/infrastructure/models/permission_model.py +++ b/src/modules/authorization/infrastructure/models/permission_model.py @@ -1,6 +1,6 @@ from uuid import UUID -from sqlalchemy import Index, String, UniqueConstraint +from sqlalchemy import ForeignKey, Index, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin @@ -24,7 +24,10 @@ class PermissionModel(Base, TimeStampMixin, SoftDeleteMixin): ) key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) - resource_id: Mapped[UUID] = mapped_column(nullable=False) + resource_id: Mapped[UUID] = mapped_column( + ForeignKey("authorization_resources.id"), + nullable=False, + ) resource: Mapped[str] = mapped_column(String(100), nullable=False) action: Mapped[str] = mapped_column(String(100), nullable=False) description: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/src/modules/authorization/infrastructure/models/role_permission_model.py b/src/modules/authorization/infrastructure/models/role_permission_model.py index 33cce9c..4c10c64 100644 --- a/src/modules/authorization/infrastructure/models/role_permission_model.py +++ b/src/modules/authorization/infrastructure/models/role_permission_model.py @@ -1,6 +1,6 @@ from uuid import UUID -from sqlalchemy import Index, UniqueConstraint +from sqlalchemy import ForeignKey, Index, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from src.shared.database.model import Base @@ -23,8 +23,11 @@ class RolePermissionModel(Base): Index("ix_role_permissions_permission_id", "permission_id"), ) - role_id: Mapped[UUID] = mapped_column(nullable=False) - permission_id: Mapped[UUID] = mapped_column(nullable=False) + role_id: Mapped[UUID] = mapped_column(ForeignKey("roles.id"), nullable=False) + permission_id: Mapped[UUID] = mapped_column( + ForeignKey("permissions.id"), + nullable=False, + ) # Relationships role: Mapped["RoleModel"] = relationship( # type: ignore[name-defined] diff --git a/src/modules/authorization/infrastructure/models/user_has_role_model.py b/src/modules/authorization/infrastructure/models/user_has_role_model.py index 3362d65..0df7600 100644 --- a/src/modules/authorization/infrastructure/models/user_has_role_model.py +++ b/src/modules/authorization/infrastructure/models/user_has_role_model.py @@ -1,6 +1,6 @@ from uuid import UUID -from sqlalchemy import Index, UniqueConstraint +from sqlalchemy import ForeignKey, Index, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from src.modules.user.infrastructure.models.user_model import UserModel @@ -23,8 +23,8 @@ class UserHasRoleModel(Base): Index("ix_user_has_roles_role_id", "role_id"), ) - user_id: Mapped[UUID] = mapped_column(nullable=False) - role_id: Mapped[UUID] = mapped_column(nullable=False) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), nullable=False) + role_id: Mapped[UUID] = mapped_column(ForeignKey("roles.id"), nullable=False) # Relationships user: Mapped["UserModel"] = relationship( # type: ignore[name-defined] diff --git a/src/modules/todo/infrastructure/models/todo_model.py b/src/modules/todo/infrastructure/models/todo_model.py index 2e38aee..bd84622 100644 --- a/src/modules/todo/infrastructure/models/todo_model.py +++ b/src/modules/todo/infrastructure/models/todo_model.py @@ -1,6 +1,6 @@ import uuid -from sqlalchemy import Boolean, String +from sqlalchemy import Boolean, ForeignKey, String from sqlalchemy.orm import Mapped, mapped_column from src.shared.database.model import Base @@ -13,4 +13,4 @@ class TodoModel(Base, TimeStampMixin, SoftDeleteMixin): title: Mapped[str] = mapped_column(String(255)) description: Mapped[str | None] = mapped_column(String(500), nullable=True) is_completed: Mapped[bool] = mapped_column(Boolean, default=False) - user_id: Mapped[uuid.UUID] = mapped_column() + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id")) diff --git a/src/modules/user/infrastructure/models/refresh_token_model.py b/src/modules/user/infrastructure/models/refresh_token_model.py index 35ae2c3..f66e772 100644 --- a/src/modules/user/infrastructure/models/refresh_token_model.py +++ b/src/modules/user/infrastructure/models/refresh_token_model.py @@ -1,6 +1,7 @@ from datetime import datetime +from uuid import UUID -from sqlalchemy import Boolean, DateTime, Index, String +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String from sqlalchemy.orm import Mapped, mapped_column, relationship from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin @@ -23,10 +24,7 @@ class UserSessionModel(Base, TimeStampMixin, SoftDeleteMixin): Index("ix_user_sessions_device_info", "device_info"), ) - user_id: Mapped[str] = mapped_column( - String(36), # UUID as string for FK - nullable=False, - ) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), nullable=False) # Session Token (hashed for security) refresh_token_hash: Mapped[str] = mapped_column(String(255), nullable=False) diff --git a/src/modules/user/infrastructure/models/user_address_model.py b/src/modules/user/infrastructure/models/user_address_model.py index 5f00154..a67eada 100644 --- a/src/modules/user/infrastructure/models/user_address_model.py +++ b/src/modules/user/infrastructure/models/user_address_model.py @@ -1,4 +1,6 @@ -from sqlalchemy import Boolean, Index, String +from uuid import UUID + +from sqlalchemy import Boolean, ForeignKey, Index, String from sqlalchemy.orm import Mapped, mapped_column, relationship from src.modules.user.infrastructure.models.user_model import UserModel @@ -21,10 +23,7 @@ class UserAddressModel(Base, TimeStampMixin, SoftDeleteMixin): Index("ix_user_addresses_country", "country"), ) - user_id: Mapped[str] = mapped_column( - String(36), # UUID as string for FK - nullable=False, - ) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), nullable=False) # Address Label (home, billing, shipping, work, etc.) label: Mapped[str] = mapped_column(String(100), nullable=False) diff --git a/src/modules/user/infrastructure/models/user_contact_model.py b/src/modules/user/infrastructure/models/user_contact_model.py index 4fd1a9e..03637f9 100644 --- a/src/modules/user/infrastructure/models/user_contact_model.py +++ b/src/modules/user/infrastructure/models/user_contact_model.py @@ -1,6 +1,7 @@ from enum import Enum +from uuid import UUID -from sqlalchemy import Boolean, Index, String +from sqlalchemy import Boolean, ForeignKey, Index, String from sqlalchemy.orm import Mapped, mapped_column, relationship from src.modules.user.infrastructure.models.user_model import UserModel @@ -31,10 +32,7 @@ class UserContactModel(Base, TimeStampMixin, SoftDeleteMixin): Index("ix_user_contacts_is_primary", "is_primary"), ) - user_id: Mapped[str] = mapped_column( - String(36), # UUID as string for FK - nullable=False, - ) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), nullable=False) # Contact Information contact_type: Mapped[str] = mapped_column(String(50), nullable=False) diff --git a/src/modules/user/infrastructure/models/user_profile_model.py b/src/modules/user/infrastructure/models/user_profile_model.py index cf4fa31..5fe5487 100644 --- a/src/modules/user/infrastructure/models/user_profile_model.py +++ b/src/modules/user/infrastructure/models/user_profile_model.py @@ -1,4 +1,6 @@ -from sqlalchemy import Date, Index, String, Text +from uuid import UUID + +from sqlalchemy import Date, ForeignKey, Index, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from src.modules.user.infrastructure.models.user_model import UserModel @@ -16,8 +18,8 @@ class UserProfileModel(Base, TimeStampMixin, SoftDeleteMixin): __tablename__ = "user_profiles" __table_args__ = (Index("ix_user_profiles_user_id", "user_id", unique=True),) - user_id: Mapped[str] = mapped_column( - String(36), # UUID as string for FK + user_id: Mapped[UUID] = mapped_column( + ForeignKey("users.id"), unique=True, nullable=False, ) diff --git a/src/modules/user/infrastructure/models/user_security_model.py b/src/modules/user/infrastructure/models/user_security_model.py index 93ff2e0..d976b48 100644 --- a/src/modules/user/infrastructure/models/user_security_model.py +++ b/src/modules/user/infrastructure/models/user_security_model.py @@ -1,6 +1,7 @@ from datetime import datetime +from uuid import UUID -from sqlalchemy import Boolean, DateTime, Index, Integer, String +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship from src.modules.user.infrastructure.models.user_model import UserModel @@ -22,8 +23,8 @@ class UserSecurityModel(Base, TimeStampMixin, SoftDeleteMixin): Index("ix_user_security_two_factor_enabled", "two_factor_enabled"), ) - user_id: Mapped[str] = mapped_column( - String(36), # UUID as string for FK + user_id: Mapped[UUID] = mapped_column( + ForeignKey("users.id"), unique=True, nullable=False, ) diff --git a/src/modules/user/infrastructure/models/user_settings_model.py b/src/modules/user/infrastructure/models/user_settings_model.py index 8e21d36..97b99b0 100644 --- a/src/modules/user/infrastructure/models/user_settings_model.py +++ b/src/modules/user/infrastructure/models/user_settings_model.py @@ -1,4 +1,6 @@ -from sqlalchemy import Index, String +from uuid import UUID + +from sqlalchemy import ForeignKey, Index from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -37,8 +39,8 @@ class UserSettingsModel(Base, TimeStampMixin, SoftDeleteMixin): Index("ix_user_settings_preferences", "preferences", postgresql_using="gin"), ) - user_id: Mapped[str] = mapped_column( - String(36), # UUID as string for FK + user_id: Mapped[UUID] = mapped_column( + ForeignKey("users.id"), unique=True, nullable=False, ) diff --git a/src/modules/user/infrastructure/models/user_verification_model.py b/src/modules/user/infrastructure/models/user_verification_model.py index f60db76..6975897 100644 --- a/src/modules/user/infrastructure/models/user_verification_model.py +++ b/src/modules/user/infrastructure/models/user_verification_model.py @@ -1,6 +1,7 @@ from datetime import datetime +from uuid import UUID -from sqlalchemy import Boolean, DateTime, Index, String +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String from sqlalchemy.orm import Mapped, mapped_column, relationship from src.modules.user.infrastructure.models.user_model import UserModel @@ -23,10 +24,7 @@ class UserVerificationModel(Base, TimeStampMixin, SoftDeleteMixin): Index("ix_user_verifications_token", "verification_token"), ) - user_id: Mapped[str] = mapped_column( - String(36), # UUID as string for FK - nullable=False, - ) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), nullable=False) # Verification Channel (email, phone, etc.) channel: Mapped[str] = mapped_column(String(50), nullable=False) diff --git a/tests/test_database_relationships.py b/tests/test_database_relationships.py new file mode 100644 index 0000000..1e04eae --- /dev/null +++ b/tests/test_database_relationships.py @@ -0,0 +1,55 @@ +import pytest +from sqlalchemy import Uuid +from sqlalchemy.orm import configure_mappers + +import src.modules.authorization.infrastructure.models.permission_model # noqa: F401 +import src.modules.authorization.infrastructure.models.resource_model # noqa: F401 +import src.modules.authorization.infrastructure.models.role_model # noqa: F401 +import src.modules.authorization.infrastructure.models.role_permission_model # noqa: F401 +import src.modules.authorization.infrastructure.models.user_has_role_model # noqa: F401 +import src.modules.todo.infrastructure.models.todo_model # noqa: F401 +import src.modules.user.infrastructure.models # noqa: F401 +from src.shared.database.model import Base + + +FOREIGN_KEYS = ( + ("permissions", "resource_id", "authorization_resources.id"), + ("role_permissions", "role_id", "roles.id"), + ("role_permissions", "permission_id", "permissions.id"), + ("todos", "user_id", "users.id"), + ("user_has_roles", "user_id", "users.id"), + ("user_has_roles", "role_id", "roles.id"), + ("user_profiles", "user_id", "users.id"), + ("user_security", "user_id", "users.id"), + ("user_settings", "user_id", "users.id"), + ("user_contacts", "user_id", "users.id"), + ("user_addresses", "user_id", "users.id"), + ("user_verifications", "user_id", "users.id"), + ("user_sessions", "user_id", "users.id"), +) + +NORMALIZED_USER_TABLES = ( + "user_profiles", + "user_security", + "user_settings", + "user_contacts", + "user_addresses", + "user_verifications", + "user_sessions", +) + + +def test_all_mappers_configure_with_declared_relationship_joins(): + configure_mappers() + + +@pytest.mark.parametrize(("table", "column", "target"), FOREIGN_KEYS) +def test_relationship_column_declares_expected_foreign_key(table, column, target): + foreign_keys = Base.metadata.tables[table].c[column].foreign_keys + + assert {foreign_key.target_fullname for foreign_key in foreign_keys} == {target} + + +@pytest.mark.parametrize("table", NORMALIZED_USER_TABLES) +def test_normalized_user_identifier_uses_uuid(table): + assert isinstance(Base.metadata.tables[table].c.user_id.type, Uuid)