diff --git a/.example.env b/.example.env new file mode 100644 index 0000000..4878aad --- /dev/null +++ b/.example.env @@ -0,0 +1,21 @@ +VALID_APP_ID=********** +VALID_API_KEY=************* +BASIC_AUTH_USERNAME=****** +BASIC_AUTH_PASSWORD=****** + + +SWAGGER_URL="/documentation" +API_URL="/swagger.json" + + + +DATABASE_USER=***** +DATABASE_PASSWORD=***** +DATABASE_HOST=****** +DATABASE_PORT=****** +DATABASE_NAME=***** + +# Flask Configuration +FLASK_APP=wsgi.py +FLASK_ENV=development +APP_PORT=4500 diff --git a/Dockerfile b/Dockerfile index fa0ff11..0bc8e59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,5 +17,6 @@ EXPOSE 5000 ENV FLASK_APP=app.py ENV FLASK_RUN_HOST=0.0.0.0 -# Run the application -CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "wsgi:wsgi_app"] \ No newline at end of file +RUN chmod +x scripts/entrypoint.sh + +ENTRYPOINT ["scripts/entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md index 7911c12..76963e2 100644 --- a/README.md +++ b/README.md @@ -20,25 +20,39 @@ cd repository ``` -### 2. Create a `.env` File +### 2. Configure Environment Variables -Before running the application, create a `.env` file in the root directory and add the required environment variables: +Instead of creating a new `.env` file, rename the provided `.env.example` file and update the necessary variables: ```bash -touch .env +cp .env.example .env ``` -Then, open the `.env` file and add the following: +Then, open the `.env` file and **update the following variables with your actual configuration:** -```ini -# Environment Variables -BASIC_AUTH_USERNAME=user -BASIC_AUTH_PASSWORD=password -SWAGGER_URL="/documentation" -API_URL="/swagger.json" +- Database credentials: + ```ini + DATABASE_USER=your_database_username + DATABASE_PASSWORD=your_database_password + DATABASE_NAME=your_database_name + DATABASE_PORT=5432 + ``` +- App ID and API Key: + ```ini + APP_ID=your_app_id + API_KEY=your_api_key + ``` + +This ensures that the application is properly configured with your environment variables. + +> **Optional:** Change file permissions for the entrypoint script + +```bash +chmod +x scripts/entrypoint.sh ``` -This ensures that the application uses secure API keys and app IDs. +--- + ### 3. Run the Application with Docker Compose diff --git a/app/__init__.py b/app/__init__.py index e7b7836..2a8d34d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -5,6 +5,11 @@ from flask_cors import CORS from app.config import Config from app.api.routes import api from app.errors import register_error_handlers +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate + +db = SQLAlchemy() +migrate = Migrate() def create_app(): """ Factory function to create a Flask app instance """ @@ -31,7 +36,21 @@ def create_app(): # Error Handlers register_error_handlers(app) - + import logging + from sqlalchemy import create_engine + + # Set up logging + logging.basicConfig(level=logging.INFO) + logger = logging.getLogger(__name__) + + # Log the database URI + logger.info(f"Database URI: {app.config['SQLALCHEMY_DATABASE_URI']}") + + # Database and Migrations + db.init_app(app) + + + migrate.init_app(app, db) return app diff --git a/app/api/services/account_service.py b/app/api/services/account_service.py new file mode 100644 index 0000000..a452b17 --- /dev/null +++ b/app/api/services/account_service.py @@ -0,0 +1,15 @@ +from app.models import Account + +def check_account_settings(account_id, customer_id): + """ + Checks if the account belongs to the customer and if it has existing liens. + """ + account = Account.query.filter_by(id=account_id, customer_id=customer_id).first() + + if not account: + return False, "Account not found or doesn't belong to customer" + + if account.lien_amount > 0: + return False, "Account has an existing lien" + + return True, "Account is valid" \ No newline at end of file diff --git a/app/api/services/customer_service.py b/app/api/services/customer_service.py new file mode 100644 index 0000000..582aa1e --- /dev/null +++ b/app/api/services/customer_service.py @@ -0,0 +1,7 @@ +from app.models import Customer + +def check_customer_eligibility(data): + # Verify customer exists + customer = Customer.query.filter_by(id=data['customerId']).first() + if not customer: + return False, "Customer not found" \ No newline at end of file diff --git a/app/api/services/loan_service.py b/app/api/services/loan_service.py new file mode 100644 index 0000000..3beb98c --- /dev/null +++ b/app/api/services/loan_service.py @@ -0,0 +1,10 @@ + +from app.models import Loan + +def check_active_loans(data): + active_loans = Loan.query.filter_by( + customer_id=data['customerId'], + status='active' + ).count() + if active_loans > 0: + return False, "Customer has active loans" \ No newline at end of file diff --git a/app/config.py b/app/config.py index 9d37a22..e237b8c 100644 --- a/app/config.py +++ b/app/config.py @@ -14,4 +14,16 @@ class Config: VALID_APP_ID = os.getenv("VALID_APP_ID", "app1") VALID_API_KEY = os.getenv("VALID_API_KEY", "test-api-key-12345") BASIC_AUTH_USERNAME = os.environ.get("BASIC_AUTH_USERNAME", "user") - BASIC_AUTH_PASSWORD = os.environ.get("BASIC_AUTH_PASSWORD", "password") \ No newline at end of file + BASIC_AUTH_PASSWORD = os.environ.get("BASIC_AUTH_PASSWORD", "password") + + DATABASE_USER = os.environ.get("DATABASE_USER") + DATABASE_PASSWORD = os.environ.get("DATABASE_PASSWORD") + DATABASE_HOST = os.environ.get("DATABASE_HOST") + DATABASE_PORT = os.environ.get("DATABASE_PORT", 10532) + DATABASE_NAME = os.environ.get("DATABASE_NAME") + + SQLALCHEMY_DATABASE_URI = ( + # f"postgresql+psycopg2://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}" + f"postgresql+psycopg2://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}?options=-csearch_path%3Dflask_app" + ) + SQLALCHEMY_TRACK_MODIFICATIONS = False \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..8fd1277 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,6 @@ +from .customer import Customer +from .account import Account +from .loan import Loan +from .transaction import Transaction + +__all__ = ['Customer', 'Account', 'Loan', 'Transaction'] \ No newline at end of file diff --git a/app/models/account.py b/app/models/account.py new file mode 100644 index 0000000..0b11842 --- /dev/null +++ b/app/models/account.py @@ -0,0 +1,15 @@ +from datetime import datetime +from app import db + +class Account(db.Model): + id = db.Column(db.String(50), primary_key=True) + customer_id = db.Column(db.String(50), db.ForeignKey('customer.id'), nullable=False) + account_type = db.Column(db.String(50)) + status = db.Column(db.String(20), default='active') + lien_amount = db.Column(db.Float, default=0.0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f'' + \ No newline at end of file diff --git a/app/models/customer.py b/app/models/customer.py new file mode 100644 index 0000000..eb83fb3 --- /dev/null +++ b/app/models/customer.py @@ -0,0 +1,12 @@ +from datetime import datetime +from app import db + +class Customer(db.Model): + id = db.Column(db.String(50), primary_key=True) + msisdn = db.Column(db.String(20), unique=True, nullable=False) + country_code = db.Column(db.String(3), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/app/models/loan.py b/app/models/loan.py new file mode 100644 index 0000000..4774508 --- /dev/null +++ b/app/models/loan.py @@ -0,0 +1,16 @@ +from datetime import datetime +from app import db + + +class Loan(db.Model): + id = db.Column(db.String(50), primary_key=True) + customer_id = db.Column(db.String(50), db.ForeignKey('customer.id'), nullable=False) + account_id = db.Column(db.String(50), db.ForeignKey('account.id'), nullable=False) + product_id = db.Column(db.String(20), nullable=False) + principal_amount = db.Column(db.Float, nullable=False) + status = db.Column(db.String(20), default='pending') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/app/models/offer.py b/app/models/offer.py new file mode 100644 index 0000000..75dac3d --- /dev/null +++ b/app/models/offer.py @@ -0,0 +1,12 @@ +from datetime import datetime +from app import db + +class Offer(db.Model): + id = db.Column(db.Integer, primary_key=True) + amount = db.Column(db.Float, nullable=False) + interest_rate = db.Column(db.Float, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/app/models/transaction.py b/app/models/transaction.py new file mode 100644 index 0000000..1a1cfd3 --- /dev/null +++ b/app/models/transaction.py @@ -0,0 +1,14 @@ +from datetime import datetime +from app import db + +class Transaction(db.Model): + id = db.Column(db.String(50), primary_key=True) + account_id = db.Column(db.String(50), db.ForeignKey('account.id'), nullable=False) + type = db.Column(db.String(50), nullable=False) + amount = db.Column(db.Float, nullable=False) + status = db.Column(db.String(20), default='pending') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e3765f2..80445eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,36 @@ +version: '3.8' + services: - digifi-flaska002: + db: + image: postgres:13 + environment: + - POSTGRES_USER=${DATABASE_USER} + - POSTGRES_PASSWORD=${DATABASE_PASSWORD} + - POSTGRES_DB=${DATABASE_NAME} + ports: + - "${DATABASE_PORT}:${DATABASE_PORT}" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER} -d ${DATABASE_NAME}"] + interval: 5s + timeout: 5s + retries: 5 + + digifi-bank-to-product-core: build: . ports: - - "4500:5000" + - "${APP_PORT:-4500}:5000" environment: - - FLASK_APP=app.py - - FLASK_RUN_HOST=0.0.0.0 + - FLASK_APP=wsgi.py + - FLASK_ENV=development + - DATABASE_URL=postgresql+psycopg2://${DATABASE_USER}:${DATABASE_PASSWORD}@db:${DATABASE_PORT}/${DATABASE_NAME}?options=-csearch_path%3Dflask_app volumes: - .:/app + depends_on: + db: + condition: service_healthy restart: always + +volumes: + postgres_data: \ No newline at end of file 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/requirements.txt b/requirements.txt index d5cb30f..3c9ceeb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,29 @@ # Flask and Extensions Flask==2.3.3 + +# Schema for validations Flask-Marshmallow==0.15.0 marshmallow==3.19.0 + +# CORS Flask-Cors==3.0.10 + +# Deployment gunicorn + +# Swagger flask-swagger-ui +# Database +flask-sqlalchemy +flask-migrate +psycopg2-binary +alembic + + +# Env +python-dotenv + diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100755 index 0000000..8c7018a --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +echo "Running DB migrations..." +flask db upgrade + +echo "Starting Gunicorn server..." +exec gunicorn -w 4 -b 0.0.0.0:5000 wsgi:wsgi_app