From 9116d54b4f43ae86da194349294b78ed7cd0ea63 Mon Sep 17 00:00:00 2001 From: Azeez Muibi Date: Fri, 11 Apr 2025 09:01:27 +0100 Subject: [PATCH] Added Transaction --- .example.env | 2 +- SQL/site_data.sql | 14 +++ app/api/routes/routes.py | 20 +++- app/api/services/__init__.py | 1 + app/api/services/transaction.py | 75 ++++++++++++ app/models/offer.py | 16 --- app/models/transaction.py | 47 +++++++- docker-compose.yml | 4 +- migrations/README | 1 + migrations/alembic.ini | 50 ++++++++ migrations/env.py | 113 ++++++++++++++++++ migrations/script.py.mako | 24 ++++ ...8_migration_on_thu_apr_10_16_21_45_utc_.py | 86 +++++++++++++ scripts/entrypoint.sh | 8 ++ 14 files changed, 436 insertions(+), 25 deletions(-) create mode 100644 SQL/site_data.sql create mode 100644 app/api/services/transaction.py delete mode 100644 app/models/offer.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/b8f6fd76ead8_migration_on_thu_apr_10_16_21_45_utc_.py create mode 100644 scripts/entrypoint.sh diff --git a/.example.env b/.example.env index 2bd2f2b..1f3a4d5 100644 --- a/.example.env +++ b/.example.env @@ -19,6 +19,6 @@ DATABASE_NAME=***** # Flask Configuration FLASK_APP=wsgi.py FLASK_ENV=development -APP_PORT=4500 +APP_PORT=4300 SIMBRELLA_BASE_URL=*************** \ No newline at end of file diff --git a/SQL/site_data.sql b/SQL/site_data.sql new file mode 100644 index 0000000..c2c4176 --- /dev/null +++ b/SQL/site_data.sql @@ -0,0 +1,14 @@ + +CREATE TABLE transactions ( + id SERIAL, + transaction_id VARCHAR(50) NOT NULL, + account_id VARCHAR(50) NOT NULL, + type VARCHAR(50) NOT NULL, + channel VARCHAR(8) NOT NULL, + created_at timestamp with time zone DEFAULT now(), + updated_at timestamp with time zone DEFAULT now() +); +ALTER TABLE ONLY transactions + ADD CONSTRAINT transactions_id_key UNIQUE (id); + + diff --git a/app/api/routes/routes.py b/app/api/routes/routes.py index 5e9c4d3..dace8bf 100644 --- a/app/api/routes/routes.py +++ b/app/api/routes/routes.py @@ -8,6 +8,7 @@ from app.api.services import ( CustomerConsentService, NotificationCallbackService, AuthorizationService, + TransactionService, ) from app.utils.logger import logger from app.api.middlewares import enforce_json, require_auth @@ -20,7 +21,6 @@ from flask_jwt_extended import ( create_refresh_token, ) - api = Blueprint("api", __name__) @@ -119,6 +119,24 @@ def health_check(): return {"status": "ok"}, 200 +# Get All Transactions Endpoint +@api.route("/transactions", methods=["GET"]) +@jwt_required() +def get_transactions(): + # Extract query parameters for filtering + filters = { + 'account_id': request.args.get('account_id'), + 'type': request.args.get('type'), + 'channel': request.args.get('channel'), + 'start_date': request.args.get('start_date'), + 'end_date': request.args.get('end_date') + } + + # logger.info(f"Get transactions request received with filters: {filters}") + response = TransactionService.process_request(filters) + return response + + # Authorize endpoint @api.route("/Authorize", methods=["POST"]) def authorize(): diff --git a/app/api/services/__init__.py b/app/api/services/__init__.py index e11319d..bb725c2 100644 --- a/app/api/services/__init__.py +++ b/app/api/services/__init__.py @@ -6,3 +6,4 @@ from app.api.services.repayment import RepaymentService from app.api.services.customer_consent import CustomerConsentService from app.api.services.notification_callback import NotificationCallbackService from app.api.services.authorization import AuthorizationService +from app.api.services.transaction import TransactionService diff --git a/app/api/services/transaction.py b/app/api/services/transaction.py new file mode 100644 index 0000000..79029a5 --- /dev/null +++ b/app/api/services/transaction.py @@ -0,0 +1,75 @@ +from flask import jsonify +from app.utils.logger import logger +from app.api.services.base_service import BaseService +from app.models.transaction import Transaction +from datetime import datetime + + +class TransactionService(BaseService): + @staticmethod + def process_request(filters=None): + """ + Process the get transactions request. + + Args: + filters (dict, optional): Filters for the transactions query. + + Returns: + dict: A standardized response with transactions data. + """ + try: + if filters is None: + filters = {} + + # Extract filters + account_id = filters.get('account_id') + transaction_type = filters.get('type') + channel = filters.get('channel') + start_date = filters.get('start_date') + end_date = filters.get('end_date') + + # Convert string dates to datetime objects if provided + if start_date and isinstance(start_date, str): + start_date = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + if end_date and isinstance(end_date, str): + end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + + # Get transactions with optional filters + transactions = Transaction.get_all_transactions( + account_id=account_id, + transaction_type=transaction_type, + channel=channel, + start_date=start_date, + end_date=end_date + ) + + # Convert transactions to dictionary format + transactions_data = [] + for transaction in transactions: + transactions_data.append({ + 'id': transaction.id, + 'transaction_id': transaction.transaction_id, + 'account_id': transaction.account_id, + 'type': transaction.type, + 'channel': transaction.channel, + 'created_at': transaction.created_at.isoformat(), + 'updated_at': transaction.updated_at.isoformat() + }) + + response_data = { + 'status': 'success', + 'message': 'Transactions retrieved successfully', + 'data': { + 'transactions': transactions_data, + 'count': len(transactions_data) + } + } + + return jsonify(response_data), 200 + + except Exception as e: + logger.error(f"Error retrieving transactions: {str(e)}", exc_info=True) + return jsonify({ + 'status': 'error', + 'message': f'Failed to retrieve transactions: {str(e)}' + }), 500 diff --git a/app/models/offer.py b/app/models/offer.py deleted file mode 100644 index ce62fa2..0000000 --- a/app/models/offer.py +++ /dev/null @@ -1,16 +0,0 @@ -from datetime import datetime, timezone -from app.extensions import db - -class Offer(db.Model): - __tablename__ = 'offers' - - id = db.Column(db.Integer, primary_key=True) - product_id = db.Column(db.String, nullable=False) - min_amount = db.Column(db.Float, nullable=False) - max_amount = db.Column(db.Float, nullable=False) - tenor = db.Column(db.Integer, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc)) - updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc)) - - def __repr__(self): - return f'' \ No newline at end of file diff --git a/app/models/transaction.py b/app/models/transaction.py index 5bde2db..debef7b 100644 --- a/app/models/transaction.py +++ b/app/models/transaction.py @@ -3,6 +3,7 @@ from app.extensions import db from sqlalchemy.exc import IntegrityError from sqlalchemy import and_, or_, not_ + class Transaction(db.Model): __tablename__ = 'transactions' id = db.Column( @@ -10,7 +11,7 @@ class Transaction(db.Model): primary_key=True, autoincrement=True, ) - #id = db.Column(db.Int, primary_key=True) + # id = db.Column(db.Int, primary_key=True) transaction_id = db.Column(db.String(50), nullable=False) account_id = db.Column(db.String(50), nullable=False) type = db.Column(db.String(50), nullable=False) @@ -27,11 +28,9 @@ class Transaction(db.Model): # if cls.query.filter_by(transaction_id=transaction_id).first(): # raise ValueError("Duplicate Transaction") - if cls.query.filter( and_( cls.transaction_id ==transaction_id, cls.type==type) ).first(): + if cls.query.filter(and_(cls.transaction_id == transaction_id, cls.type == type)).first(): raise ValueError("Duplicate Transaction") - - transaction = cls( transaction_id=transaction_id, account_id=account_id, @@ -50,4 +49,42 @@ class Transaction(db.Model): @classmethod def get_transaction_by_id(cls, transaction_id): - return cls.query.get(transaction_id) \ No newline at end of file + return cls.query.get(transaction_id) + + @classmethod + def get_all_transactions(cls, account_id=None, transaction_type=None, channel=None, start_date=None, end_date=None): + """ + Get all transactions with optional filtering + + Args: + account_id (str, optional): Filter by account ID + transaction_type (str, optional): Filter by transaction type + channel (str, optional): Filter by channel + start_date (datetime, optional): Filter by start date + end_date (datetime, optional): Filter by end date + + Returns: + list: List of Transaction objects + """ + query = cls.query + + # Apply filters if provided + if account_id: + query = query.filter(cls.account_id == account_id) + + if transaction_type: + query = query.filter(cls.type == transaction_type) + + if channel: + query = query.filter(cls.channel == channel) + + if start_date: + query = query.filter(cls.created_at >= start_date) + + if end_date: + query = query.filter(cls.created_at <= end_date) + + # Order by created_at descending (newest first) + query = query.order_by(cls.created_at.desc()) + + return query.all() diff --git a/docker-compose.yml b/docker-compose.yml index ef41164..929e4cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,10 @@ services: - digifi-bank-to-product-core: + digifi-core: build: . env_file: - .env ports: - - "${APP_PORT:-4500}:5000" + - "${APP_PORT:-4300}:5000" environment: - FLASK_APP=${FLASK_APP} - FLASK_ENV=${FLASK_ENV} diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/b8f6fd76ead8_migration_on_thu_apr_10_16_21_45_utc_.py b/migrations/versions/b8f6fd76ead8_migration_on_thu_apr_10_16_21_45_utc_.py new file mode 100644 index 0000000..69d16e7 --- /dev/null +++ b/migrations/versions/b8f6fd76ead8_migration_on_thu_apr_10_16_21_45_utc_.py @@ -0,0 +1,86 @@ +"""Migration on Thu Apr 10 16:21:45 UTC 2025 + +Revision ID: b8f6fd76ead8 +Revises: +Create Date: 2025-04-10 16:22:15.946157 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'b8f6fd76ead8' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('repayments', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('loan_id', sa.String(length=50), nullable=False), + sa.Column('customer_id', sa.String(length=50), nullable=False), + sa.Column('product_id', sa.String(length=20), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('loans', schema=None) as batch_op: + batch_op.alter_column('id', + existing_type=sa.VARCHAR(length=50), + type_=sa.Integer(), + existing_nullable=False, + autoincrement=True, + existing_server_default=sa.text("nextval('loan_id_seq'::regclass)")) + + with op.batch_alter_table('transactions', schema=None) as batch_op: + batch_op.alter_column('channel', + existing_type=sa.VARCHAR(length=8), + type_=sa.String(length=50), + existing_nullable=False) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.DateTime(), + existing_nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.DateTime(), + existing_nullable=True, + existing_server_default=sa.text('now()')) + batch_op.drop_constraint('transactions_id_key', type_='unique') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('transactions', schema=None) as batch_op: + batch_op.create_unique_constraint('transactions_id_key', ['id']) + batch_op.alter_column('updated_at', + existing_type=sa.DateTime(), + type_=postgresql.TIMESTAMP(timezone=True), + existing_nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(), + type_=postgresql.TIMESTAMP(timezone=True), + existing_nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('channel', + existing_type=sa.String(length=50), + type_=sa.VARCHAR(length=8), + existing_nullable=False) + + with op.batch_alter_table('loans', schema=None) as batch_op: + batch_op.alter_column('id', + existing_type=sa.Integer(), + type_=sa.VARCHAR(length=50), + existing_nullable=False, + autoincrement=True, + existing_server_default=sa.text("nextval('loan_id_seq'::regclass)")) + + op.drop_table('repayments') + # ### end Alembic commands ### diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 index 0000000..3e73752 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +echo "Running DB migrations..." +flask db migrate -m "Migration on $(date)" +flask db upgrade + +echo "Starting Gunicorn server..." +exec gunicorn -w 4 -b 0.0.0.0:5000 wsgi:wsgi_app