Implement BaseModel class, TimestampMixin, TzDateTime type; cleanup Task model
This commit is contained in:
parent
7ce43a2bfe
commit
252c15acbd
|
|
@ -26,16 +26,22 @@ def process_revision_directives(_context, _revision, directives):
|
||||||
directives[:] = []
|
directives[:] = []
|
||||||
|
|
||||||
|
|
||||||
|
context_parameters = {
|
||||||
|
'target_metadata': BaseModel.metadata,
|
||||||
|
'process_revision_directives': process_revision_directives,
|
||||||
|
'user_module_prefix': 'tofu_types.',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline():
|
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.
|
Run migrations in 'offline' mode, which does not require an actual database engine and can be used to generate SQL scripts.
|
||||||
"""
|
"""
|
||||||
context.configure(
|
context.configure(
|
||||||
url=app.config.sqlalchemy_database_uri,
|
url=app.config.sqlalchemy_database_uri,
|
||||||
target_metadata=BaseModel.metadata,
|
|
||||||
process_revision_directives=process_revision_directives,
|
|
||||||
literal_binds=True,
|
literal_binds=True,
|
||||||
dialect_opts={"paramstyle": "named"},
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
**context_parameters,
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
|
|
@ -49,8 +55,7 @@ def run_migrations_online():
|
||||||
with db.engine.connect() as connection:
|
with db.engine.connect() as connection:
|
||||||
context.configure(
|
context.configure(
|
||||||
connection=connection,
|
connection=connection,
|
||||||
target_metadata=BaseModel.metadata,
|
**context_parameters,
|
||||||
process_revision_directives=process_revision_directives,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ Create Date: ${create_date}
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
import tofu_api.common.database.types as tofu_types
|
||||||
${imports if imports else ""}
|
${imports if imports else ""}
|
||||||
|
|
||||||
# Revision identifiers, used by Alembic.
|
# Revision identifiers, used by Alembic.
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
"""
|
"""
|
||||||
Initial revision
|
Initial revision
|
||||||
|
|
||||||
Revision ID: 716726b4a1fe
|
Revision ID: 044f3afd19b0
|
||||||
Revises:
|
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
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
import tofu_api.common.database.types as tofu_types
|
||||||
|
|
||||||
# Revision identifiers, used by Alembic.
|
# Revision identifiers, used by Alembic.
|
||||||
revision = '716726b4a1fe'
|
revision = '044f3afd19b0'
|
||||||
down_revision = None
|
down_revision = None
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
@ -19,16 +21,18 @@ depends_on = None
|
||||||
def upgrade():
|
def upgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.create_table(
|
op.create_table(
|
||||||
'tasks',
|
'task',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
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('title', sa.String(length=255), nullable=False),
|
||||||
sa.Column('description', sa.Text(), 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 ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.drop_table('tasks')
|
op.drop_table('task')
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
@ -6,6 +6,7 @@ from flask import Flask
|
||||||
|
|
||||||
from tofu_api.api import TofuApiBlueprint
|
from tofu_api.api import TofuApiBlueprint
|
||||||
from tofu_api.common.config import Config
|
from tofu_api.common.config import Config
|
||||||
|
from tofu_api.common.json import JSONEncoder
|
||||||
from tofu_api.dependencies import Dependencies
|
from tofu_api.dependencies import Dependencies
|
||||||
|
|
||||||
# Enable deprecation warnings in dev environment
|
# Enable deprecation warnings in dev environment
|
||||||
|
|
@ -19,6 +20,7 @@ class App(Flask):
|
||||||
"""
|
"""
|
||||||
# Override Flask classes
|
# Override Flask classes
|
||||||
config_class = Config
|
config_class = Config
|
||||||
|
json_encoder = JSONEncoder
|
||||||
|
|
||||||
# Set type hint for config
|
# Set type hint for config
|
||||||
config: Config
|
config: Config
|
||||||
|
|
@ -57,7 +59,7 @@ class App(Flask):
|
||||||
db = self.dependencies.get_sqlalchemy()
|
db = self.dependencies.get_sqlalchemy()
|
||||||
db.init_database(self)
|
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)
|
import tofu_api.models # noqa (unused import)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
|
from .metadata import MetaData
|
||||||
from .sqlalchemy import SQLAlchemy
|
from .sqlalchemy import SQLAlchemy
|
||||||
|
from .typing import Col, Rel
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from .tz_date_time import TzDateTime
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from .json_encoder import JSONEncoder
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Base model first
|
# Base model first
|
||||||
from .base import metadata, BaseModel
|
from .base import BaseModel
|
||||||
|
|
||||||
# Data models
|
# Data models
|
||||||
from .task import Task
|
from .task import Task
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,62 @@
|
||||||
from sqlalchemy import MetaData
|
from typing import Any, Iterable, Optional
|
||||||
from sqlalchemy.orm import declarative_base
|
|
||||||
|
from sqlalchemy import Column, Integer, inspect
|
||||||
|
from sqlalchemy.orm import InstanceState, as_declarative
|
||||||
|
|
||||||
|
from tofu_api.common.database import Col, MetaData
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'metadata',
|
|
||||||
'BaseModel',
|
'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
|
@as_declarative(name='BaseModel', metadata=MetaData())
|
||||||
metadata = MetaData(naming_convention=_naming_convention)
|
class BaseModel:
|
||||||
|
"""
|
||||||
|
Declarative base class for database models.
|
||||||
|
"""
|
||||||
|
|
||||||
# Generate declarative base class for database models
|
# Default primary key
|
||||||
BaseModel = declarative_base(name='BaseModel', metadata=metadata)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
from .base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class Task(BaseModel):
|
class Task(TimestampMixin, BaseModel):
|
||||||
"""
|
"""
|
||||||
Database model for tasks.
|
Database model for tasks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = 'tasks'
|
__tablename__ = 'task'
|
||||||
|
|
||||||
id: int = Column(Integer, nullable=False, primary_key=True)
|
title: Col[str] = Column(String(255), nullable=False)
|
||||||
# TODO: created_at, modified_at
|
description: Col[str] = Column(Text, nullable=False, default='')
|
||||||
|
|
||||||
title: str = Column(String(255), nullable=False)
|
def __repr__(self):
|
||||||
description: str = Column(Text, nullable=False, default='')
|
return self._repr(id=self.id, title=self.title)
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue