diff --git a/migrations/env.py b/migrations/env.py index bd16fa2..f8be1e4 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -26,16 +26,22 @@ def process_revision_directives(_context, _revision, directives): directives[:] = [] +context_parameters = { + 'target_metadata': BaseModel.metadata, + 'process_revision_directives': process_revision_directives, + 'user_module_prefix': 'tofu_types.', +} + + def run_migrations_offline(): """ Run migrations in 'offline' mode, which does not require an actual database engine and can be used to generate SQL scripts. """ context.configure( url=app.config.sqlalchemy_database_uri, - target_metadata=BaseModel.metadata, - process_revision_directives=process_revision_directives, literal_binds=True, dialect_opts={"paramstyle": "named"}, + **context_parameters, ) with context.begin_transaction(): @@ -49,8 +55,7 @@ def run_migrations_online(): with db.engine.connect() as connection: context.configure( connection=connection, - target_metadata=BaseModel.metadata, - process_revision_directives=process_revision_directives, + **context_parameters, ) with context.begin_transaction(): diff --git a/migrations/script.py.mako b/migrations/script.py.mako index 0b72244..2889cd5 100644 --- a/migrations/script.py.mako +++ b/migrations/script.py.mako @@ -8,6 +8,8 @@ Create Date: ${create_date} from alembic import op import sqlalchemy as sa + +import tofu_api.common.database.types as tofu_types ${imports if imports else ""} # Revision identifiers, used by Alembic. diff --git a/migrations/versions/2022_04_15_1605-716726b4a1fe_initial_revision.py b/migrations/versions/2022_04_15_2141-044f3afd19b0_initial_revision.py similarity index 57% rename from migrations/versions/2022_04_15_1605-716726b4a1fe_initial_revision.py rename to migrations/versions/2022_04_15_2141-044f3afd19b0_initial_revision.py index ca8bee8..36f414a 100644 --- a/migrations/versions/2022_04_15_1605-716726b4a1fe_initial_revision.py +++ b/migrations/versions/2022_04_15_2141-044f3afd19b0_initial_revision.py @@ -1,16 +1,18 @@ """ Initial revision -Revision ID: 716726b4a1fe +Revision ID: 044f3afd19b0 Revises: -Create Date: 2022-04-15 16:05:29.358429+02:00 +Create Date: 2022-04-15 21:41:39.962542+02:00 """ from alembic import op import sqlalchemy as sa +import tofu_api.common.database.types as tofu_types + # Revision identifiers, used by Alembic. -revision = '716726b4a1fe' +revision = '044f3afd19b0' down_revision = None branch_labels = None depends_on = None @@ -19,16 +21,18 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table( - 'tasks', + 'task', sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', tofu_types.TzDateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('modified_at', tofu_types.TzDateTime(), server_default=sa.text('now()'), nullable=False), sa.Column('title', sa.String(length=255), nullable=False), sa.Column('description', sa.Text(), nullable=False), - sa.PrimaryKeyConstraint('id', name=op.f('pk_tasks')) + sa.PrimaryKeyConstraint('id', name=op.f('pk_task')) ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('tasks') + op.drop_table('task') # ### end Alembic commands ### diff --git a/tofu_api/app.py b/tofu_api/app.py index c7f079a..86987e8 100644 --- a/tofu_api/app.py +++ b/tofu_api/app.py @@ -6,6 +6,7 @@ from flask import Flask from tofu_api.api import TofuApiBlueprint from tofu_api.common.config import Config +from tofu_api.common.json import JSONEncoder from tofu_api.dependencies import Dependencies # Enable deprecation warnings in dev environment @@ -19,6 +20,7 @@ class App(Flask): """ # Override Flask classes config_class = Config + json_encoder = JSONEncoder # Set type hint for config config: Config @@ -57,7 +59,7 @@ class App(Flask): db = self.dependencies.get_sqlalchemy() db.init_database(self) - # Import models to fill the metadata object + # Import models to populate the database metadata import tofu_api.models # noqa (unused import) diff --git a/tofu_api/common/database/__init__.py b/tofu_api/common/database/__init__.py index 479c708..2159e34 100644 --- a/tofu_api/common/database/__init__.py +++ b/tofu_api/common/database/__init__.py @@ -1 +1,3 @@ +from .metadata import MetaData from .sqlalchemy import SQLAlchemy +from .typing import Col, Rel diff --git a/tofu_api/common/database/metadata.py b/tofu_api/common/database/metadata.py new file mode 100644 index 0000000..c3e30e3 --- /dev/null +++ b/tofu_api/common/database/metadata.py @@ -0,0 +1,25 @@ +from sqlalchemy import MetaData as _MetaData + +__all__ = [ + 'MetaData', +] + + +class MetaData(_MetaData): + """ + App specific subclass of the SQLAlchemy MetaData class. + """ + + # Define naming convention for constraints + _naming_convention = { + "ix": 'ix_%(column_0_label)s', + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" + } + + def __init__(self, *args, naming_convention=None, **kwargs): + if not naming_convention: + naming_convention = self._naming_convention + super().__init__(*args, naming_convention=naming_convention, **kwargs) diff --git a/tofu_api/common/database/mixins.py b/tofu_api/common/database/mixins.py new file mode 100644 index 0000000..82044e8 --- /dev/null +++ b/tofu_api/common/database/mixins.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from sqlalchemy import Column, func +from sqlalchemy.orm import declarative_mixin + +from tofu_api.common.database import Col +from tofu_api.common.database.types import TzDateTime + +__all__ = [ + 'TimestampMixin' +] + + +@declarative_mixin +class TimestampMixin: + """ + Mixin for database models that provides the "created_at" and "modified_at" columns. + """ + # Created timestamp (automatically set to NOW() once on object creation) + created_at: Col[datetime] = Column(TzDateTime, nullable=False, server_default=func.now()) + + # Modified timestamp (automatically set to NOW() on each update) + modified_at: Col[datetime] = Column(TzDateTime, nullable=False, server_default=func.now(), onupdate=func.now()) diff --git a/tofu_api/common/database/types/__init__.py b/tofu_api/common/database/types/__init__.py new file mode 100644 index 0000000..98d93c0 --- /dev/null +++ b/tofu_api/common/database/types/__init__.py @@ -0,0 +1 @@ +from .tz_date_time import TzDateTime diff --git a/tofu_api/common/database/types/tz_date_time.py b/tofu_api/common/database/types/tz_date_time.py new file mode 100644 index 0000000..b6c7fb5 --- /dev/null +++ b/tofu_api/common/database/types/tz_date_time.py @@ -0,0 +1,39 @@ +from datetime import datetime, timezone + +from sqlalchemy import DateTime, TypeDecorator + +__all__ = [ + 'TzDateTime', +] + + +class TzDateTime(TypeDecorator): + """ + Custom SQLAlchemy data type for timezone aware datetimes. + """ + impl = DateTime + cache_ok = True + + @property + def python_type(self): + return datetime + + def process_bind_param(self, value: datetime, dialect): + """ + Convert a datetime object that is bound to a query parameter. + """ + if value is not None and value.tzinfo: + value = value.astimezone(timezone.utc).replace(tzinfo=None) + return value + + def process_result_value(self, value: datetime, dialect): + """ + Convert a datetime object from a query result. + """ + return value.replace(tzinfo=timezone.utc) if value is not None else None + + def process_literal_param(self, value: datetime, dialect): + """ + Convert a literal parameter value to be rendered inline within a statement. + """ + return self.process_bind_param(value, dialect) diff --git a/tofu_api/common/database/typing.py b/tofu_api/common/database/typing.py new file mode 100644 index 0000000..eed5f50 --- /dev/null +++ b/tofu_api/common/database/typing.py @@ -0,0 +1,14 @@ +from typing import TypeVar, Union + +from sqlalchemy import Column +from sqlalchemy.orm import RelationshipProperty + +__all__ = [ + 'Col', + 'Rel', +] + +# Define type aliases for SQLAlchemy columns and relationships in declarative models +_T = TypeVar('_T') +Col = Union[Column, _T] +Rel = Union[RelationshipProperty, _T] diff --git a/tofu_api/common/json/__init__.py b/tofu_api/common/json/__init__.py new file mode 100644 index 0000000..b8f935f --- /dev/null +++ b/tofu_api/common/json/__init__.py @@ -0,0 +1 @@ +from .json_encoder import JSONEncoder diff --git a/tofu_api/common/json/json_encoder.py b/tofu_api/common/json/json_encoder.py new file mode 100644 index 0000000..11820bb --- /dev/null +++ b/tofu_api/common/json/json_encoder.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import Any + +from flask.json import JSONEncoder as _FlaskJSONEncoder + +__all__ = [ + 'JSONEncoder', +] + + +class JSONEncoder(_FlaskJSONEncoder): + """ + Custom JSON encoder built on top of the Flask JSONEncoder class. + """ + + def default(self, obj: Any) -> Any: + """ + Convert any object to a JSON serializable type. + """ + # Convert datetimes to ISO format without microseconds (e.g. '2022-01-02T10:20:30+00:00') + if isinstance(obj, datetime): + return obj.isoformat(timespec='seconds') + + # Use to_dict() method on objects that have it + if hasattr(obj, 'to_dict'): + return obj.to_dict() + + # Fallback to the Flask JSONEncoder + return super().default(obj) diff --git a/tofu_api/models/__init__.py b/tofu_api/models/__init__.py index 55d01aa..d3d40a4 100644 --- a/tofu_api/models/__init__.py +++ b/tofu_api/models/__init__.py @@ -1,5 +1,5 @@ # Base model first -from .base import metadata, BaseModel +from .base import BaseModel # Data models from .task import Task diff --git a/tofu_api/models/base.py b/tofu_api/models/base.py index e611329..342f9b3 100644 --- a/tofu_api/models/base.py +++ b/tofu_api/models/base.py @@ -1,22 +1,62 @@ -from sqlalchemy import MetaData -from sqlalchemy.orm import declarative_base +from typing import Any, Iterable, Optional + +from sqlalchemy import Column, Integer, inspect +from sqlalchemy.orm import InstanceState, as_declarative + +from tofu_api.common.database import Col, MetaData __all__ = [ - 'metadata', 'BaseModel', ] -# Define naming convention for constraints -_naming_convention = { - "ix": 'ix_%(column_0_label)s', - "uq": "uq_%(table_name)s_%(column_0_name)s", - "ck": "ck_%(table_name)s_%(constraint_name)s", - "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", - "pk": "pk_%(table_name)s" -} -# Create metadata object for database schemas -metadata = MetaData(naming_convention=_naming_convention) +@as_declarative(name='BaseModel', metadata=MetaData()) +class BaseModel: + """ + Declarative base class for database models. + """ -# Generate declarative base class for database models -BaseModel = declarative_base(name='BaseModel', metadata=metadata) + # Default primary key + id: Col[int] = Column(Integer, nullable=False, primary_key=True) + + def __repr__(self) -> str: + """ + Return a string representation of this object. + """ + return self._repr(id=self.id) if hasattr(self, 'id') else self._repr() + + def _repr(self, **fields) -> str: + """ + Helper method for implementing __repr__. + """ + state: InstanceState = inspect(self) + state_str = f' [transient {id(self)}]' if state.transient \ + else f' [pending {id(self)}]' if state.pending \ + else ' [deleted]' if state.deleted \ + else ' [detached]' if state.detached else '' + param_str = ', '.join([f'{key}={value!r}' for key, value in fields.items()] if fields else state.identity or []) + return f'<{type(self).__name__}({param_str}){state_str}>' + + def to_dict( + self, + *, + fields: Optional[Iterable[str]] = None, + exclude: Optional[Iterable[str]] = None, + ) -> dict[str, Any]: + """ + Return the object's data as a dictionary. + + By default, the dictionary will contain all table columns (with their column name as key) defined in the model. This can be + overridden by setting the `fields` and/or `exclude` parameters, in which case only fields that are listed in `fields` will be + included in the dictionary, except for fields listed in `exclude`. + """ + # Determine fields to include in dictionary (starting will all table columns) + included_fields = set(column.name for column in self.__table__.columns) + if fields is not None: + included_fields.intersection_update(fields) + if exclude is not None: + included_fields.difference_update(exclude) + + return { + field: getattr(self, field) for field in included_fields + } diff --git a/tofu_api/models/task.py b/tofu_api/models/task.py index af8bc11..bd9e17b 100644 --- a/tofu_api/models/task.py +++ b/tofu_api/models/task.py @@ -1,25 +1,19 @@ -from sqlalchemy import Column, Integer, String, Text +from sqlalchemy import Column, String, Text +from tofu_api.common.database import Col +from tofu_api.common.database.mixins import TimestampMixin from .base import BaseModel -class Task(BaseModel): +class Task(TimestampMixin, BaseModel): """ Database model for tasks. """ - __tablename__ = 'tasks' + __tablename__ = 'task' - id: int = Column(Integer, nullable=False, primary_key=True) - # TODO: created_at, modified_at + title: Col[str] = Column(String(255), nullable=False) + description: Col[str] = Column(Text, nullable=False, default='') - title: str = Column(String(255), nullable=False) - description: str = Column(Text, nullable=False, default='') - - def to_dict(self) -> dict: - # TODO: Implement a generic to_dict() in the base model - return { - 'id': self.id, - 'title': self.title, - 'description': self.description, - } + def __repr__(self): + return self._repr(id=self.id, title=self.title)