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[:] = []
|
||||
|
||||
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
from .metadata import MetaData
|
||||
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
|
||||
from .base import metadata, BaseModel
|
||||
from .base import BaseModel
|
||||
|
||||
# Data models
|
||||
from .task import Task
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue