diff --git a/jobs/furnishings/requirements.txt b/jobs/furnishings/requirements.txt index 335bb81deb..68eedb1826 100644 --- a/jobs/furnishings/requirements.txt +++ b/jobs/furnishings/requirements.txt @@ -33,3 +33,4 @@ requests==2.25.1 cachelib==0.9.0 git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api git+https://github.com/bcgov/business-schemas.git@2.15.38#egg=registry_schemas +git+https://github.com/bcgov/lear.git#egg=sql-versioning&subdirectory=python/common/sql-versioning diff --git a/jobs/furnishings/tests/unit/__init__.py b/jobs/furnishings/tests/unit/__init__.py index 06e205683a..3c06391fa8 100644 --- a/jobs/furnishings/tests/unit/__init__.py +++ b/jobs/furnishings/tests/unit/__init__.py @@ -18,9 +18,10 @@ from datedelta import datedelta from freezegun import freeze_time + from legal_api.models import Address, Batch, BatchProcessing, Business, Filing, Furnishing, db from legal_api.models.colin_event_id import ColinEventId -from sqlalchemy_continuum import versioning_manager +from legal_api.models.db import versioning_manager EPOCH_DATETIME = datetime.datetime.utcfromtimestamp(0).replace(tzinfo=datetime.timezone.utc) diff --git a/legal-api/flags.json b/legal-api/flags.json index a86122496a..fe00fd30cd 100644 --- a/legal-api/flags.json +++ b/legal-api/flags.json @@ -9,6 +9,19 @@ "involuntary-dissolution-filter": { "include-accounts": [], "exclude-accounts": [] + }, + "db-versioning": { + "legal-api": false, + "emailer": false, + "filer": false, + "entity-bn": false, + "digital-credentials": false, + "dissolutions-job": false, + "furnishings-job": false, + "emailer-reminder-job": false, + "future-effective-job": false, + "update-colin-filings-job": false, + "update-legal-filings-job": false } } } diff --git a/legal-api/requirements.txt b/legal-api/requirements.txt index e84ab5a754..80dfadb843 100755 --- a/legal-api/requirements.txt +++ b/legal-api/requirements.txt @@ -60,3 +60,4 @@ reportlab==3.6.12 html-sanitizer==2.4.1 lxml==5.2.2 git+https://github.com/bcgov/business-schemas.git@2.18.31#egg=registry_schemas +git+https://github.com/bcgov/lear.git#egg=sql-versioning&subdirectory=python/common/sql-versioning diff --git a/legal-api/src/legal_api/__init__.py b/legal-api/src/legal_api/__init__.py index 581f13fbd9..985ad4a763 100644 --- a/legal-api/src/legal_api/__init__.py +++ b/legal-api/src/legal_api/__init__.py @@ -25,6 +25,7 @@ from legal_api import config, models from legal_api.models import db +from legal_api.models.db import init_db from legal_api.resources import endpoints from legal_api.schemas import rsbc_schemas from legal_api.services import digital_credentials, flags, queue @@ -54,7 +55,7 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'production')): send_default_pii=False ) - db.init_app(app) + init_db(app) rsbc_schemas.init_app(app) flags.init_app(app) queue.init_app(app) diff --git a/legal-api/src/legal_api/config.py b/legal-api/src/legal_api/config.py index 54eac925f5..0b35323e3a 100644 --- a/legal-api/src/legal_api/config.py +++ b/legal-api/src/legal_api/config.py @@ -58,6 +58,7 @@ class _Config(): # pylint: disable=too-few-public-methods Used as the base for all the other configurations. """ + SERVICE_NAME = 'legal-api' PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) LEGAL_API_BASE_URL = os.getenv('LEGAL_API_BASE_URL', 'https://LEGAL_API_BASE_URL/api/v1/businesses') diff --git a/legal-api/src/legal_api/models/address.py b/legal-api/src/legal_api/models/address.py index 91c78bb126..23c5512fb9 100644 --- a/legal-api/src/legal_api/models/address.py +++ b/legal-api/src/legal_api/models/address.py @@ -16,11 +16,12 @@ Currently this only provides API versioning information """ import pycountry +from sql_versioning import Versioned from .db import db -class Address(db.Model): # pylint: disable=too-many-instance-attributes +class Address(db.Model, Versioned): # pylint: disable=too-many-instance-attributes """This class manages all of the business addresses. Every business is required to have 2 addresses on record, DELIVERY and MAILING. diff --git a/legal-api/src/legal_api/models/alias.py b/legal-api/src/legal_api/models/alias.py index 5d0fbe9017..1352021a66 100644 --- a/legal-api/src/legal_api/models/alias.py +++ b/legal-api/src/legal_api/models/alias.py @@ -16,10 +16,12 @@ from enum import Enum +from sql_versioning import Versioned + from .db import db -class Alias(db.Model): # pylint: disable=too-many-instance-attributes +class Alias(db.Model, Versioned): # pylint: disable=too-many-instance-attributes """This class manages the aliases.""" class AliasType(Enum): diff --git a/legal-api/src/legal_api/models/amalgamating_business.py b/legal-api/src/legal_api/models/amalgamating_business.py index 8b2d49fe4a..aea42243b7 100644 --- a/legal-api/src/legal_api/models/amalgamating_business.py +++ b/legal-api/src/legal_api/models/amalgamating_business.py @@ -18,6 +18,7 @@ from enum import auto +from sql_versioning import Versioned from sqlalchemy import or_ from sqlalchemy_continuum import version_class @@ -25,7 +26,7 @@ from .db import db -class AmalgamatingBusiness(db.Model): # pylint: disable=too-many-instance-attributes +class AmalgamatingBusiness(db.Model, Versioned): # pylint: disable=too-many-instance-attributes """This class manages the amalgamating businesses.""" # pylint: disable=invalid-name diff --git a/legal-api/src/legal_api/models/amalgamation.py b/legal-api/src/legal_api/models/amalgamation.py index 8fd34d1315..40650a26c0 100644 --- a/legal-api/src/legal_api/models/amalgamation.py +++ b/legal-api/src/legal_api/models/amalgamation.py @@ -19,6 +19,7 @@ from enum import auto +from sql_versioning import Versioned from sqlalchemy import or_ from sqlalchemy_continuum import version_class @@ -26,7 +27,7 @@ from .db import db -class Amalgamation(db.Model): # pylint: disable=too-many-instance-attributes +class Amalgamation(db.Model, Versioned): # pylint: disable=too-many-instance-attributes """This class manages the amalgamations.""" # pylint: disable=invalid-name diff --git a/legal-api/src/legal_api/models/business.py b/legal-api/src/legal_api/models/business.py index 63a6e8a03e..8b29c93885 100644 --- a/legal-api/src/legal_api/models/business.py +++ b/legal-api/src/legal_api/models/business.py @@ -22,6 +22,7 @@ import datedelta import pytz from flask import current_app +from sql_versioning import Versioned from sqlalchemy.exc import OperationalError, ResourceClosedError from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import aliased, backref @@ -50,7 +51,7 @@ from .user import User # noqa: F401,I003 pylint: disable=unused-import; needed by the SQLAlchemy backref -class Business(db.Model): # pylint: disable=too-many-instance-attributes,disable=too-many-public-methods +class Business(db.Model, Versioned): # pylint: disable=too-many-instance-attributes,disable=too-many-public-methods """This class manages all of the base data about a business. A business is base form of any entity that can interact directly diff --git a/legal-api/src/legal_api/models/corp_type.py b/legal-api/src/legal_api/models/corp_type.py index 61efd855aa..32303ad2c5 100644 --- a/legal-api/src/legal_api/models/corp_type.py +++ b/legal-api/src/legal_api/models/corp_type.py @@ -20,7 +20,6 @@ class CorpType(db.Model): # pylint: disable=too-many-instance-attributes """This class manages the corp type.""" - __versioned__ = {} __tablename__ = 'corp_types' corp_type_cd = db.Column('corp_type_cd', db.String(5), primary_key=True) diff --git a/legal-api/src/legal_api/models/db.py b/legal-api/src/legal_api/models/db.py index 8205d363ac..67b3916632 100644 --- a/legal-api/src/legal_api/models/db.py +++ b/legal-api/src/legal_api/models/db.py @@ -15,13 +15,279 @@ These will get initialized by the application using the models """ -from flask_sqlalchemy import SQLAlchemy +from datetime import datetime + +from flask import current_app +from flask_sqlalchemy import SignallingSession, SQLAlchemy +from sql_versioning import TransactionManager, debug +from sql_versioning import disable_versioning as _new_disable_versioning +from sql_versioning import enable_versioning as _new_enable_versioning +from sql_versioning import version_class as _new_version_class +from sqlalchemy import event, orm +from sqlalchemy.orm import Session, mapper from sqlalchemy_continuum import make_versioned +from sqlalchemy_continuum import version_class as _old_version_class +from sqlalchemy_continuum.manager import VersioningManager # by convention in the Flask community these are lower case, # whereas pylint wants them upper case db = SQLAlchemy() # pylint: disable=invalid-name -# make_versioned(user_cls=None, plugins=[FlaskPlugin()]) -make_versioned(user_cls=None) + +class Transaction(db.Model): + """This class manages the transaction.""" + + __tablename__ = 'transaction' + + id = db.Column( + db.BigInteger, + db.Sequence('transaction_id_seq'), + primary_key=True, + autoincrement=True + ) + remote_addr = db.Column(db.String(50), nullable=True) + issued_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=True) + + +def init_db(app): + """Initialize database using flask app and configure db mappers. + + :param app: Flask app + :return: None + """ + db.init_app(app) + orm.configure_mappers() + + +# TODO: remove versioning switching logic +# TODO: remove debugging variables, messages, and decorators +versioning_manager = VersioningManager(transaction_cls=Transaction) + + +def _old_enable_versioning(): + """Enable old versioning. + + :return: None + """ + versioning_manager.track_operations(mapper) + versioning_manager.track_session(Session) + + +def _old_disable_versioning(): + """Disable old versioning. + + :return: None + """ + versioning_manager.remove_operations_tracking(mapper) + versioning_manager.remove_session_tracking(Session) + + +def _old_get_transaction_id(session): + """Get the transaction ID using the old versioning. + + :param session: The database session instance. + :return: The transaction ID + """ + uow = versioning_manager.unit_of_work(session) + transaction = uow.create_transaction(session) + return transaction.id + + +def _new_get_transaction_id(session): + """Get the transaction ID using the new versioning. + + :param session: The database session instance. + :return: The transaction ID + """ + new_transaction_manager = TransactionManager(session) + return new_transaction_manager.create_transaction() + + +class VersioningProxy: + """A proxy class to handle switching between old and new versioning extension.""" + + _current_versioning = None # only used to set a session's versioning if this session is unset + _is_initialized = False + + _versioning_control = { + 'old': { + 'enable': _old_enable_versioning, + 'disable': _old_disable_versioning, + 'version_class': _old_version_class, + 'get_transaction_id': _old_get_transaction_id + }, + 'new': { + 'enable': _new_enable_versioning, + 'disable': _new_disable_versioning, + 'version_class': _new_version_class, + 'get_transaction_id': _new_get_transaction_id + } + } + + @classmethod + def _check_versioning(cls): + """Check which versioning should be used based on feature flag. + + :return: None + """ + from legal_api.services import flags # pylint: disable=import-outside-toplevel + current_service = current_app.config['SERVICE_NAME'] + db_versioning = flags.value('db-versioning') + use_new_versioning = (bool(db_versioning) and bool(db_versioning.get(current_service))) + cls._current_versioning = 'new' if use_new_versioning else 'old' + print(f'\033[31mCurrent versioning={cls._current_versioning}\033[0m') + + @classmethod + def _initialize_versioning(cls): + """Initialize versioning. + + :return: None + """ + cls._is_initialized = True + cls._check_versioning() + disabled = 'new' if cls._current_versioning == 'old' else 'old' + cls._versioning_control[disabled]['disable']() + + @classmethod + def _switch_versioning(cls, previous, current): + """Switch versioning from one to the other. + + :param previous: The previously used versioning. + :param current: The versioning system to switch to. + :return: None + """ + cls._versioning_control[previous]['disable']() + cls._versioning_control[current]['enable']() + + @classmethod + @debug + def lock_versioning(cls, session, transaction): + """Lock versioning for the session. + + This ensures that only one versioning extension is enabled throughout the session. + + :param session: The database session instance. + :param transaction: The transaction associated with the session. + :return: None + """ + print(f"\033[32mCurrent service={current_app.config['SERVICE_NAME']}, session={session}," + f' transaction={transaction}\033[0m') + if '_versioning_locked' not in session.info: + if not cls._is_initialized: + cls._initialize_versioning() + print(f'\033[31mVersioning locked, current versioning type={cls._current_versioning}' + '(initialized)\033[0m') + else: + previous_versioning = cls._current_versioning + cls._check_versioning() + + # TODO: remove debug - lock_type + lock_type = 'unchanged' + if cls._current_versioning != previous_versioning: + cls._switch_versioning(previous_versioning, cls._current_versioning) + lock_type = 'switched' + + print(f'\033[31mVersioning locked, current versioning type={cls._current_versioning}' + f'({lock_type})\033[0m') + + session.info['_versioning_locked'] = cls._current_versioning + session.info['_transactions_locked'] = [] + + # TODO: remove debug - else statement + else: + print('\033[31mVersioning already set for this session, skip\033[0m') + + session.info['_transactions_locked'].append(transaction) + + @classmethod + @debug + def unlock_versioning(cls, session, transaction): + """Unlock versioning for the session. + + It is unlocked once all the active transactions are complete. + + :param session: The database session instance. + :param transaction: The transaction associated with the session. + :return: None + """ + print(f'\033[32mSession={session}, transaction={transaction}\033[0m') + + if '_versioning_locked' in session.info and '_transactions_locked' in session.info: + session.info['_transactions_locked'].remove(transaction) + print('\033[31mTransaction unlocked\033[0m') + + if not session.info['_transactions_locked']: + session.info.pop('_versioning_locked', None) + session.info.pop('_transactions_locked', None) + print('\033[31mVersioning unlocked\033[0m') + + # TODO: remove debug - else statement + else: + print("\033[32mThis session has active transaction, can't be unlocked\033[0m") + + # TODO: remove debug - else statement + else: + print("\033[32mVersioning/Transaction lock doesn't exist, skip\033[0m") + + @classmethod + @debug + def get_transaction_id(cls, session): + """Get the transaction ID for the session. + + :param session: The database session instance. + :return: The transaction ID. + """ + transaction_id = None + current_versioning = session.info['_versioning_locked'] + + print(f'\033[31mCurrent versioning type={current_versioning}\033[0m') + transaction_id = cls._versioning_control[current_versioning]['get_transaction_id'](session) + print(f'\033[31mUsing transaction_id = {transaction_id}\033[0m') + + return transaction_id + + @classmethod + @debug + def version_class(cls, session, obj): + """Return version class for an object based in the session. + + :param session: The database session instance. + :param obj: The object for which the version class is needed. + :return: The version class of the object. + """ + if not session.in_transaction(): # trigger versioning setup listener + session.begin() + + current_versioning = session.info['_versioning_locked'] + print(f'\033[31mCurrent versioning type={current_versioning}\033[0m') + + return cls._versioning_control[current_versioning]['version_class'](obj) + + +@debug +def setup_versioning(): + """Set up and initialize versioining switching. + + :return: None + """ + # use SignallingSession to skip events for continuum's internal session/txn operations + @event.listens_for(SignallingSession, 'after_transaction_create') + @debug + def after_transaction_create(session, transaction): + VersioningProxy.lock_versioning(session, transaction) + + @event.listens_for(SignallingSession, 'after_transaction_end') + @debug + def clear_transaction(session, transaction): + VersioningProxy.unlock_versioning(session, transaction) + + _new_enable_versioning(transaction_cls=Transaction) + make_versioned(user_cls=None, manager=versioning_manager) + + +# TODO: enable versioning switching +# it should be called before data model initialzed, otherwise, old versioning doesn't work properly +# setup_versioning() + +make_versioned(user_cls=None, manager=versioning_manager) diff --git a/legal-api/src/legal_api/models/document.py b/legal-api/src/legal_api/models/document.py index 0373d37a43..0017990742 100644 --- a/legal-api/src/legal_api/models/document.py +++ b/legal-api/src/legal_api/models/document.py @@ -20,6 +20,7 @@ from enum import Enum +from sql_versioning import Versioned from sqlalchemy import desc from .db import db @@ -36,7 +37,7 @@ class DocumentType(Enum): DIRECTOR_AFFIDAVIT = 'director_affidavit' -class Document(db.Model): +class Document(db.Model, Versioned): """This is the model for a document.""" __versioned__ = {} diff --git a/legal-api/src/legal_api/models/jurisdiction.py b/legal-api/src/legal_api/models/jurisdiction.py index f14d97ce76..1705fc4188 100644 --- a/legal-api/src/legal_api/models/jurisdiction.py +++ b/legal-api/src/legal_api/models/jurisdiction.py @@ -14,11 +14,13 @@ """This module holds the data about jurisdiction.""" from __future__ import annotations +from sql_versioning import Versioned + from .db import db from .filing import Filing -class Jurisdiction(db.Model): # pylint: disable=too-many-instance-attributes +class Jurisdiction(db.Model, Versioned): # pylint: disable=too-many-instance-attributes """This class manages the jurisdiction.""" __versioned__ = {} diff --git a/legal-api/src/legal_api/models/office.py b/legal-api/src/legal_api/models/office.py index 622dc58c03..f04d16e37d 100644 --- a/legal-api/src/legal_api/models/office.py +++ b/legal-api/src/legal_api/models/office.py @@ -17,10 +17,12 @@ """ +from sql_versioning import Versioned + from .db import db -class Office(db.Model): # pylint: disable=too-few-public-methods +class Office(db.Model, Versioned): # pylint: disable=too-few-public-methods """This is the object mapping for the Office entity. An office is associated with one business, and 0...n addresses diff --git a/legal-api/src/legal_api/models/party.py b/legal-api/src/legal_api/models/party.py index 4b737410f3..ef09724ad0 100644 --- a/legal-api/src/legal_api/models/party.py +++ b/legal-api/src/legal_api/models/party.py @@ -17,15 +17,16 @@ from enum import Enum from http import HTTPStatus +from sql_versioning import Versioned from sqlalchemy import event from legal_api.exceptions import BusinessException -from .db import db # noqa: I001 from .address import Address # noqa: I001,I003,F401 pylint: disable=unused-import; needed by the SQLAlchemy rel +from .db import db # noqa: I001 -class Party(db.Model): # pylint: disable=too-many-instance-attributes +class Party(db.Model, Versioned): # pylint: disable=too-many-instance-attributes """This class manages all of the parties (people and organizations).""" class PartyTypes(Enum): diff --git a/legal-api/src/legal_api/models/party_role.py b/legal-api/src/legal_api/models/party_role.py index d624a27b23..2d68828387 100644 --- a/legal-api/src/legal_api/models/party_role.py +++ b/legal-api/src/legal_api/models/party_role.py @@ -17,13 +17,14 @@ from datetime import datetime from enum import Enum +from sql_versioning import Versioned from sqlalchemy import Date, cast, or_ from .db import db # noqa: I001 from .party import Party # noqa: I001,F401,I003 pylint: disable=unused-import; needed by the SQLAlchemy rel -class PartyRole(db.Model): +class PartyRole(db.Model, Versioned): """Class that manages data for party roles related to a business.""" class RoleTypes(Enum): diff --git a/legal-api/src/legal_api/models/resolution.py b/legal-api/src/legal_api/models/resolution.py index d854897feb..61a34a164e 100644 --- a/legal-api/src/legal_api/models/resolution.py +++ b/legal-api/src/legal_api/models/resolution.py @@ -16,10 +16,12 @@ from enum import Enum +from sql_versioning import Versioned + from .db import db -class Resolution(db.Model): # pylint: disable=too-many-instance-attributes +class Resolution(db.Model, Versioned): # pylint: disable=too-many-instance-attributes """This class manages the resolutions.""" class ResolutionType(Enum): diff --git a/legal-api/src/legal_api/models/share_class.py b/legal-api/src/legal_api/models/share_class.py index 8288474063..43ecc5da28 100644 --- a/legal-api/src/legal_api/models/share_class.py +++ b/legal-api/src/legal_api/models/share_class.py @@ -16,6 +16,7 @@ from http import HTTPStatus +from sql_versioning import Versioned from sqlalchemy import event from legal_api.exceptions import BusinessException @@ -24,7 +25,7 @@ from .share_series import ShareSeries # noqa: F401 pylint: disable=unused-import -class ShareClass(db.Model): # pylint: disable=too-many-instance-attributes +class ShareClass(db.Model, Versioned): # pylint: disable=too-many-instance-attributes """This class manages the share classes.""" __versioned__ = {} diff --git a/legal-api/src/legal_api/models/share_series.py b/legal-api/src/legal_api/models/share_series.py index 8550cc2dcf..029bd6069c 100644 --- a/legal-api/src/legal_api/models/share_series.py +++ b/legal-api/src/legal_api/models/share_series.py @@ -15,6 +15,7 @@ from http import HTTPStatus +from sql_versioning import Versioned from sqlalchemy import event from legal_api.exceptions import BusinessException @@ -22,7 +23,7 @@ from .db import db -class ShareSeries(db.Model): # pylint: disable=too-many-instance-attributes +class ShareSeries(db.Model, Versioned): # pylint: disable=too-many-instance-attributes """This class manages the share series.""" __versioned__ = {} diff --git a/legal-api/src/legal_api/models/user.py b/legal-api/src/legal_api/models/user.py index b6310c8ea7..eee6531da2 100644 --- a/legal-api/src/legal_api/models/user.py +++ b/legal-api/src/legal_api/models/user.py @@ -20,6 +20,7 @@ from enum import auto from flask import current_app +from sql_versioning import Versioned from legal_api.exceptions import BusinessException from legal_api.utils.base import BaseEnum @@ -47,7 +48,7 @@ def _generate_next_value_(name, start, count, last_values): # pylint: disable=W public_user = auto() -class User(db.Model): +class User(db.Model, Versioned): """Used to hold the audit information for a User of this service.""" __versioned__ = {} diff --git a/legal-api/tests/unit/models/__init__.py b/legal-api/tests/unit/models/__init__.py index 8ccd8fd6c8..fadc6ebbb7 100644 --- a/legal-api/tests/unit/models/__init__.py +++ b/legal-api/tests/unit/models/__init__.py @@ -19,7 +19,6 @@ from datedelta import datedelta from freezegun import freeze_time from registry_schemas.example_data import ANNUAL_REPORT -from sqlalchemy_continuum import versioning_manager from legal_api.exceptions.error_messages import ErrorCode from legal_api.models import ( @@ -43,6 +42,7 @@ db, ) from legal_api.models.colin_event_id import ColinEventId +from legal_api.models.db import versioning_manager from legal_api.utils.datetime import datetime, timezone from tests import EPOCH_DATETIME, FROZEN_DATETIME diff --git a/legal-api/tests/unit/models/test_business.py b/legal-api/tests/unit/models/test_business.py index 5ab8f64ca1..6cda1bb70d 100644 --- a/legal-api/tests/unit/models/test_business.py +++ b/legal-api/tests/unit/models/test_business.py @@ -18,26 +18,33 @@ """ import copy from datetime import datetime, timedelta -from flask import current_app from unittest.mock import patch import datedelta import pytest -from sqlalchemy_continuum import versioning_manager +from flask import current_app from registry_schemas.example_data import FILING_HEADER, RESTORATION, TRANSITION_FILING_TEMPLATE from legal_api.exceptions import BusinessException -from legal_api.models import AmalgamatingBusiness, Amalgamation, Batch, BatchProcessing, Business, Filing, Party, PartyRole, db +from legal_api.models import ( + AmalgamatingBusiness, + Amalgamation, + Batch, + BatchProcessing, + Business, + Filing, + Party, + PartyRole, + db, +) +from legal_api.models.db import versioning_manager from legal_api.services import flags from legal_api.utils.legislation_datetime import LegislationDatetime from tests import EPOCH_DATETIME, TIMEZONE_OFFSET from tests.unit import has_expected_date_str_format -from tests.unit.models import ( - factory_party_role, - factory_batch, - factory_business as factory_business_from_tests, - factory_completed_filing -) +from tests.unit.models import factory_batch +from tests.unit.models import factory_business as factory_business_from_tests +from tests.unit.models import factory_completed_filing, factory_party_role def factory_business(designation: str = '001'): diff --git a/legal-api/tests/unit/models/test_filing.py b/legal-api/tests/unit/models/test_filing.py index e558692c4e..e468c408de 100644 --- a/legal-api/tests/unit/models/test_filing.py +++ b/legal-api/tests/unit/models/test_filing.py @@ -36,10 +36,10 @@ SPECIAL_RESOLUTION, ) from sqlalchemy.exc import DataError -from sqlalchemy_continuum import versioning_manager from legal_api.exceptions import BusinessException from legal_api.models import Business, Filing, User +from legal_api.models.db import versioning_manager from tests import EPOCH_DATETIME from tests.conftest import not_raises from tests.unit.models import ( diff --git a/legal-api/tests/unit/reports/test_report.py b/legal-api/tests/unit/reports/test_report.py index 22b5f457c5..5ac9632db2 100644 --- a/legal-api/tests/unit/reports/test_report.py +++ b/legal-api/tests/unit/reports/test_report.py @@ -35,9 +35,9 @@ SPECIAL_RESOLUTION, TRANSITION_FILING_TEMPLATE, ) -from sqlalchemy_continuum import versioning_manager from legal_api.models import db # noqa:I001 +from legal_api.models.db import versioning_manager from legal_api.reports.report import Report # noqa:I001 from legal_api.services import VersionedBusinessDetailsService # noqa:I001 from tests.unit.models import factory_business, factory_completed_filing # noqa:E501,I001 diff --git a/python/common/sql-versioning/README.md b/python/common/sql-versioning/README.md new file mode 100644 index 0000000000..d014f6da25 --- /dev/null +++ b/python/common/sql-versioning/README.md @@ -0,0 +1,104 @@ + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) + + +# Application Name + +SQLAlchemy Versioning library (LEAR Version) + +## Technology Stack Used + +- Python, Flask +- SQLAlchemy + +## Third-Party Products/Libraries used and the License they are covered by + +This project uses the following third-party libraries: + +- Flask - BSD License +- SQLAlchemy - MIT License + +Each library is subject to its own license, and the respective licenses can be found in their repositories. + + +## Project Status + +As of 2024-10-10 in Development + +## Documentation + +GitHub Pages (https://guides.github.com/features/pages/) are a neat way to document you application/project. + +## Security + +TBD + +## Files in this repository + +``` +sql-versioning/ +├── sql_versioning - versioning files +└── tests - testing files +``` + +## Deployment (Local Development) + +To set up the development environment on your local machine, follow these steps: + +1. Developer Workstation Requirements/Setup: + - Install Python 3.8+ + - Install Poetry + - Install Docker + +2. Setup + - Fork and clone the repository + + - Set to use the local repo for the virtual environment: + ```bash + poetry config virtualenvs.in-project true + ``` + - Install dependencies: + ```bash + poetry install + ``` + +3. Running Tests + - To run tests, use: + ```bash + poetry run pytest + ``` + - In rare cases, if the test container doesn't start automatically, you can manually set up the testing database by running: + ```bash + docker-compose -f tests/docker-compose.yml up -d + ``` + +## Deployment (OpenShift) + +TBD + +## Getting Help or Reporting an Issue + +To report bugs/issues/feature requests, please file an [issue](../../issues). + +## How to Contribute + +If you would like to contribute, please see our [CONTRIBUTING](./CONTRIBUTING.md) guidelines. + +Please note that this project is released with a [Contributor Code of Conduct](./CODE_OF_CONDUCT.md). +By participating in this project you agree to abide by its terms. + +## License + + Copyright 2024 Province of British Columbia + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/python/common/sql-versioning/poetry.lock b/python/common/sql-versioning/poetry.lock new file mode 100644 index 0000000000..3d403b002e --- /dev/null +++ b/python/common/sql-versioning/poetry.lock @@ -0,0 +1,381 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "greenlet" +version = "3.1.1" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.7" +files = [ + {file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617"}, + {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7"}, + {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6"}, + {file = "greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80"}, + {file = "greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a"}, + {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511"}, + {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395"}, + {file = "greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39"}, + {file = "greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9"}, + {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0"}, + {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942"}, + {file = "greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01"}, + {file = "greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e"}, + {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1"}, + {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c"}, + {file = "greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822"}, + {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01"}, + {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de"}, + {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa"}, + {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af"}, + {file = "greenlet-3.1.1-cp37-cp37m-win32.whl", hash = "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798"}, + {file = "greenlet-3.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef"}, + {file = "greenlet-3.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1"}, + {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd"}, + {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7"}, + {file = "greenlet-3.1.1-cp38-cp38-win32.whl", hash = "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef"}, + {file = "greenlet-3.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d"}, + {file = "greenlet-3.1.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c"}, + {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e"}, + {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e"}, + {file = "greenlet-3.1.1-cp39-cp39-win32.whl", hash = "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c"}, + {file = "greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22"}, + {file = "greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + +[[package]] +name = "lovely-pytest-docker" +version = "1.0.0" +description = "Pytest testing utilities with docker containers." +optional = false +python-versions = "*" +files = [ + {file = "lovely_pytest_docker-1.0.0.tar.gz", hash = "sha256:7283abfe400c31ecc7155f9338c6f5af476f2ab506e1aadb9f7e9a5005e491d6"}, +] + +[package.dependencies] +pytest = "*" +six = "*" + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "psycopg2-binary" +version = "2.9.9" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-win32.whl", hash = "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-win32.whl", hash = "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-win32.whl", hash = "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:275ff571376626195ab95a746e6a04c7df8ea34638b99fc11160de91f2fef503"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f9b5571d33660d5009a8b3c25dc1db560206e2d2f89d3df1cb32d72c0d117d52"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:420f9bbf47a02616e8554e825208cb947969451978dceb77f95ad09c37791dae"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4154ad09dac630a0f13f37b583eae260c6aa885d67dfbccb5b02c33f31a6d420"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a148c5d507bb9b4f2030a2025c545fccb0e1ef317393eaba42e7eabd28eb6041"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:68fc1f1ba168724771e38bee37d940d2865cb0f562380a1fb1ffb428b75cb692"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:281309265596e388ef483250db3640e5f414168c5a67e9c665cafce9492eda2f"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-win32.whl", hash = "sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-win32.whl", hash = "sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957"}, +] + +[[package]] +name = "pytest" +version = "8.3.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sqlalchemy" +version = "1.4.44" +description = "Database Abstraction Library" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "SQLAlchemy-1.4.44-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:da60b98b0f6f0df9fbf8b72d67d13b73aa8091923a48af79a951d4088530a239"}, + {file = "SQLAlchemy-1.4.44-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:95f4f8d62589755b507218f2e3189475a4c1f5cc9db2aec772071a7dc6cd5726"}, + {file = "SQLAlchemy-1.4.44-cp27-cp27m-win32.whl", hash = "sha256:afd1ac99179d1864a68c06b31263a08ea25a49df94e272712eb2824ef151e294"}, + {file = "SQLAlchemy-1.4.44-cp27-cp27m-win_amd64.whl", hash = "sha256:f8e5443295b218b08bef8eb85d31b214d184b3690d99a33b7bd8e5591e2b0aa1"}, + {file = "SQLAlchemy-1.4.44-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:53f90a2374f60e703c94118d21533765412da8225ba98659de7dd7998641ab17"}, + {file = "SQLAlchemy-1.4.44-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:65a0ad931944fcb0be12a8e0ac322dbd3ecf17c53f088bc10b6da8f0caac287b"}, + {file = "SQLAlchemy-1.4.44-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b185041a4dc5c685283ea98c2f67bbfa47bb28e4a4f5b27ebf40684e7a9f8"}, + {file = "SQLAlchemy-1.4.44-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:80ead36fb1d676cc019586ffdc21c7e906ce4bf243fe4021e4973dae332b6038"}, + {file = "SQLAlchemy-1.4.44-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68e0cd5d32a32c4395168d42f2fefbb03b817ead3a8f3704b8bd5697c0b26c24"}, + {file = "SQLAlchemy-1.4.44-cp310-cp310-win32.whl", hash = "sha256:ae1ed1ebc407d2f66c6f0ec44ef7d56e3f455859df5494680e2cf89dad8e3ae0"}, + {file = "SQLAlchemy-1.4.44-cp310-cp310-win_amd64.whl", hash = "sha256:6f0ea4d7348feb5e5d0bf317aace92e28398fa9a6e38b7be9ec1f31aad4a8039"}, + {file = "SQLAlchemy-1.4.44-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f5e8ed9cde48b76318ab989deeddc48f833d2a6a7b7c393c49b704f67dedf01d"}, + {file = "SQLAlchemy-1.4.44-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c857676d810ca196be73c98eb839125d6fa849bfa3589be06201a6517f9961c"}, + {file = "SQLAlchemy-1.4.44-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c56e6899fa6e767e4be5d106941804a4201c5cb9620a409c0b80448ec70b656"}, + {file = "SQLAlchemy-1.4.44-cp311-cp311-win32.whl", hash = "sha256:c46322354c58d4dc039a2c982d28284330f8919f31206894281f4b595b9d8dbe"}, + {file = "SQLAlchemy-1.4.44-cp311-cp311-win_amd64.whl", hash = "sha256:7313e4acebb9ae88dbde14a8a177467a7625b7449306c03a3f9f309b30e163d0"}, + {file = "SQLAlchemy-1.4.44-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:17aee7bfcef7bf0dea92f10e5dfdd67418dcf6fe0759f520e168b605855c003e"}, + {file = "SQLAlchemy-1.4.44-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9470633395e5f24d6741b4c8a6e905bce405a28cf417bba4ccbaadf3dab0111d"}, + {file = "SQLAlchemy-1.4.44-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:393f51a09778e8984d735b59a810731394308b4038acdb1635397c2865dae2b6"}, + {file = "SQLAlchemy-1.4.44-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7e3b9e01fdbe1ce3a165cc7e1ff52b24813ee79c6df6dee0d1e13888a97817e"}, + {file = "SQLAlchemy-1.4.44-cp36-cp36m-win32.whl", hash = "sha256:6a06c2506c41926d2769f7968759995f2505e31c5b5a0821e43ca5a3ddb0e8ae"}, + {file = "SQLAlchemy-1.4.44-cp36-cp36m-win_amd64.whl", hash = "sha256:3ca21b35b714ce36f4b8d1ee8d15f149db8eb43a472cf71600bf18dae32286e7"}, + {file = "SQLAlchemy-1.4.44-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:3cbdbed8cdcae0f83640a9c44fa02b45a6c61e149c58d45a63c9581aba62850f"}, + {file = "SQLAlchemy-1.4.44-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a22208c1982f1fe2ae82e5e4c3d4a6f2445a7a0d65fb7983a3d7cbbe3983f5a4"}, + {file = "SQLAlchemy-1.4.44-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d3b9ac11f36ab9a726097fba7c7f6384f0129aedb017f1d4d1d4fce9052a1320"}, + {file = "SQLAlchemy-1.4.44-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d654870a66027af3a26df1372cf7f002e161c6768ebe4c9c6fdc0da331cb5173"}, + {file = "SQLAlchemy-1.4.44-cp37-cp37m-win32.whl", hash = "sha256:0be9b479c5806cece01f1581726573a8d6515f8404e082c375b922c45cfc2a7b"}, + {file = "SQLAlchemy-1.4.44-cp37-cp37m-win_amd64.whl", hash = "sha256:3eba07f740488c3a125f17c092a81eeae24a6c7ec32ac9dbc52bf7afaf0c4f16"}, + {file = "SQLAlchemy-1.4.44-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:ad5f966623905ee33694680dda1b735544c99c7638f216045d21546d3d8c6f5b"}, + {file = "SQLAlchemy-1.4.44-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f68eab46649504eb95be36ca529aea16cd199f080726c28cbdbcbf23d20b2a2"}, + {file = "SQLAlchemy-1.4.44-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:21f3df74a0ab39e1255e94613556e33c1dc3b454059fe0b365ec3bbb9ed82e4a"}, + {file = "SQLAlchemy-1.4.44-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8080bc51a775627865e0f1dbfc0040ff4ace685f187f6036837e1727ba2ed10"}, + {file = "SQLAlchemy-1.4.44-cp38-cp38-win32.whl", hash = "sha256:b6a337a2643a41476fb6262059b8740f4b9a2ec29bf00ffb18c18c080f6e0aed"}, + {file = "SQLAlchemy-1.4.44-cp38-cp38-win_amd64.whl", hash = "sha256:b737fbeb2f78926d1f59964feb287bbbd050e7904766f87c8ce5cfb86e6d840c"}, + {file = "SQLAlchemy-1.4.44-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:c9aa372b295a36771cffc226b6517df3011a7d146ac22d19fa6a75f1cdf9d7e6"}, + {file = "SQLAlchemy-1.4.44-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:237067ba0ef45a518b64606e1807f7229969ad568288b110ed5f0ca714a3ed3a"}, + {file = "SQLAlchemy-1.4.44-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6d7e1b28342b45f19e3dea7873a9479e4a57e15095a575afca902e517fb89652"}, + {file = "SQLAlchemy-1.4.44-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c0093678001f5d79f2dcbf3104c54d6c89e41ab50d619494c503a4d3f1aef2"}, + {file = "SQLAlchemy-1.4.44-cp39-cp39-win32.whl", hash = "sha256:7cf7c7adbf4417e3f46fc5a2dbf8395a5a69698217337086888f79700a12e93a"}, + {file = "SQLAlchemy-1.4.44-cp39-cp39-win_amd64.whl", hash = "sha256:d3b6d4588994da73567bb00af9d7224a16c8027865a8aab53ae9be83f9b7cbd1"}, + {file = "SQLAlchemy-1.4.44.tar.gz", hash = "sha256:2dda5f96719ae89b3ec0f1b79698d86eb9aecb1d54e990abb3fdd92c04b46a90"}, +] + +[package.dependencies] +greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} + +[package.extras] +aiomysql = ["aiomysql", "greenlet (!=0.4.17)"] +aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing-extensions (!=3.10.0.1)"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4)", "greenlet (!=0.4.17)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)", "sqlalchemy2-stubs"] +mysql = ["mysqlclient (>=1.4.0)", "mysqlclient (>=1.4.0,<2)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx-oracle (>=7)", "cx-oracle (>=7,<8)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] +postgresql-pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +pymysql = ["pymysql", "pymysql (<1)"] +sqlcipher = ["sqlcipher3-binary"] + +[[package]] +name = "tomli" +version = "2.0.2" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, + {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "e431f3da1414bd7f52c3ae432803539a6fc62e04f46ac0cb8a5417a727318179" diff --git a/python/common/sql-versioning/pyproject.toml b/python/common/sql-versioning/pyproject.toml new file mode 100644 index 0000000000..0466db1585 --- /dev/null +++ b/python/common/sql-versioning/pyproject.toml @@ -0,0 +1,22 @@ +[tool.poetry] +name = "sql-versioning" +version = "0.1.0" +description = "" +authors = ["Hongjing Chen "] +readme = "README.md" +packages = [{include = "sql_versioning"}] + +[tool.poetry.dependencies] +python = "^3.8" +sqlalchemy = "1.4.44" + + +[tool.poetry.group.dev.dependencies] +psycopg2-binary = "^2.9.9" +pytest = "^8.3.3" +isort = "^5.13.2" +lovely-pytest-docker = "^1.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/python/common/sql-versioning/sql_versioning/__init__.py b/python/common/sql-versioning/sql_versioning/__init__.py new file mode 100644 index 0000000000..9ece3f89e6 --- /dev/null +++ b/python/common/sql-versioning/sql_versioning/__init__.py @@ -0,0 +1,29 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Versioning extension for SQLAlchemy.""" +from .debugging import debug +from .versioning import (Base, TransactionFactory, TransactionManager, + Versioned, disable_versioning, enable_versioning, + version_class) + +__all__ = ( + "Base", + "TransactionFactory", + "TransactionManager", + "Versioned", + "debug", + "disable_versioning", + "enable_versioning", + "version_class" +) diff --git a/python/common/sql-versioning/sql_versioning/debugging.py b/python/common/sql-versioning/sql_versioning/debugging.py new file mode 100644 index 0000000000..5909fd9aaa --- /dev/null +++ b/python/common/sql-versioning/sql_versioning/debugging.py @@ -0,0 +1,27 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Utilities used for debugging.""" +# TODO: remove this debugging utility file +import functools + + +def debug(func): + """A decorator to print a message before and after a function call.""" + @functools.wraps(func) + def wrapper(*args, **kwargs): + print(f'\033[34m--> Entering {func.__qualname__}()\033[0m') + ret = func(*args, **kwargs) + print(f'\033[34m<-- Exiting {func.__qualname__}()\033[0m') + return ret + return wrapper diff --git a/python/common/sql-versioning/sql_versioning/versioning.py b/python/common/sql-versioning/sql_versioning/versioning.py new file mode 100644 index 0000000000..8a34ff5dd2 --- /dev/null +++ b/python/common/sql-versioning/sql_versioning/versioning.py @@ -0,0 +1,404 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Versioned mixin class, listeners and other utilities.""" +import datetime +from contextlib import suppress + +from sqlalchemy import (BigInteger, Column, DateTime, Integer, SmallInteger, + String, and_, event, func, insert, inspect, select, + update) +from sqlalchemy.ext.declarative import declarative_base, declared_attr +from sqlalchemy.orm import Session, mapper + +from .debugging import debug + +Base = declarative_base() + + +# ---------- Utilities ---------- +def _is_obj_modified(obj): + """ + Check if the properties and relationships of the given object have been modified. + + :param obj: The object to inspect for changes. + :return: True if any property or relationship has been modified, otherwise False. + """ + column_names = inspect(obj.__class__).columns.keys() + relationship_keys = inspect(obj.__class__).relationships.keys() + + for key, attr in inspect(obj).attrs.items(): + if key in column_names: + if attr.history.has_changes(): + return True + if key in relationship_keys: + if attr.history.has_changes(): + return True + return False + + +def _is_session_modified(session): + """Check if the session contains modified versioned objects. + + :param session: The database sesseion instance. + :return: True if the session contains modified versioned objects, otherwise False. + """ + for obj in versioned_objects(session): + if obj in session.deleted or session.new: + return True + if obj in session.dirty and _is_obj_modified(obj): + return True + return False + + +def _get_operation_type(session, obj): + """Return the operation type for the given object within the session. + + :param session: The database session instance. + :param obj: The object to determine the operation type. + :return: The operation type ('I' for insert, 'U' for update, 'D' for delete), or None if unchanged. + """ + if obj in session.new: + return 'I' + elif obj in session.dirty: + return 'U' if _is_obj_modified(obj) else None + elif obj in session.deleted: + return 'D' + return None + + +def _create_version(session, target, operation_type): + """Create and updates a versioned record given the target object and operation type. + + :param session: The database session instance. + :param target: The object to create version. + :param operation_type: The type of operation ('I', 'U', 'D') being performed on the object. + :return: None + """ + + print(f'\033[32mCreating version for {target.__class__.__name__} (id={target.id}), operation_type: {operation_type}\033[0m') + + if not session: + print(f'\033[32mSkipping version creation for {target.__class__.__name__} (id={target.id})\033[0m') + return + + transaction_manager = TransactionManager(session) + transaction_id = transaction_manager.create_transaction() + + if transaction_id is None: + print(f'\033[31mError - Unable to create transaction for {target.__class__.__name__} (id={target.id})\033[0m') + return + + VersionClass = target.__class__.__versioned_cls__ + + # Check if a version for this transaction already exists + existing_version = session.execute( + select(VersionClass).where( + and_( + VersionClass.id == target.id, + VersionClass.transaction_id == transaction_id + ) + ) + ).scalar_one_or_none() + + # Prepare new version data + new_version_data = { + 'id': target.id, + 'transaction_id': transaction_id, + 'end_transaction_id': None, + 'operation_type': {'I': 0, 'U': 1, 'D': 2}.get(operation_type, 1) + } + + for column in inspect(target.__class__).columns: + if column.name not in ['transaction_id', 'end_transaction_id', 'operation_type']: + if hasattr(target, column.name): + new_version_data[column.name] = getattr(target, column.name) + + if existing_version: + # Update the existing version + session.execute( + update(VersionClass). + where(and_( + VersionClass.id == target.id, + VersionClass.transaction_id == transaction_id + )). + values(new_version_data) + ) + else: + # Insert a new version + session.execute(insert(VersionClass).values(new_version_data)) + + # Close any open versions + session.execute( + update(VersionClass). + where(and_( + VersionClass.id == target.id, + VersionClass.end_transaction_id.is_(None), + VersionClass.transaction_id != transaction_id + )). + values(end_transaction_id=transaction_id) + ) + + print(f'\033[32mVersion created/updated for {target.__class__.__name__} (id={target.id}), transaction_id: {transaction_id}\033[0m') + + +# ---------- Transaction Related Classes ---------- +class TransactionFactory: + """Factory to create or return singleton Transaction model.""" + + _transaction_model = None + + @staticmethod + def create_transaction_model(transaction_cls=None): + """Create or return the existing Transaction model. + + :param transaction_cls: A custom transaction model class. If provided, + it replaces the default Transaction model. Defaults to None. + + :return: Transaction model class (either custom or default). + """ + + if transaction_cls: + TransactionFactory._transaction_model = transaction_cls + + elif TransactionFactory._transaction_model is None: + class Transaction(Base): + __tablename__ = 'transaction' + + id = Column(BigInteger, primary_key=True, autoincrement=True) + issued_at = Column(DateTime(timezone=False), default=datetime.datetime.utcnow, nullable=True) + remote_addr = Column(String(50), nullable=True) + + TransactionFactory._transaction_model = Transaction + + return TransactionFactory._transaction_model + + +class TransactionManager: + """Handle transaction creation, retrieval, and cleanup for a session.""" + + def __init__(self, session): + """Initialize a TransactionManager + + :param session: The database session instance. + """ + self.session = session + self.transaction_model = TransactionFactory.create_transaction_model() + + @debug + def create_transaction(self): + """Create a new transaction or reuses the existing one in the session. + + :return: The ID of the created or reused transaction. + """ + + if 'current_transaction_id' in self.session.info: + print(f"\033[32mReusing existing transaction: {self.session.info['current_transaction_id']}\033[0m") + return self.session.info['current_transaction_id'] + + # Use insert().returning() to get the ID and issued_at without committing + stmt = insert(self.transaction_model).values( + issued_at = func.now() + ).returning(self.transaction_model.id, self.transaction_model.issued_at) + result = self.session.execute(stmt) + transaction_id, issued_at = result.first() + + print(f'\033[32mCreated new transaction: {transaction_id}\033[0m') + + self.session.info['current_transaction_id'] = transaction_id + print(f'\033[32mSet current_transaction_id: {transaction_id}\033[0m') + return transaction_id + + def get_current_transaction_id(self): + """Return the current transaction_id stored in the session. + + :return: The current transaction ID in the session. + """ + return self.session.info.get('current_transaction_id') + + @debug + def clear_current_transaction(self): + """Clear the current transaction_id stored in the session. + + :return: None + """ + print(f"\033[32mClearing current transaction: {self.session.info.get('current_transaction_id')}\033[0m") + self.session.info.pop('current_transaction_id', None) + + +# ---------- Event Listeners ---------- +@debug +def _before_flush(session, flush_context, instances): + """Trigger before a flush operation to ensure a transaction is created.""" + try: + if not _is_session_modified(session): + print('\033[31mThere is no modified versioned object in this session.\033[0m') + return + transaction_manager = TransactionManager(session) + transaction_manager.create_transaction() + + except Exception as e: + raise e + + +@debug +def _after_flush(session, flush_context): + """Trigger after a flush operation to create version records for changed objects.""" + try: + for obj in versioned_objects(session): + operation_type = _get_operation_type(session, obj) + if operation_type: + _create_version(session, obj, operation_type) + except Exception as e: + raise e + + +@debug +def _clear_transaction(session): + """Clears the current transaction from the session after commit or rollback.""" + try: + transaction_manager = TransactionManager(session) + transaction_manager.clear_current_transaction() + except Exception as e: + raise e + + +EVENT_LISTENERS = { + 'before_flush': _before_flush, + 'after_flush': _after_flush, + 'after_commit': _clear_transaction, + 'after_rollback': _clear_transaction +} + + +# ---------- Main Versioning Class/Functions ---------- +class Versioned: + """Class to add versioning capability to models.""" + + @declared_attr + def __versioned_cls__(cls): + """Return the versioned class associated with the model. + + :return: The versioned class. + """ + return cls.get_or_create_version_class() + + @classmethod + def get_or_create_version_class(cls): + """Create a versioned class. + + :return: The versioned class. + """ + if not hasattr(cls, '_version_cls'): + class_name = f'{cls.__name__}Version' + table_name = f'{cls.__tablename__}_version' + + attrs = { + '__tablename__': table_name, + 'id': Column(Integer, primary_key=True), + 'transaction_id': Column(BigInteger, primary_key=True, nullable=False), + 'end_transaction_id': Column(BigInteger, nullable=True), + 'operation_type': Column(SmallInteger, nullable=False), + } + + # We'll add columns from the original table later + cls._version_cls = type(class_name, (Base,), attrs) + + # Add this class to a list to be processed later + if not hasattr(cls, '_pending_version_classes'): + cls._pending_version_classes = [] + cls._pending_version_classes.append(cls) + + return cls._version_cls + + @classmethod + def __init_subclass__(cls, **kwargs): + """ + Initialize subclass and register a listener to configure versioned + classes after mapper configuration. + + :param kwargs: Arguments for the subclass. + :return: None + """ + super().__init_subclass__(**kwargs) + event.listen(mapper, 'after_configured', cls._after_configured) + + @classmethod + def _after_configured(cls): + """ + Trigger after configured. Add columns from the original table to + the versioned class. + + :return: None + """ + if hasattr(cls, '_pending_version_classes'): + for pending_cls in cls._pending_version_classes: + version_cls = pending_cls._version_cls + # Now add columns from the original table + for c in pending_cls.__table__.columns: + if not hasattr(version_cls, c.name): + setattr(version_cls, c.name, Column(c.type)) + delattr(cls, '_pending_version_classes') + + +def version_class(obj): + """Return the version class associated with a model. + + :param obj: The object to get the version class for. + :return: The version class or None if not found. + """ + with suppress(Exception): + versioned_class = obj.__versioned_cls__ + print(f'\033[32mVersioned Class={versioned_class}\033[0m') + return versioned_class + return None + + +def versioned_objects(session): + """Yield versioned objects that have been changed from the session. + + :param session: The database session instance. + :return: Generator of versioned objects. + """ + for obj in session.new.union(session.dirty).union(session.deleted): + if isinstance(obj, Versioned): + yield obj + + +@debug +def enable_versioning(transaction_cls=None): + """Enable versioning. It registers listeners. + + :param transaction_cls: Optional custom transaction class used for versioning. + :return: None + """ + try: + TransactionFactory.create_transaction_model(transaction_cls) + + for event_name, listener in EVENT_LISTENERS.items(): + event.listen(Session, event_name, listener) + except Exception as e: + raise e + + +@debug +def disable_versioning(): + """Disable versioning. It removes listeners. + + :return: None + """ + try: + for event_name, listener in EVENT_LISTENERS.items(): + event.remove(Session, event_name, listener) + except Exception as e: + raise e diff --git a/python/common/sql-versioning/tests/__init__.py b/python/common/sql-versioning/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/common/sql-versioning/tests/conftest.py b/python/common/sql-versioning/tests/conftest.py new file mode 100644 index 0000000000..110da1ba92 --- /dev/null +++ b/python/common/sql-versioning/tests/conftest.py @@ -0,0 +1,58 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Common setup and fixtures for the pytest suite used by this service.""" +import time + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from sql_versioning import Base + +POSTGRES_URL = 'postgresql://postgres:postgres@localhost:5433/test' + + +@pytest.fixture(scope='session') +def db(docker_services): + """Create postgres service.""" + docker_services.start('postgres') + time.sleep(2) + + +@pytest.fixture(scope='session') +def session(db): + """Clear DB and build tables""" + engine = create_engine(POSTGRES_URL) + + # create tables + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + + Session = sessionmaker(bind=engine) + session = Session() + + yield session + + # cleanup + session.close() + Base.metadata.drop_all(engine) + + +@pytest.fixture(autouse=True) +def clear_tables(session): + """Clear tables and reset transaction sequence.""" + for table in reversed(Base.metadata.sorted_tables): + session.execute(table.delete()) + session.execute('ALTER SEQUENCE transaction_id_seq RESTART WITH 1') + session.commit() diff --git a/python/common/sql-versioning/tests/docker-compose.yml b/python/common/sql-versioning/tests/docker-compose.yml new file mode 100644 index 0000000000..2ebd7bbbd6 --- /dev/null +++ b/python/common/sql-versioning/tests/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + postgres: + image: postgres:11 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test + ports: + - "5433:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: diff --git a/python/common/sql-versioning/tests/test_versioning.py b/python/common/sql-versioning/tests/test_versioning.py new file mode 100644 index 0000000000..a7b74cb816 --- /dev/null +++ b/python/common/sql-versioning/tests/test_versioning.py @@ -0,0 +1,240 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for versioning extension. + +Test-Suite to ensure that the versioning extension is working as expected. +""" +import pytest +from sqlalchemy import Column, ForeignKey, Integer, String, orm + +from sql_versioning import (Base, TransactionFactory, Versioned, + enable_versioning, version_class) + +enable_versioning() + +Transaction = TransactionFactory.create_transaction_model() + +class Model(Base): + __tablename__ = 'models' + id = Column(Integer, primary_key=True) + name = Column(String) + +class User(Base, Versioned): + __tablename__ = 'users' + + id = Column(Integer, primary_key=True) + name = Column(String) + + address = orm.relationship('Address', backref='user', uselist=False) + +class Address(Base, Versioned): + __tablename__ = 'addresses' + + id = Column(Integer, primary_key=True) + name = Column(String) + + user_id = Column(Integer, ForeignKey('users.id')) + +orm.configure_mappers() + + +@pytest.mark.parametrize('test_name', ['CLASS','INSTANCE']) +def test_version_class(db, session, test_name): + """Test version_class.""" + if test_name == 'CLASS': + model = Model + user = User + else: + model = Model() + user = User() + + model_version = version_class(model) + assert model_version is None + + user_version = version_class(user) + assert user_version + + +def test_versioned_obj(db, session): + """Test version_class-2.""" + address = Address() + version = version_class(address) + assert version + + +def test_basic(db, session): + """Test basic db operation.""" + + model = Model(name='model') + session.add(model) + session.commit() + + result_model = session.query(Model)\ + .filter(Model.name=='model')\ + .one_or_none() + assert result_model + assert result_model.id + + user = User(name='user') + session.add(user) + session.commit() + + result_user = session.query(User)\ + .filter(User.name=='user')\ + .one_or_none() + assert result_user + assert result_user.id + + +def test_versioning_insert(db, session): + """Test insertion.""" + user = User(name='user') + address = Address(name='address') + user.address = address + session.add(user) + session.commit() + + result = session.query(User)\ + .filter(User.name=='user')\ + .one_or_none() + assert result + assert result.address + + transactions = session.query(Transaction).all() + assert len(transactions) == 1 + transaction = transactions[0] + assert transaction + + user_version = version_class(User) + result_revision = session.query(user_version)\ + .filter(user_version.name=='user')\ + .one_or_none() + assert result_revision + assert result_revision.transaction_id == transaction.id + assert result_revision.operation_type == 0 + assert result_revision.end_transaction_id is None + + address_version = version_class(Address) + result_versioned_address = session.query(address_version)\ + .filter(address_version.name=='address')\ + .one_or_none() + assert result_versioned_address + assert result_versioned_address.transaction_id == transaction.id + assert result_versioned_address.operation_type == 0 + assert result_versioned_address.end_transaction_id is None + + +def test_versioning_delete(db, session): + """Test deletion.""" + user = User(name='test') + session.add(user) + session.commit() + + session.delete(user) + session.commit() + + user_version = version_class(User) + results = session.query(user_version)\ + .filter(user_version.id==user.id)\ + .order_by(user_version.transaction_id)\ + .all() + + assert len(results) == 2 + assert results[1].operation_type == 2 + assert results[1].transaction_id is not None + assert results[0].operation_type == 0 + assert results[0].end_transaction_id == results[1].transaction_id + + +def test_versioning_update(db, session): + """Test update.""" + user = User(name='old') + session.add(user) + session.commit() + + user.name = 'new' + session.add(user) + session.commit() + + # the following operations should not result in new versioned records for native sqlalchemy session + session.add(user) + session.commit() + + user_version = version_class(User) + results = session.query(user_version)\ + .filter(user_version.id==user.id)\ + .order_by(user_version.transaction_id)\ + .all() + + assert len(results) == 2 + assert results[1].operation_type == 1 + assert results[1].name == 'new' + assert results[1].transaction_id is not None + assert results[0].operation_type == 0 + assert results[0].name == 'old' + assert results[0].end_transaction_id == results[1].transaction_id + + +def test_versioning_query(db, session): + """Test querying versioned data.""" + user = User(name='old') + session.add(user) + session.commit() + + user.name = 'new' + session.add(user) + session.commit() + + user_version = version_class(User) + result = session.query(user_version)\ + .filter(user_version.transaction_id==1)\ + .one_or_none() + + assert result + assert result.name == 'old' + + +def test_versioning_rollback(db, session): + """Test rollback.""" + user1 = User(name='test1') + session.add(user1) + session.commit() + + user_version = version_class(User) + results_txn1 = session.query(Transaction).all() + results_user1 = session.query(User).all() + results_version1 = session.query(user_version).all() + + assert len(results_txn1) == 1 + assert len(results_user1) == 1 + assert len(results_version1) == 1 + + try: + user2 = User(name='test2') + session.add(user2) + session.flush() + raise Exception('an error') + except Exception: + session.rollback() + + results_txn2 = session.query(Transaction).all() + results_user2 = session.query(User).all() + results_version2 = session.query(user_version).all() + + assert len(results_txn2) == 1 + assert len(results_user2) == 1 + assert len(results_version2) == 1 + assert results_txn1[0] == results_txn2[0] + assert results_user1[0] == results_user2[0] + assert results_version1[0] == results_version2[0] diff --git a/queue_services/entity-digital-credentials/requirements.txt b/queue_services/entity-digital-credentials/requirements.txt index 06b6916a8e..a67f794736 100644 --- a/queue_services/entity-digital-credentials/requirements.txt +++ b/queue_services/entity-digital-credentials/requirements.txt @@ -23,3 +23,4 @@ Werkzeug==1.0.1 git+https://github.com/bcgov/business-schemas.git@2.18.15#egg=registry_schemas git+https://github.com/bcgov/lear.git#egg=entity_queue_common&subdirectory=queue_services/common git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api +git+https://github.com/bcgov/lear.git#egg=sql-versioning&subdirectory=python/common/sql-versioning diff --git a/queue_services/entity-digital-credentials/tests/unit/__init__.py b/queue_services/entity-digital-credentials/tests/unit/__init__.py index 42f5b8a031..0ed27a9e90 100644 --- a/queue_services/entity-digital-credentials/tests/unit/__init__.py +++ b/queue_services/entity-digital-credentials/tests/unit/__init__.py @@ -14,7 +14,7 @@ """The Unit Tests and the helper routines.""" from legal_api.models import Business, DCConnection, DCDefinition, DCIssuedCredential, Filing -from sqlalchemy_continuum import versioning_manager +from legal_api.models.db import versioning_manager def create_business(identifier): diff --git a/queue_services/entity-emailer/requirements.txt b/queue_services/entity-emailer/requirements.txt index 4492e99ddc..d8eeae2c8c 100644 --- a/queue_services/entity-emailer/requirements.txt +++ b/queue_services/entity-emailer/requirements.txt @@ -81,3 +81,4 @@ zipp==3.15.0 git+https://github.com/bcgov/business-schemas.git@2.18.27#egg=registry_schemas git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api git+https://github.com/bcgov/lear.git#egg=entity_queue_common&subdirectory=queue_services/common +git+https://github.com/bcgov/lear.git#egg=sql-versioning&subdirectory=python/common/sql-versioning diff --git a/queue_services/entity-emailer/tests/unit/__init__.py b/queue_services/entity-emailer/tests/unit/__init__.py index be8c73a95e..0f80379fe6 100644 --- a/queue_services/entity-emailer/tests/unit/__init__.py +++ b/queue_services/entity-emailer/tests/unit/__init__.py @@ -19,6 +19,7 @@ from unittest.mock import Mock from legal_api.models import Batch, Business, Filing, Furnishing, Party, PartyRole, RegistrationBootstrap, User +from legal_api.models.db import versioning_manager from registry_schemas.example_data import ( AGM_EXTENSION, AGM_LOCATION_CHANGE, @@ -43,7 +44,6 @@ REGISTRATION, RESTORATION, ) -from sqlalchemy_continuum import versioning_manager from tests import EPOCH_DATETIME diff --git a/queue_services/entity-filer/requirements.txt b/queue_services/entity-filer/requirements.txt index 05106a8259..a7783e6cbd 100755 --- a/queue_services/entity-filer/requirements.txt +++ b/queue_services/entity-filer/requirements.txt @@ -27,3 +27,4 @@ git+https://github.com/bcgov/sbc-connect-common.git#egg=gcp-queue&subdirectory=p git+https://github.com/bcgov/business-schemas.git@2.18.27#egg=registry_schemas git+https://github.com/bcgov/lear.git#egg=entity_queue_common&subdirectory=queue_services/common git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api +git+https://github.com/bcgov/lear.git#egg=sql-versioning&subdirectory=python/common/sql-versioning diff --git a/queue_services/entity-filer/src/entity_filer/worker.py b/queue_services/entity-filer/src/entity_filer/worker.py index e13d96a929..5e331629ea 100644 --- a/queue_services/entity-filer/src/entity_filer/worker.py +++ b/queue_services/entity-filer/src/entity_filer/worker.py @@ -39,11 +39,11 @@ from legal_api import db from legal_api.core import Filing as FilingCore from legal_api.models import Business, Filing +from legal_api.models.db import init_db, versioning_manager from legal_api.services import Flags from legal_api.utils.datetime import datetime, timezone from sentry_sdk import capture_message from sqlalchemy.exc import OperationalError -from sqlalchemy_continuum import versioning_manager from entity_filer import config from entity_filer.filing_meta import FilingMeta, json_serial @@ -83,7 +83,7 @@ APP_CONFIG = config.get_named_config(os.getenv('DEPLOYMENT_ENV', 'production')) FLASK_APP = Flask(__name__) FLASK_APP.config.from_object(APP_CONFIG) -db.init_app(FLASK_APP) +init_db(FLASK_APP) gcp_queue.init_app(FLASK_APP) if FLASK_APP.config.get('LD_SDK_KEY', None): diff --git a/queue_services/entity-filer/tests/unit/__init__.py b/queue_services/entity-filer/tests/unit/__init__.py index 274615d646..11c7608453 100644 --- a/queue_services/entity-filer/tests/unit/__init__.py +++ b/queue_services/entity-filer/tests/unit/__init__.py @@ -14,16 +14,18 @@ """The Unit Tests and the helper routines.""" import base64 import uuid +from datetime import datetime from datedelta import datedelta -from datetime import datetime from dateutil.parser import parse from freezegun import freeze_time -from sqlalchemy_continuum import versioning_manager + +from legal_api.models import Batch, BatchProcessing, Filing, Resolution, ShareClass, ShareSeries, db +from legal_api.models.colin_event_id import ColinEventId +from legal_api.models.db import versioning_manager from legal_api.utils.datetime import datetime, timezone from tests import EPOCH_DATETIME, FROZEN_DATETIME -from legal_api.models import db, Batch, BatchProcessing, Filing, Resolution, ShareClass, ShareSeries -from legal_api.models.colin_event_id import ColinEventId + AR_FILING = { 'filing': {