diff --git a/.gitattributes b/.gitattributes index d95a7f8..a8776de 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -/app/secret_key filter=git-crypt diff=git-crypt \ No newline at end of file +secrets filter=git-crypt diff=git-crypt diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1b8db4a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "app/mre_module"] + path = app/mre_module + url = http://10.0.50.3:3002/Quarter/MRE-module.git diff --git a/app/MRE/__init__.py b/app/MRE/__init__.py index 0e56b53..146e692 100644 --- a/app/MRE/__init__.py +++ b/app/MRE/__init__.py @@ -1,7 +1,3 @@ from flask import Blueprint -from .api import api_blueprint -mre_blueprint = Blueprint('mre_blueprint', - __name__, - url_prefix='mre') -mre_blueprint.register_blueprint(api_blueprint) +blueprint = Blueprint('mre', __name__) diff --git a/app/MRE/api.py b/app/MRE/api.py new file mode 100644 index 0000000..58b7c69 --- /dev/null +++ b/app/MRE/api.py @@ -0,0 +1,39 @@ +from flask import current_app +from flask_restx import Api, fields, apidoc, Model, Namespace, Resource +from flask_restx.reqparse import RequestParser +from app.mre_module import compute_mre + +namespace = Namespace( + 'mre', + description='API endpoints for MRE calculations.' +) + + +MRE_request_parser = RequestParser() +MRE_request_parser.add_argument('consumer_fico', + required=True, + type=int, + help='The consumer\'s fico score (int between [300, 850])') +MRE_request_parser.add_argument('home_price', + required=True, + type=float, + help='The price of the home the consumer wants to buy (float)') +MRE_request_parser.add_argument('down_payment', + required=True, + type=float, + help='The down payment (as a percentage) for purchasing the home (float between [0, 1]).') + +@namespace.route('/compute_mre') +@namespace.doc(params={ + 'consumer_fico': 'The consumer\'s fico score (int).', + 'home_price': 'The price of the home (float).', + 'down_payment': 'The down_payment percentage (float).' +}) +class ComputeMRE(Resource): + @namespace.doc(description='Computes and returns the MRE.') + def get(self): + args = MRE_request_parser.parse_args() + mre = compute_mre(args.home_price, args.down_payment, args.consumer_fico) + + return {'consumer_mre': mre} + diff --git a/app/MRE/api/__init__.py b/app/MRE/api/__init__.py deleted file mode 100644 index 065b810..0000000 --- a/app/MRE/api/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from flask import Blueprint - -api_blueprint = Blueprint('api_blueprint', - __name__, - url_prefix='api') \ No newline at end of file diff --git a/app/MRE/api/controller.py b/app/MRE/api/controller.py deleted file mode 100644 index f511f6b..0000000 --- a/app/MRE/api/controller.py +++ /dev/null @@ -1,7 +0,0 @@ -import cmath - -''' -TODO: Actually implement this -''' -def compute_mre(consumer_fico: int, consumer_income: float, home_price: float) -> float: - return None diff --git a/app/MRE/api/routes.py b/app/MRE/api/routes.py deleted file mode 100644 index aee5e4b..0000000 --- a/app/MRE/api/routes.py +++ /dev/null @@ -1,17 +0,0 @@ -from flask import current_app -from flask_restx import Api, fields, apidoc, Model -from . import api_blueprint - -api = Api(api_blueprint) - -mre_from_home_price_model = Model( - { - 'consumer_income': fields.Float(attribute='consumer_income'), - 'home_price': fields.Float(attribute='home_price'), - 'consumer_fico': fields.Integer(attribute='consumer_fico') - } -) - -@api.route('compute_mre_from_home_price') -def compute_mre_from_home_price(): - pass \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 2d4d059..01cab12 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,6 +1,8 @@ -from flask import Flask +from flask import Flask, g, request, url_for +import time import os -from app.MRE import mre_blueprint +from app.MRE import blueprint as mre_blueprint +from .api import blueprint as api_blueprint from logging import Logger, Formatter, getLogger, DEBUG, INFO, FileHandler @@ -19,8 +21,9 @@ def init_logger(app: Flask, log_level=INFO) -> Flask: to_file = FileHandler(os.environ.get('MRE_LOG_PATH', f'./{app.name}.log')) to_file.setFormatter(formatter) logger.addHandler(to_file) - + app.logger = logger + return app @@ -30,5 +33,31 @@ def create_app(app_name: str='MRE') -> Flask: app = init_logger(app) app.register_blueprint(mre_blueprint) + app.register_blueprint(api_blueprint, url_prefix='/api') + + @app.before_request + def before_request(): + g.start = time.time() + + @app.after_request + def after_request(response): + request_time = time.time() - g.start + app.logger.info(f'HTTP request completed (method={request.method}, path={request.path}, request_time={request_time}, status_code={response.status_code}).') + return response + +# def has_no_empty_params(rule): +# defaults = rule.defaults if rule.defaults is not None else () +# arguments = rule.arguments if rule.arguments is not None else () +# return len(defaults) >= len(arguments) +# +# @app.route('/ping') +# def ping(): +# links = [] +# for rule in app.url_map.iter_rules(): +# if "GET" in rule.methods and has_no_empty_params(rule): +# url = url_for(rule.endpoint, **(rule.defaults or {})) +# links.append(url) +# app.logger.info(f'Endpoint: {url}') +# return {'links': links}, 200 return app diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..7860b06 --- /dev/null +++ b/app/api.py @@ -0,0 +1,12 @@ +from flask_restx import Api +from flask import Blueprint +from .MRE.api import namespace as mre_namespace + +blueprint = Blueprint('api', __name__) +api = Api( + blueprint, + doc='/doc/', + title='Quarter API documentatoin' +) +api.add_namespace(mre_namespace, '/mre') + diff --git a/app/mre_module b/app/mre_module new file mode 160000 index 0000000..ce94312 --- /dev/null +++ b/app/mre_module @@ -0,0 +1 @@ +Subproject commit ce94312099a10d3cf273e42d4b315ac17a2f187f diff --git a/docker-compose.yaml b/docker/docker-compose.yaml.tmpl similarity index 56% rename from docker-compose.yaml rename to docker/docker-compose.yaml.tmpl index 3db6620..32d9a43 100644 --- a/docker-compose.yaml +++ b/docker/docker-compose.yaml.tmpl @@ -2,6 +2,8 @@ version: "3.8" services: nginx: + networks: + - frontend image: nginx:latest ports: - "80:80" @@ -10,12 +12,17 @@ services: - ./nginx/conf.d:/etc/nginx/conf.d # Mount Nginx configuration - ./static:/var/www/static # Serve static files depends_on: - - uwsgi + - mre_api - uwsgi: - image: python:3.9-slim-buster # Use a slim Python image + mre_api: + build: + context: . + dockerfile: docker/mre.Dockerfile + networks: + - frontend + - backend volumes: - - ./app:/app # Mount your Flask app directory + - ./app:/var/www/app # Mount your Flask app directory - ./uwsgi.ini:/etc/uwsgi.ini # UWSGI configuration command: uwsgi --ini /etc/uwsgi.ini expose: @@ -23,27 +30,43 @@ services: environment: - FLASK_APP=run.py # Adjust this based on your Flask app's entry point - FLASK_ENV=dev # Dev environment + - MRE_POSTGRES_PASSWORD={{ POSTGRES_PASSWD }} + - MRE_POSTGRES_USER={{ POSTGRES_USER }} + - MRE_REDIS_PASSWORD={{ REDIS_PASSWD }} depends_on: - - redis # Ensure Redis is running before UWSGI starts - + - redis + - postgres + redis: + networks: + - backend image: redis:latest ports: - "6379:6379" # Expose Redis port (for debugging/accessing from outside) volumes: - redis_data:/data # Persist Redis data + command: redis-server --save 20 1 --loglevel {{ LOG_LEGEL }} --requirepass {{ REDIS_PASSWD }} postgres: + networks: + - backend image: postgres:latest ports: - "5432:5432" # Expose PostgreSQL port (for debugging/admin) environment: - - POSTGRES_USER=youruser # Replace with your desired username - - POSTGRES_PASSWORD=yourpassword # Replace with your desired password + - POSTGRES_USER={{ POSTGRES_USER }} # Replace with your desired username + - POSTGRES_PASSWORD={{ POSTGRES_PASSWD }} # Replace with your desired password - POSTGRES_DB=yourdb # Replace with your desired database name volumes: - postgres_data:/var/lib/postgresql/data # Persist PostgreSQL data volumes: postgres_data: - redis_data: \ No newline at end of file + redis_data: + +networks: + frontend: + driver: bridge + backend: + driver: bridge + internal: true diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..8ee4158 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,3 @@ +#! /usr/bin/bash + +uwsgi --ini /var/www/run.py diff --git a/docker/mre.Dockerfile b/docker/mre.Dockerfile new file mode 100644 index 0000000..9d7b992 --- /dev/null +++ b/docker/mre.Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.13.2-bullseye + +LABEL MAINTAINER="Chris Diesch " + +WORKDIR /var/www + +# Add the entrypoint and make it executable +ADD ./docker/entrypoint.sh /var/docker-entrypoint.sh +RUN chmod +x /var/docker-entrypoint.sh + +# add the requirements and install them + +ADD ./requirements.txt /var/www/requirements.txt +RUN pip install -Ur /var/www/requirements.txt + +# add the uwsgi runner +ADD ./run.py /var/www/run.py + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# Copy the app directory +COPY ./app /var/www/app + +# define the entrypoint +ENTRYPOINT ["/var/docker-entrypoint.sh"] diff --git a/requirements.txt b/requirements.txt index 96ede90..ee9b6e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ flask==3.1.0 flask-restx==1.3.0 pyopenssl==25.0.0 -werkzeug==3.1.3 \ No newline at end of file +werkzeug==3.1.3 +jinja2==3.1.6 +pytest==8.3.5 diff --git a/run.py b/run.py index 050634c..561e134 100644 --- a/run.py +++ b/run.py @@ -3,4 +3,4 @@ from app import create_app app = create_app() if __name__ == '__main__': - app.run(ssl_context='adhoc') \ No newline at end of file + app.run(port=8080, host='0.0.0.0') diff --git a/scripts/render_templates.py b/scripts/render_templates.py new file mode 100644 index 0000000..7ad37b5 --- /dev/null +++ b/scripts/render_templates.py @@ -0,0 +1,102 @@ +import os +import jinja2 +import argparse +import traceback + +def render_template(template_file: str, + out_path: str, + **kwargs) -> None: + folder, file = os.path.split(template_file) + template_path = os.path.abspath(folder) + env = jinja2.Environment(loader=jinja2.FileSystemLoader(template_path), + trim_blocks=True, + lstrip_blocks=True) + template = env.get_template(file) + with open(out_path, 'w+') as save: + save.write(template.render(**kwargs)) + + +def load_template_vars_file(var_file_path: str) -> dict: + result = {} + with open(var_file_path) as reader: + for line in reader: + line = line.strip() + if line.startswith('#') or '=' not in line: + continue + key, value = line.split('=', 1) + result[key] = value + + return result + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(prog='render_templates.py', + description='A script to render the docker jinja templates.', + add_help=True) + parser.add_argument('template_path', + help='The path to the template(s) to render (Can be either a directory or a file).') + parser.add_argument('var_file_path', + help='The path to the file containing the variables to use for rendering the tempalte(s).') + parser.add_argument('out_path', + help='The path to write the rendered template (MUST be a directory).') + parser.add_argument('-k', + '--kwargs', + required=False, + type=str, + default='', + help='Additional kwargs to include if needed (format: key1=value1,key2=value2...).') + parser.add_argument('-o', + '--overwrite', + action='store_true', + help='If set the previous template will be overwritten.') + + args = parser.parse_args() + + # make sure we have a template file and a variable file. + if not os.path.exists(args.template_path): + print(f'The given template path {args.template_path} does not exist.') + exit(1) + + if not os.path.exists(args.var_file_path): + print(f'The given template path {args.var_file_path} does not exist.') + exit(1) + + run_on_dir = False + # if the template path is a directory, make sure output path exists + if os.path.isdir(args.template_path): + run_on_dir = True + if not os.path.exists(args.out_path): + os.makedirs(args.out_path) + else: + print('The output path must be a directory since the template path is.') + exit(1) + + args.template_path = [os.path.join(args.template_path, p) + for p in filter(lambda x: x.ends_with('.tmpl'), + os.listdir(args.template_path))] + # if the tempalte path is a file + else: + # wrap it in a list + args.template_path = [args.template_path] + + # load the variables from the variable file + kwargs = load_template_vars_file(args.var_file_path) + args.kwargs = {a.split('=')[0]: a.split('=')[1] for a in args.kwargs.split(',')} if args.kwargs else {} + for k in kwargs: + # The assumption made here is that anything passed on the commandline should be prioritized + # over values that appear in the template variables file. + if k not in args.kwargs: + args.kwargs[k] = kwargs[k] + + for template in args.template_path: + out_file_name = os.path.join(args.out_path, + os.path.split(template.replace('.tmpl', ''))[1]) + # if the output exists and we aren't overwritting files, then move on. + if os.path.exists(out_file_name) and not args.overwrite: + continue + + try: + render_template(template, out_file_name, **args.kwargs) + except Exception as ex: + print(f'Failed to render template "{template}":') + print(f'Error: {str(ex)}\n{traceback.format_exec()}') diff --git a/start.sh b/scripts/start.sh similarity index 100% rename from start.sh rename to scripts/start.sh diff --git a/stop.sh b/scripts/stop.sh similarity index 100% rename from stop.sh rename to scripts/stop.sh diff --git a/secrets b/secrets index ccaaa2f..f1a5955 100644 Binary files a/secrets and b/secrets differ