├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── docker ├── .dockerignore ├── Dockerfile ├── docker-compose.dev.yaml ├── docker-compose.pro.yaml ├── docker-compose.test.yaml └── docker-compose.yaml ├── docs └── forex.yaml ├── fixtures ├── exchange_rate.json └── provider.json ├── manage.py ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── scripts ├── entrypoint.sh ├── start_api.sh └── start_worker.sh ├── src ├── __init__.py ├── domain │ ├── __init__.py │ ├── constants.py │ ├── core │ │ ├── __init__.py │ │ ├── constants.py │ │ └── routing.py │ ├── exchange_rate.py │ └── provider.py ├── infrastructure │ ├── __init__.py │ ├── adminsite │ │ ├── __init__.py │ │ ├── exchange_rate │ │ │ ├── __init__.py │ │ │ └── admin.py │ │ └── provider │ │ │ ├── __init__.py │ │ │ ├── admin.py │ │ │ └── forms.py │ ├── api │ │ ├── __init__.py │ │ ├── routes │ │ │ ├── __init__.py │ │ │ ├── exchange_rate │ │ │ │ ├── __init__.py │ │ │ │ ├── routers.py │ │ │ │ └── urls.py │ │ │ └── urls.py │ │ └── views │ │ │ ├── __init__.py │ │ │ └── exchange_rate.py │ ├── clients │ │ ├── __init__.py │ │ └── provider │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── decorators.py │ │ │ ├── drivers.py │ │ │ ├── exceptions.py │ │ │ ├── exchange_rate_api │ │ │ ├── __init__.py │ │ │ ├── drivers.py │ │ │ ├── exceptions.py │ │ │ └── serializers.py │ │ │ ├── fixer │ │ │ ├── __init__.py │ │ │ ├── drivers.py │ │ │ ├── exceptions.py │ │ │ └── serializers.py │ │ │ ├── mock │ │ │ ├── __init__.py │ │ │ ├── drivers.py │ │ │ └── requests.py │ │ │ ├── utils.py │ │ │ └── xchange_api │ │ │ ├── __init__.py │ │ │ ├── currencies.json │ │ │ ├── drivers.py │ │ │ ├── exceptions.py │ │ │ └── serializers.py │ ├── factories │ │ ├── __init__.py │ │ ├── exchange_rates.py │ │ └── provider.py │ ├── orm │ │ ├── __init__.py │ │ ├── cache │ │ │ ├── __init__.py │ │ │ ├── exchange_rate │ │ │ │ ├── __init__.py │ │ │ │ ├── constants.py │ │ │ │ └── repositories.py │ │ │ └── provider │ │ │ │ ├── __init__.py │ │ │ │ ├── constants.py │ │ │ │ └── repositories.py │ │ └── db │ │ │ ├── __init__.py │ │ │ ├── apps.py │ │ │ ├── exchange_rate │ │ │ ├── __init__.py │ │ │ ├── admin.py │ │ │ ├── migrations │ │ │ │ ├── 0001_create_currency_and_exchangerate.py │ │ │ │ └── __init__.py │ │ │ ├── models.py │ │ │ ├── repositories.py │ │ │ └── tasks.py │ │ │ └── provider │ │ │ ├── __init__.py │ │ │ ├── admin.py │ │ │ ├── constants.py │ │ │ ├── migrations │ │ │ ├── 0001_create_provider_and_providersetting.py │ │ │ └── __init__.py │ │ │ ├── models.py │ │ │ └── repositories.py │ ├── server │ │ ├── __init__.py │ │ ├── celery.py │ │ ├── urls.py │ │ └── wsgi.py │ └── settings │ │ ├── __init__.py │ │ ├── base.py │ │ ├── development.py │ │ ├── production.py │ │ └── test.py ├── interface │ ├── clients │ │ ├── __init__.py │ │ └── provider.py │ ├── controllers │ │ ├── __init__.py │ │ ├── exchange_rate.py │ │ └── utils.py │ ├── repositories │ │ ├── __init__.py │ │ ├── exceptions.py │ │ ├── exchange_rate.py │ │ └── provider.py │ ├── routes │ │ ├── __init__.py │ │ ├── constants.py │ │ └── exchange_rate.py │ └── serializers │ │ ├── __init__.py │ │ └── exchange_rate.py └── usecases │ ├── __init__.py │ ├── exchange_rate.py │ └── provider.py └── tests ├── __init__.py ├── domain ├── __init__.py ├── test_exchange_rate_entities.py ├── test_provider_entities.py └── test_routing.py ├── fixtures.py ├── infrastructure ├── __init__.py ├── api │ ├── __init__.py │ └── views │ │ ├── __init__.py │ │ ├── integration │ │ ├── __init__.py │ │ └── test_exchange_rate_views.py │ │ └── unit │ │ ├── __init.py │ │ └── test_exchange_rate_views.py └── orm │ ├── __init__.py │ ├── cache │ ├── __init__.py │ ├── integration │ │ ├── __init__.py │ │ ├── test_exchange_rate_repositories.py │ │ └── test_provider_repositories.py │ └── unit │ │ ├── __init__.py │ │ ├── test_exchange_rate_repositories.py │ │ └── test_provider_repositories.py │ └── db │ ├── __init__.py │ ├── factories │ ├── __init__.py │ ├── exchange_rate.py │ └── provider.py │ ├── integration │ ├── __init__.py │ ├── test_exchange_rate_repositories.py │ ├── test_exchange_rate_tasks.py │ └── test_provider_repositories.py │ └── unit │ ├── __init__.py │ ├── test_exchange_rate_models.py │ ├── test_exchange_rate_repositories.py │ ├── test_exchange_rate_tasks.py │ ├── test_provider_models.py │ └── test_provider_repositories.py ├── interface ├── __init__.py ├── controllers │ ├── __init__.py │ └── test_exchange_rate_controllers.py ├── repositories │ ├── __init__.py │ ├── test_exchange_rate_repositories.py │ └── test_provider_repositories.py └── serializers │ ├── __init__.py │ └── test_exchange_rate_serializers.py └── usecases ├── __init__.py ├── test_exchange_rate_interactors.py └── test_provider_interactors.py /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.html text 4 | *.css text 5 | 6 | *.bat text eol=crlf 7 | *.sh text eol=lf 8 | 9 | *.jpg binary 10 | *.png binary 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Celery stuff 10 | celerybeat-schedule 11 | celerybeat.pid 12 | 13 | # Cython debug symbols 14 | cython_debug/ 15 | 16 | # Django stuff: 17 | *.log 18 | local_settings.py 19 | db.sqlite3 20 | db.sqlite3-journal 21 | 22 | # Distribution / packaging 23 | .Python 24 | build/ 25 | develop-eggs/ 26 | dist/ 27 | downloads/ 28 | eggs/ 29 | .eggs/ 30 | lib/ 31 | lib64/ 32 | parts/ 33 | sdist/ 34 | var/ 35 | wheels/ 36 | share/python-wheels/ 37 | *.egg-info/ 38 | .installed.cfg 39 | *.egg 40 | MANIFEST 41 | 42 | # Environments 43 | .venv 44 | env/ 45 | venv/ 46 | ENV/ 47 | env.bak/ 48 | venv.bak/ 49 | 50 | # Installer logs 51 | pip-log.txt 52 | pip-delete-this-directory.txt 53 | 54 | # PEP 582 55 | __pypackages__/ 56 | 57 | # PyBuilder 58 | .pybuilder/ 59 | target/ 60 | 61 | # pyenv 62 | .python-version 63 | 64 | # PyInstaller 65 | *.manifest 66 | *.spec 67 | 68 | # Translations 69 | *.mo 70 | *.pot 71 | 72 | # Unit test / coverage reports 73 | htmlcov/ 74 | .tox/ 75 | .nox/ 76 | .coverage 77 | .coverage.* 78 | .cache 79 | nosetests.xml 80 | coverage.xml 81 | *.cover 82 | *.py,cover 83 | .hypothesis/ 84 | .pytest_cache/ 85 | cover/ 86 | 87 | # Sphinx documentation 88 | docs/_build/ 89 | 90 | # VSCode Editor 91 | .vscode 92 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sergio de Diego 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # _Forex - Django Clean Architecture_ 2 | 3 | This repository contains the code for Forex API backend. The application aims to provide information for all currencies, latests and historical time series exchange rates for currency pairs, currency conversion and time weighted rates calculation. 4 | 5 | ## Documentation 6 | 7 | This project has been developed using [Django][django] and [Django Rest Framework][djangorestframework], with [Celery][celery] as background tasks runner, [Postgres][postgres] as relational database and [Redis][redis] as cache service. 8 | 9 | Code structure implementation follows a [Clean Architecture][cleanarchitecture] approach, emphasizing on code readability, responsibility decoupling and unit testing. 10 | 11 | For API backend endpoints documentation refer to the [forex yaml][swagger] file in the docs directory. 12 | 13 | ## Setup 14 | 15 | Download source code cloning this repository: 16 | ``` 17 | git clone https://github.com/sdediego/django-clean-architecture.git 18 | ``` 19 | 20 | Get free API Key for the following exchange rate services: 21 | 1. https://fixer.io/ 22 | 2. https://xchangeapi.com/ 23 | 3. https://www.exchangerate-api.com/ 24 | 25 | Later update database with each value for the corresponding provider _api_key_ setting using the backend admin panel. 26 | 27 | ## Run the API backend: 28 | 29 | Create docker images and execute the containers for development. From the project directory: 30 | ``` 31 | docker-compose -f ./docker/docker-compose.yaml -f ./docker/docker-compose.dev.yaml up 32 | ``` 33 | 34 | Shutdown the application and remove network and containers gracefully: 35 | ``` 36 | docker-compose -f ./docker/docker-compose.yaml -f ./docker/docker-compose.dev.yaml down 37 | ``` 38 | 39 | ## Execute tests suite 40 | 41 | 1. Execute the docker containers with environment variables setup for testing: 42 | ``` 43 | docker-compose -f ./docker/docker-compose.yaml -f ./docker/docker-compose.test.yaml up 44 | ``` 45 | 2. Access running api backend _forex_api_ docker container shell: 46 | ``` 47 | docker exec -it forex_api bash 48 | ``` 49 | 3. Execute pytest command from project directory: 50 | ``` 51 | pytest 52 | ``` 53 | 54 | [//]: # (These are reference links used in the body of this note and get stripped out when the markdown processor does its job.) 55 | 56 | [django]: 57 | [djangorestframework]: 58 | [celery]: 59 | [postgres]: 60 | [redis]: 61 | [cleanarchitecture]: 62 | [swagger]: 63 | -------------------------------------------------------------------------------- /docker/.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | **/__pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Celery stuff 10 | celerybeat-schedule 11 | celerybeat.pid 12 | 13 | # Cython debug symbols 14 | cython_debug/ 15 | 16 | # Django stuff: 17 | *.log 18 | db.sqlite3 19 | db.sqlite3-journal 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # Docker 42 | Dockerfile 43 | docker-compose.yaml 44 | 45 | # Git 46 | .git 47 | .gitattributes 48 | .gitignore 49 | 50 | # Installer logs 51 | pip-log.txt 52 | pip-delete-this-directory.txt 53 | 54 | # PEP 582 55 | __pypackages__/ 56 | 57 | # Project stuff 58 | LICENSE 59 | README.md 60 | 61 | # PyBuilder 62 | .pybuilder/ 63 | target/ 64 | 65 | # pyenv 66 | .python-version 67 | 68 | # PyInstaller. 69 | *.manifest 70 | *.spec 71 | 72 | # Translations 73 | *.mo 74 | *.pot 75 | 76 | # Unit test / coverage reports 77 | htmlcov/ 78 | .tox/ 79 | .nox/ 80 | .coverage 81 | .coverage.* 82 | .cache 83 | nosetests.xml 84 | coverage.xml 85 | *.cover 86 | *.py,cover 87 | .hypothesis/ 88 | .pytest_cache/ 89 | cover/ 90 | 91 | # Sphinx documentation 92 | docs/ 93 | docs/_build/ 94 | 95 | # VSCode Editor 96 | .vscode 97 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | 3 | # environment variables 4 | ARG DJANGO_ENV 5 | 6 | ENV DJANGO_ENV=$DJANGO_ENV \ 7 | PROJECT_DIR="/code" \ 8 | # python 9 | PYTHONFAULTHANDLER=1 \ 10 | PYTHONHASHSEED=random \ 11 | PYTHONUNBUFFERED=1 \ 12 | # pip 13 | PIP_NO_CHACHE_DIR=off \ 14 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 15 | PIP_DEFAULT_TIMEOUT=100 \ 16 | # poetry 17 | POETRY_VERSION=1.1.7 \ 18 | POETRY_VIRTUALENVS_CREATE=false \ 19 | PATH="${PATH}:/root/.poetry/bin" 20 | 21 | # system dependencies 22 | RUN apt-get update && apt-get upgrade -y \ 23 | && apt-get install --no-install-recommends --no-install-suggests -y \ 24 | build-essential \ 25 | libpq-dev \ 26 | postgresql \ 27 | postgresql-contrib \ 28 | python3-dev \ 29 | && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* \ 30 | && pip install poetry==$POETRY_VERSION && poetry --version 31 | 32 | # set working directory 33 | WORKDIR $PROJECT_DIR 34 | 35 | # copy script as an entry point: 36 | COPY ./scripts ${PROJECT_DIR}/scripts/ 37 | 38 | # copy dependencies only 39 | COPY ./pyproject.toml ./poetry.lock ${PROJECT_DIR}/ 40 | 41 | # setting up proper permissions 42 | RUN chmod +x ${PROJECT_DIR}/scripts/entrypoint.sh \ 43 | && chmod +x ${PROJECT_DIR}/scripts/start_api.sh \ 44 | && chmod +x ${PROJECT_DIR}/scripts/start_worker.sh \ 45 | && groupadd -r api && useradd -d /code -r -g api api \ 46 | && chown api:api -R /code \ 47 | # install dependencies 48 | && poetry config virtualenvs.create false \ 49 | && poetry install $(test "$DJANGO_ENV" = production && echo "--no-dev") --no-interaction --no-ansi 50 | 51 | # expose port 52 | EXPOSE $PORT 53 | 54 | # copy project 55 | COPY --chown=api:api . ${PROJECT_DIR}/ 56 | 57 | # running as non-root user: 58 | USER api 59 | 60 | # run server 61 | CMD ./scripts/entrypoint.sh 62 | -------------------------------------------------------------------------------- /docker/docker-compose.dev.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | api: 5 | image: forex:development 6 | restart: on-failure 7 | command: sh scripts/start_api.sh 8 | environment: 9 | - DJANGO_ENV=development 10 | - DJANGO_PORT=8000 11 | volumes: 12 | - ../scripts:/code/scripts 13 | - ../src:/code/src 14 | - ../tests:/code/tests 15 | cache: 16 | restart: on-failure 17 | environment: 18 | - REDIS_PORT=6379 19 | volumes: 20 | - redisdata_development:/var/lib/redis/data 21 | db: 22 | restart: on-failure 23 | environment: 24 | - POSTGRES_DB=${POSTGRES_DB:-forex} 25 | - POSTGRES_USER=${POSTGRES_USER:-postgres} 26 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} 27 | - POSTGRES_PORT=${POSTGRES_PORT:-5432} 28 | volumes: 29 | - pgdata_development:/var/lib/postgresql/data 30 | healthcheck: 31 | test: pg_isready -q -d forex -U postgres 32 | interval: 10s 33 | timeout: 10s 34 | retries: 5 35 | worker: 36 | image: worker:development 37 | restart: on-failure 38 | command: sh scripts/start_worker.sh 39 | environment: 40 | - DJANGO_ENV=development 41 | volumes: 42 | - ../scripts:/code/scripts 43 | - ../src:/code/src 44 | - ../tests:/code/tests 45 | 46 | volumes: 47 | pgdata_development: 48 | driver: local 49 | name: pgdata_development 50 | redisdata_development: 51 | driver: local 52 | name: redisdata_development 53 | -------------------------------------------------------------------------------- /docker/docker-compose.pro.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | api: 5 | image: forex:production 6 | restart: always 7 | command: sh scripts/start_api.sh 8 | environment: 9 | - DJANGO_ENV=production 10 | - DJANGO_PORT=${DJANGO_PORT} 11 | cache: 12 | restart: always 13 | environment: 14 | - REDIS_PORT=${REDIS_PORT} 15 | volumes: 16 | - redisdata_production:/var/lib/redis/data 17 | db: 18 | restart: always 19 | environment: 20 | - POSTGRES_DB=${POSTGRES_DB} 21 | - POSTGRES_USER=${POSTGRES_USER} 22 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 23 | - POSTGRES_PORT=${POSTGRES_PORT} 24 | volumes: 25 | - pgdata_production:/var/lib/postgresql/data 26 | healthcheck: 27 | test: pg_isready -q -d ${POSTGRES_DB} -U postgres 28 | interval: 10s 29 | timeout: 10s 30 | retries: 5 31 | worker: 32 | image: worker:production 33 | restart: always 34 | command: sh scripts/start_worker.sh 35 | environment: 36 | - DJANGO_ENV=production 37 | 38 | volumes: 39 | pgdata_production: 40 | driver: local 41 | name: pgdata_production 42 | redisdata_production: 43 | driver: local 44 | name: redisdata_production 45 | -------------------------------------------------------------------------------- /docker/docker-compose.test.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | api: 5 | image: forex:test 6 | restart: on-failure 7 | command: sh scripts/start_api.sh 8 | environment: 9 | - DJANGO_ENV=test 10 | - DJANGO_PORT=8000 11 | volumes: 12 | - ../scripts:/code/scripts 13 | - ../src:/code/src 14 | - ../tests:/code/tests 15 | cache: 16 | restart: on-failure 17 | environment: 18 | - REDIS_PORT=6379 19 | volumes: 20 | - redisdata_test:/var/lib/redis/data 21 | db: 22 | restart: on-failure 23 | environment: 24 | - POSTGRES_DB=${POSTGRES_DB:-forex_test} 25 | - POSTGRES_USER=${POSTGRES_USER:-postgres} 26 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} 27 | - POSTGRES_PORT=${POSTGRES_PORT:-5432} 28 | volumes: 29 | - pgdata_test:/var/lib/postgresql/data 30 | healthcheck: 31 | test: pg_isready -q -d forex_test -U postgres 32 | interval: 10s 33 | timeout: 10s 34 | retries: 5 35 | worker: 36 | image: worker:test 37 | restart: on-failure 38 | command: sh scripts/start_worker.sh 39 | environment: 40 | - DJANGO_ENV=test 41 | volumes: 42 | - ../scripts:/code/scripts 43 | - ../src:/code/src 44 | - ../tests:/code/tests 45 | 46 | volumes: 47 | pgdata_test: 48 | driver: local 49 | name: pgdata_test 50 | redisdata_test: 51 | driver: local 52 | name: redisdata_test 53 | -------------------------------------------------------------------------------- /docker/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | api: 5 | build: 6 | context: ./.. 7 | dockerfile: ./docker/Dockerfile 8 | container_name: forex_api 9 | ports: 10 | - ${DJANGO_PORT:-8000}:${DJANGO_PORT:-8000} 11 | depends_on: 12 | db: 13 | condition: service_healthy 14 | cache: 15 | condition: service_healthy 16 | cache: 17 | image: redis:6.2 18 | container_name: forex_cache 19 | ports: 20 | - ${REDIS_PORT:-6379}:${REDIS_PORT:-6379} 21 | healthcheck: 22 | test: redis-cli ping 23 | interval: 10s 24 | timeout: 10s 25 | retries: 5 26 | db: 27 | image: postgres:12.7 28 | container_name: forex_db 29 | ports: 30 | - ${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432} 31 | worker: 32 | build: 33 | context: ./.. 34 | dockerfile: ./docker/Dockerfile 35 | container_name: forex_worker 36 | depends_on: 37 | db: 38 | condition: service_healthy 39 | cache: 40 | condition: service_healthy 41 | -------------------------------------------------------------------------------- /docs/forex.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | description: "Forex API backend server. Includes calls to retrieve currencies, exchange rates, currency convertion and time weighted rates" 4 | version: "1.0.0" 5 | title: "Forex API backend server" 6 | contact: 7 | email: "sergiodediego@outlook.com" 8 | license: 9 | name: "MIT License" 10 | url: "https://choosealicense.com/licenses/mit/" 11 | host: "localhost:8000" 12 | basePath: "/api/exchange-rates" 13 | tags: 14 | - name: "currency" 15 | description: "Currency detail and list of available currencies" 16 | - name: "exchange rate" 17 | description: "Currency convertion, exchange rates and time weighted rates" 18 | schemes: 19 | - http 20 | paths: 21 | /: 22 | get: 23 | tags: 24 | - "exchange rate" 25 | summary: "Currency exchange rates time series" 26 | description: "Multiple comma separated exchanged currency codes can be provided" 27 | produces: 28 | - "application/json" 29 | parameters: 30 | - name: "source_currency" 31 | in: "query" 32 | description: "Source currency code" 33 | required: true 34 | type: "string" 35 | - name: "exchanged_currency" 36 | in: "query" 37 | description: "Exchanged currencies codes separated by commas" 38 | required: true 39 | type: "string" 40 | - name: "date_from" 41 | in: "query" 42 | description: "Start date in YYYY-MM-DD format" 43 | required: true 44 | type: "string" 45 | format: "date-time" 46 | - name: "date_to" 47 | in: "query" 48 | description: "End date in YYYY-MM-DD format" 49 | required: true 50 | type: "string" 51 | format: "date-time" 52 | responses: 53 | "200": 54 | description: "Successful operation" 55 | schema: 56 | type: "array" 57 | items: 58 | $ref: "#/definitions/CurrencyExchangeRate" 59 | "400": 60 | description: "Invalid parameters supplied" 61 | /convert/: 62 | get: 63 | tags: 64 | - "exchange rate" 65 | summary: "Convert currency amount to exchanged currency" 66 | description: "Lastest exchange rate for currency pair is used" 67 | produces: 68 | - "application/json" 69 | parameters: 70 | - name: "source_currency" 71 | in: "query" 72 | description: "Source currency code" 73 | required: true 74 | type: "string" 75 | - name: "exchanged_currency" 76 | in: "query" 77 | description: "Exchanged currency code to convert into" 78 | required: true 79 | type: "string" 80 | - name: "amount" 81 | in: "query" 82 | description: "Source currency amount to convert" 83 | required: true 84 | type: "number" 85 | format: "float" 86 | responses: 87 | "200": 88 | description: "Successful operation" 89 | schema: 90 | $ref: "#/definitions/CurrencyExchangeRateAmount" 91 | "400": 92 | description: "Invalid parameters supplied" 93 | "404": 94 | description: "Exchange rate for convertion not found" 95 | /currencies/: 96 | get: 97 | tags: 98 | - "currency" 99 | summary: "Currencies list" 100 | description: "Complete list of available currencies" 101 | produces: 102 | - "application/json" 103 | responses: 104 | "200": 105 | description: "Successful operation" 106 | schema: 107 | type: "array" 108 | items: 109 | $ref: "#/definitions/Currency" 110 | /currencies/{code}/: 111 | get: 112 | tags: 113 | - "currency" 114 | summary: "Currency info" 115 | description: "Currency code, symbol and name details" 116 | produces: 117 | - "application/json" 118 | parameters: 119 | - in: "path" 120 | name: "code" 121 | description: "Standard ISO 4217 alphabetic three-letter currency code" 122 | required: true 123 | type: "string" 124 | responses: 125 | "200": 126 | description: "Successful operation" 127 | schema: 128 | $ref: "#/definitions/Currency" 129 | "404": 130 | description: "Currency not found" 131 | /time-weighted/: 132 | get: 133 | tags: 134 | - "exchange rate" 135 | summary: "Time weighted rate (TWR) of return" 136 | description: "Calculate TWR compound measure" 137 | produces: 138 | - "application/json" 139 | parameters: 140 | - name: "source_currency" 141 | in: "query" 142 | description: "Source currency code" 143 | required: true 144 | type: "string" 145 | - name: "exchanged_currency" 146 | in: "query" 147 | description: "Exchanged currency code" 148 | required: true 149 | type: "string" 150 | - name: "date_from" 151 | in: "query" 152 | description: "Start date in YYYY-MM-DD format" 153 | required: true 154 | type: "string" 155 | format: "date-time" 156 | - name: "date_to" 157 | in: "query" 158 | description: "End date in YYYY-MM-DD format" 159 | required: true 160 | type: "string" 161 | format: "date-time" 162 | responses: 163 | "200": 164 | description: "Successful operation" 165 | schema: 166 | $ref: "#/definitions/TimeWeightedRate" 167 | "400": 168 | description: "Invalid parameters supplied" 169 | "500": 170 | description: "Remote server not available" 171 | definitions: 172 | Currency: 173 | type: "object" 174 | properties: 175 | code: 176 | type: "string" 177 | description: "Standard ISO 4217 alphabetic three-letter currency code" 178 | example: "USD" 179 | name: 180 | type: "string" 181 | description: "Currency name" 182 | example: "United States dollar" 183 | symbol: 184 | type: "string" 185 | description: "Currency symbol" 186 | example: "$" 187 | CurrencyExchangeRate: 188 | type: "object" 189 | properties: 190 | exchanged_currency: 191 | type: "string" 192 | description: "Standard ISO 4217 alphabetic three-letter currency code" 193 | example: "EUR" 194 | valuation_date: 195 | type: "string" 196 | description: "Exchange rate date in YYYY-MM-DD format" 197 | example: "2021-10-26" 198 | rate_value: 199 | type: "number" 200 | description: "Exchange rate float value" 201 | example: "1.243216" 202 | CurrencyExchangeRateAmount: 203 | type: "object" 204 | properties: 205 | exchanged_currency: 206 | type: "string" 207 | description: "Standard ISO 4217 alphabetic three-letter currency code" 208 | example: "EUR" 209 | exchanged_amount: 210 | type: "number" 211 | description: "Exchanged amount in exchanged currency units" 212 | example: "23.76" 213 | rate_value: 214 | type: "number" 215 | description: "Exchange rate value applied to the convertion" 216 | example: "1.243216" 217 | TimeWeightedRate: 218 | type: "object" 219 | properties: 220 | time_weighted_rate: 221 | type: "number" 222 | description: "Calculated time weighted rate of return compound measure" 223 | example: "1.223178" 224 | -------------------------------------------------------------------------------- /fixtures/provider.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "model": "provider.provider", 3 | "pk": 1, 4 | "fields": { 5 | "name": "Fixer", 6 | "driver": "FixerDriver", 7 | "priority": 1, 8 | "enabled": true 9 | } 10 | }, { 11 | "model": "provider.provider", 12 | "pk": 2, 13 | "fields": { 14 | "name": "XChange API", 15 | "driver": "XChangeAPIDriver", 16 | "priority": 2, 17 | "enabled": true 18 | } 19 | }, { 20 | "model": "provider.provider", 21 | "pk": 3, 22 | "fields": { 23 | "name": "ExchangeRate API", 24 | "driver": "ExchangeRateAPIDriver", 25 | "priority": 3, 26 | "enabled": true 27 | } 28 | }, { 29 | "model": "provider.provider", 30 | "pk": 4, 31 | "fields": { 32 | "name": "Mock", 33 | "driver": "MockDriver", 34 | "priority": 4, 35 | "enabled": false 36 | } 37 | }, { 38 | "model": "provider.providersetting", 39 | "pk": 1, 40 | "fields": { 41 | "provider": 1, 42 | "setting_type": "url", 43 | "key": "api_url", 44 | "value": "http://data.fixer.io/api/", 45 | "description": "Refers to Base URL which all Fixer API request endpoints and URLs are based on." 46 | } 47 | }, { 48 | "model": "provider.providersetting", 49 | "pk": 2, 50 | "fields": { 51 | "provider": 1, 52 | "setting_type": "secret", 53 | "key": "access_key", 54 | "value": "", 55 | "description": "A unique key assigned to each Fixer API account used to authenticate with the API." 56 | } 57 | }, { 58 | "model": "provider.providersetting", 59 | "pk": 3, 60 | "fields": { 61 | "provider": 2, 62 | "setting_type": "url", 63 | "key": "api_url", 64 | "value": "https://api.xchangeapi.com/", 65 | "description": "The URL address that you need to use to connect to the API." 66 | } 67 | }, { 68 | "model": "provider.providersetting", 69 | "pk": 4, 70 | "fields": { 71 | "provider": 2, 72 | "setting_type": "secret", 73 | "key": "api_key", 74 | "value": "", 75 | "description": "The API key to allow access to the API." 76 | } 77 | }, { 78 | "model": "provider.providersetting", 79 | "pk": 5, 80 | "fields": { 81 | "provider": 3, 82 | "setting_type": "url", 83 | "key": "api_url", 84 | "value": "https://v6.exchangerate-api.com/v6/", 85 | "description": "The base URL which API request endpoints are based on." 86 | } 87 | }, { 88 | "model": "provider.providersetting", 89 | "pk": 6, 90 | "fields": { 91 | "provider": 3, 92 | "setting_type": "secret", 93 | "key": "api_key", 94 | "value": "", 95 | "description": "The API key to get access to the API." 96 | } 97 | }] -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | env = os.environ.get('DJANGO_ENV') 10 | settings_module = f'src.infrastructure.settings.{env}' 11 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings_module) 12 | try: 13 | from django.core.management import execute_from_command_line 14 | except ImportError as exc: 15 | raise ImportError( 16 | "Couldn't import Django. Are you sure it's installed and " 17 | "available on your PYTHONPATH environment variable? Did you " 18 | "forget to activate a virtual environment?" 19 | ) from exc 20 | execute_from_command_line(sys.argv) 21 | 22 | 23 | if __name__ == '__main__': 24 | main() 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "forex" 3 | version = "0.1.0" 4 | description = "Forex rates application" 5 | authors = ["sdediego "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.8" 10 | Django = "^3.2.5" 11 | psycopg2 = "^2.9.1" 12 | djangorestframework = "^3.12.4" 13 | marshmallow = "^3.13.0" 14 | django-redis = "^5.0.0" 15 | requests = "^2.26.0" 16 | pandas = "^1.3.2" 17 | celery = "^5.1.2" 18 | 19 | [tool.poetry.dev-dependencies] 20 | autopep8 = "^1.5.7" 21 | pytest-django = "^4.4.0" 22 | factory-boy = "^3.2.0" 23 | 24 | [build-system] 25 | requires = ["poetry-core>=1.0.0"] 26 | build-backend = "poetry.core.masonry.api" 27 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = src.infrastructure.settings.test 3 | python_files = tests.py test_*.py *_tests.py 4 | addopts = -p no:warnings 5 | -------------------------------------------------------------------------------- /scripts/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | # Exit if any of the commands fails. 4 | set -o errexit 5 | # Exit if one of pipe command fails. 6 | set -o pipefail 7 | # Exit if any of the variables is not set. 8 | set -o nounset 9 | 10 | # Execute corresponding command. 11 | exec "$@" 12 | -------------------------------------------------------------------------------- /scripts/start_api.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | # Check env variables 7 | echo "DJANGO_ENV is ${DJANGO_ENV}" 8 | echo "Port ${DJANGO_PORT} exposed for Forex API" 9 | 10 | # Set working directory 11 | cd ${PROJECT_DIR} 12 | echo "Change to working directory $(pwd)" 13 | 14 | # Run database migrations 15 | python manage.py migrate 16 | 17 | if [ ${DJANGO_ENV} = 'development' ]; then 18 | # Create superuser if not exists 19 | export DJANGO_SUPERUSER_USERNAME="admin" 20 | export DJANGO_SUPERUSER_PASSWORD="admin" 21 | export DJANGO_SUPERUSER_EMAIL="admin@forex.com" 22 | python manage.py createsuperuser --noinput || echo "Superuser already exists." 23 | 24 | # Load fixtures if needed 25 | for file in "${PROJECT_DIR}/fixtures/*.json"; do 26 | python manage.py loaddata $file 27 | done 28 | fi 29 | 30 | # Start API server 31 | python manage.py runserver 0.0.0.0:${DJANGO_PORT} 32 | -------------------------------------------------------------------------------- /scripts/start_worker.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | # Check env variables 7 | echo "DJANGO_ENV is ${DJANGO_ENV}" 8 | 9 | # Set working directory 10 | cd ${PROJECT_DIR} 11 | echo "Change to working directory $(pwd)" 12 | 13 | # Run celery worker for asynchronous tasks 14 | celery -A src.infrastructure.server worker -l INFO 15 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/__init__.py -------------------------------------------------------------------------------- /src/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/domain/__init__.py -------------------------------------------------------------------------------- /src/domain/constants.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | # Provider setting types 4 | BOOLEAN_SETTING_TYPE = 'boolean' 5 | FLOAT_SETTING_TYPE = 'float' 6 | INTEGER_SETTING_TYPE = 'integer' 7 | SECRET_SETTING_TYPE = 'secret' 8 | TEXT_SETTING_TYPE = 'text' 9 | URL_SETTING_TYPE = 'url' 10 | -------------------------------------------------------------------------------- /src/domain/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/domain/core/__init__.py -------------------------------------------------------------------------------- /src/domain/core/constants.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | # Allowed http verbs 4 | HTTP_VERB_DELETE = 'delete' 5 | HTTP_VERB_GET = 'get' 6 | HTTP_VERB_PATCH = 'pacth' 7 | HTTP_VERB_POST = 'post' 8 | HTTP_VERB_PUT = 'put' 9 | HTTP_VERBS = { 10 | HTTP_VERB_DELETE, 11 | HTTP_VERB_GET, 12 | HTTP_VERB_PATCH, 13 | HTTP_VERB_POST, 14 | HTTP_VERB_PUT, 15 | } 16 | -------------------------------------------------------------------------------- /src/domain/core/routing.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from collections.abc import Iterable 4 | from dataclasses import dataclass 5 | from typing import Callable, Union 6 | 7 | from src.domain.core.constants import HTTP_VERBS 8 | 9 | 10 | @dataclass 11 | class Route: 12 | http_verb: str 13 | path: str 14 | controller: Callable 15 | method: str 16 | name: str 17 | 18 | def __post_init__(self): 19 | has_method = hasattr(self.controller, self.method) 20 | assert has_method, f'Invalid method {self.method} for {self.controller}' 21 | assert self.http_verb in HTTP_VERBS, f'Invalid http verb {self.http_verb}' 22 | 23 | @property 24 | def url(self) -> str: 25 | return self.path 26 | 27 | @property 28 | def mapping(self) -> dict: 29 | return {self.http_verb: self.method} 30 | 31 | 32 | class Router: 33 | 34 | def __init__(self, name: str = None): 35 | self.name = name 36 | self.registry = dict() 37 | 38 | def register(self, routes: Union[Iterable, Route]): 39 | routes = routes if isinstance(routes, Iterable) else [routes] 40 | for route in routes: 41 | name = f'{self.name}_{route.name}' if self.name else route.name 42 | assert name not in self.registry, f'{name} route already registered' 43 | self.registry.update({name: route}) 44 | 45 | def get_route(self, name: str) -> Route: 46 | return self.registry.get(name) if name in self.registry else None 47 | 48 | def get_url(self, name: str) -> str: 49 | route = self.get_route(name) 50 | return route.url if route else None 51 | 52 | def get_urls(self) -> list: 53 | return [route.url for route in self.registry.values()] 54 | 55 | def map(self, name: str) -> dict: 56 | route = self.get_route(name) 57 | return route.mapping if route else None 58 | -------------------------------------------------------------------------------- /src/domain/exchange_rate.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from dataclasses import dataclass 4 | from datetime import date 5 | from typing import Union 6 | 7 | 8 | @dataclass 9 | class CurrencyEntity: 10 | code: str = None 11 | name: str = None 12 | symbol: str = None 13 | 14 | @staticmethod 15 | def to_string(currency: 'CurrencyEntity') -> str: 16 | symbol = f" ({currency.symbol}):" if currency.symbol else ":" 17 | return f'{currency.code}{symbol} {currency.name}' 18 | 19 | 20 | @dataclass 21 | class CurrencyExchangeRateEntity: 22 | source_currency: Union[CurrencyEntity, str] = None 23 | exchanged_currency: Union[CurrencyEntity, str] = None 24 | valuation_date: str = None 25 | rate_value: float = None 26 | 27 | def __post_init__(self): 28 | if self.valuation_date and isinstance(self.valuation_date, date): 29 | self.valuation_date = self.valuation_date.strftime('%Y-%m-%d') 30 | if self.rate_value: 31 | self.rate_value = round(float(self.rate_value), 6) 32 | 33 | @staticmethod 34 | def to_string(exchange_rate: 'CurrencyExchangeRateEntity') -> str: 35 | source_currency = exchange_rate.source_currency.code if hasattr( 36 | exchange_rate.source_currency, 'code') else exchange_rate.source_currency 37 | exchanged_currency = exchange_rate.exchanged_currency.code if hasattr( 38 | exchange_rate.exchanged_currency, 'code') else exchange_rate.exchanged_currency 39 | return ( 40 | f'{source_currency}/{exchanged_currency} ' 41 | f'= {exchange_rate.rate_value} ({exchange_rate.valuation_date})' 42 | ) 43 | 44 | def calculate_amount(self, amount: float) -> float: 45 | return round(amount * self.rate_value, 2) 46 | 47 | 48 | @dataclass 49 | class CurrencyExchangeAmountEntity: 50 | exchanged_currency: str = None 51 | exchanged_amount: float = None 52 | rate_value: float = None 53 | 54 | def __post_init__(self): 55 | if hasattr(self.exchanged_currency, 'code'): 56 | self.exchanged_currency = self.exchanged_currency.code 57 | if self.exchanged_amount: 58 | self.exchanged_amount = round(float(self.exchanged_amount), 2) 59 | if self.rate_value: 60 | self.rate_value = round(float(self.rate_value), 6) 61 | 62 | @staticmethod 63 | def to_string(exchange_amount: 'CurrencyExchangeAmountEntity') -> str: 64 | return ( 65 | f'{exchange_amount.exchanged_currency}({exchange_amount.rate_value})' 66 | f' = {exchange_amount.exchanged_amount}' 67 | ) 68 | 69 | 70 | @dataclass 71 | class TimeWeightedRateEntity: 72 | time_weighted_rate: float = None 73 | 74 | def __post_init__(self): 75 | if self.time_weighted_rate: 76 | self.time_weighted_rate = round(float(self.time_weighted_rate), 6) 77 | 78 | @staticmethod 79 | def to_string(time_weighted_rate: 'TimeWeightedRateEntity') -> str: 80 | return f'twr = {time_weighted_rate.time_weighted_rate}' 81 | -------------------------------------------------------------------------------- /src/domain/provider.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import base64 4 | from dataclasses import dataclass, field 5 | from typing import Any 6 | 7 | from src.domain.constants import ( 8 | BOOLEAN_SETTING_TYPE, FLOAT_SETTING_TYPE, INTEGER_SETTING_TYPE, 9 | SECRET_SETTING_TYPE, TEXT_SETTING_TYPE, URL_SETTING_TYPE) 10 | 11 | 12 | @dataclass 13 | class ProviderEntity: 14 | name: str = None 15 | driver: str = None 16 | priority: int = None 17 | enabled: bool = None 18 | settings: dict = field(default_factory=dict) 19 | 20 | @staticmethod 21 | def to_string(provider: 'ProviderEntity') -> str: 22 | return f'{provider.name} ({provider.driver}): Priority {provider.priority}' 23 | 24 | 25 | @dataclass 26 | class ProviderSettingEntity: 27 | provider: ProviderEntity = None 28 | setting_type: str = None 29 | key: str = None 30 | value: str = None 31 | description: str = None 32 | 33 | def __post_init__(self): 34 | if self.setting_type == BOOLEAN_SETTING_TYPE: 35 | self.value = self.value == 'True' 36 | elif self.setting_type == INTEGER_SETTING_TYPE: 37 | self.value = int(self.value) 38 | elif self.setting_type == FLOAT_SETTING_TYPE: 39 | self.value = float(self.value) 40 | elif self.setting_type == SECRET_SETTING_TYPE: 41 | self.value = self.decode_secret() 42 | elif self.setting_type in (TEXT_SETTING_TYPE, URL_SETTING_TYPE): 43 | self.value = str(self.value) 44 | 45 | @staticmethod 46 | def to_string(setting: 'ProviderSettingEntity') -> str: 47 | value = setting.value 48 | if setting.setting_type == SECRET_SETTING_TYPE: 49 | value = '*' * 10 50 | return f'{setting.provider.name} - {setting.key}: {value}' 51 | 52 | def decode_secret(self) -> str: 53 | return base64.decodebytes(self.value.encode()).decode() 54 | 55 | @staticmethod 56 | def encode_secret(value: Any) -> str: 57 | value = str(value) 58 | return base64.encodebytes(value.encode()).decode() 59 | -------------------------------------------------------------------------------- /src/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/adminsite/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/adminsite/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/adminsite/exchange_rate/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/adminsite/exchange_rate/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/adminsite/exchange_rate/admin.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from django.contrib import admin 4 | 5 | from src.infrastructure.orm.db.exchange_rate.models import ( 6 | Currency, CurrencyExchangeRate) 7 | 8 | 9 | class CurrencyAdmin(admin.ModelAdmin): 10 | model = Currency 11 | list_display = ('code', 'name', 'symbol') 12 | ordering = ('name',) 13 | 14 | 15 | class CurrencyExchangeRateAdmin(admin.ModelAdmin): 16 | model = CurrencyExchangeRate 17 | list_display = ( 18 | 'source_currency', 'exchanged_currency', 'rate_value', 'valuation_date') 19 | ordering = ('-valuation_date', 'source_currency') 20 | -------------------------------------------------------------------------------- /src/infrastructure/adminsite/provider/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/adminsite/provider/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/adminsite/provider/admin.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from django.contrib import admin 4 | 5 | from src.infrastructure.adminsite.provider.forms import ( 6 | ProviderForm, ProviderSettingForm) 7 | from src.infrastructure.orm.db.provider.models import ( 8 | Provider, ProviderSetting) 9 | 10 | 11 | class ProviderSettingInline(admin.TabularInline): 12 | model = ProviderSetting 13 | form = ProviderSettingForm 14 | extra = 0 15 | 16 | 17 | class ProviderAdmin(admin.ModelAdmin): 18 | model = Provider 19 | form = ProviderForm 20 | inlines = [ProviderSettingInline] 21 | list_display = ('name', 'driver', 'priority', 'enabled') 22 | ordering = ('priority',) 23 | -------------------------------------------------------------------------------- /src/infrastructure/adminsite/provider/forms.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from django import forms 4 | from django.core.validators import URLValidator 5 | from django.forms import ModelForm, ValidationError 6 | 7 | from src.domain.constants import SECRET_SETTING_TYPE 8 | from src.domain.provider import ProviderSettingEntity 9 | from src.infrastructure.clients.provider.utils import get_drivers_choices 10 | from src.infrastructure.orm.db.provider.models import Provider, ProviderSetting 11 | 12 | 13 | class ProviderForm(ModelForm): 14 | driver = forms.ChoiceField(choices=get_drivers_choices(), required=True) 15 | 16 | class Meta: 17 | model = Provider 18 | fields = ('name', 'driver', 'priority', 'enabled') 19 | 20 | 21 | class ProviderSettingForm(ModelForm): 22 | 23 | class Meta: 24 | model = ProviderSetting 25 | fields = ('provider', 'setting_type', 'key', 'value', 'description') 26 | 27 | def __init__(self, *args, **kwargs): 28 | super().__init__(*args, **kwargs) 29 | if self.initial.get('setting_type') == SECRET_SETTING_TYPE: 30 | self.initial['value'] = '*' * 10 31 | 32 | def clean(self): 33 | super().clean() 34 | method_name = f'_clean_{self.cleaned_data.get("setting_type")}' 35 | method = getattr(self, method_name) 36 | method(self.cleaned_data.get('value')) 37 | 38 | def _clean_secret(self, value: str): 39 | try: 40 | str(value) 41 | except (TypeError, ValueError): 42 | raise ValidationError(f'{value} value must be string') 43 | 44 | def _clean_text(self, value: str): 45 | try: 46 | str(value) 47 | except (TypeError, ValueError): 48 | raise ValidationError(f'{value} value must be string') 49 | 50 | def _clean_integer(self, value: int): 51 | try: 52 | int(value) 53 | except (TypeError, ValueError): 54 | raise ValidationError(f'{value} value must be integer') 55 | 56 | def _clean_float(self, value: float): 57 | try: 58 | float(value) 59 | except (TypeError, ValueError): 60 | raise ValidationError(f'{value} value must be float') 61 | 62 | def _clean_boolean(self, value: bool): 63 | if value not in ('True', 'False'): 64 | raise ValidationError( 65 | f'{value} value must be either True or False') 66 | 67 | def _clean_url(self, value: str): 68 | url_validator = URLValidator() 69 | try: 70 | url_validator(value) 71 | except ValidationError: 72 | raise ValidationError(f'{value} must be a valid absolute url') 73 | 74 | def save(self, commit: bool = True): 75 | if self.cleaned_data.get('setting_type') == SECRET_SETTING_TYPE \ 76 | and 'value' in self.changed_data: 77 | self.instance.value = ProviderSettingEntity.encode_secret( 78 | self.cleaned_data.get('value')) 79 | return super().save(commit) 80 | -------------------------------------------------------------------------------- /src/infrastructure/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/api/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/api/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/api/routes/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/api/routes/exchange_rate/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/api/routes/exchange_rate/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/api/routes/exchange_rate/routers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from rest_framework.routers import SimpleRouter, Route 4 | 5 | from src.infrastructure.factories.exchange_rates import ( 6 | CurrencyViewSetFactory, CurrencyExchangeRateViewSetFactory) 7 | from src.interface.routes.exchange_rate import ( 8 | currency_router, exchange_rate_router) 9 | 10 | 11 | class CurrencyRouter(SimpleRouter): 12 | routes = [ 13 | Route( 14 | url=currency_router.get_url('currencies_list'), 15 | mapping=currency_router.map('currencies_list'), 16 | initkwargs={'viewset_factory': CurrencyViewSetFactory}, 17 | name='{basename}-list', 18 | detail=False, 19 | ), 20 | Route( 21 | url=currency_router.get_url('currencies_get'), 22 | mapping=currency_router.map('currencies_get'), 23 | initkwargs={'viewset_factory': CurrencyViewSetFactory}, 24 | name='{basename}-get', 25 | detail=False, 26 | ), 27 | ] 28 | 29 | 30 | class CurrencyExchangeRateRouter(SimpleRouter): 31 | routes = [ 32 | Route( 33 | url=exchange_rate_router.get_url('exchange_rate_list'), 34 | mapping=exchange_rate_router.map('exchange_rate_list'), 35 | initkwargs={'viewset_factory': CurrencyExchangeRateViewSetFactory}, 36 | name='{basename}-list', 37 | detail=False, 38 | ), 39 | Route( 40 | url=exchange_rate_router.get_url('exchange_rate_convert'), 41 | mapping=exchange_rate_router.map('exchange_rate_convert'), 42 | initkwargs={'viewset_factory': CurrencyExchangeRateViewSetFactory}, 43 | name='{basename}-convert', 44 | detail=False, 45 | ), 46 | Route( 47 | url=exchange_rate_router.get_url('exchange_rate_calculate_twr'), 48 | mapping=exchange_rate_router.map('exchange_rate_calculate_twr'), 49 | initkwargs={'viewset_factory': CurrencyExchangeRateViewSetFactory}, 50 | name='{basename}-calculate-twr', 51 | detail=False, 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /src/infrastructure/api/routes/exchange_rate/urls.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from django.conf.urls import include 4 | from django.urls import path 5 | 6 | from src.infrastructure.api.routes.exchange_rate.routers import ( 7 | CurrencyRouter, CurrencyExchangeRateRouter) 8 | from src.infrastructure.api.views.exchange_rate import ( 9 | CurrencyViewSet, CurrencyExchangeRateViewSet) 10 | 11 | 12 | currency_router = CurrencyRouter() 13 | currency_router.register('', viewset=CurrencyViewSet, basename='currencies') 14 | 15 | 16 | exchange_rate_router = CurrencyExchangeRateRouter() 17 | exchange_rate_router.register( 18 | '', viewset=CurrencyExchangeRateViewSet, basename='exchange-rate') 19 | 20 | 21 | urlpatterns = [ 22 | path('', include(exchange_rate_router.urls)), 23 | path('', include(currency_router.urls)) 24 | ] 25 | -------------------------------------------------------------------------------- /src/infrastructure/api/routes/urls.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from django.conf import settings 4 | from django.conf.urls import include 5 | from django.urls import path 6 | 7 | 8 | urlpatterns = [ 9 | path('', include(f'{settings.API_ROUTES}.exchange_rate.urls')) 10 | ] 11 | -------------------------------------------------------------------------------- /src/infrastructure/api/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/api/views/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/api/views/exchange_rate.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from rest_framework.request import Request 4 | from rest_framework.response import Response 5 | from rest_framework.viewsets import ViewSet 6 | 7 | from src.interface.controllers.exchange_rate import ( 8 | CurrencyController, CurrencyExchangeRateController) 9 | 10 | 11 | class CurrencyViewSet(ViewSet): 12 | viewset_factory = None 13 | 14 | @property 15 | def controller(self) -> CurrencyController: 16 | return self.viewset_factory.create() 17 | 18 | def get(self, request: Request, code: str, *args, **kwargs) -> Response: 19 | payload, status = self.controller.get(code) 20 | return Response(data=payload, status=status) 21 | 22 | def list(self, request: Request, *args, **kwargs) -> Response: 23 | payload, status = self.controller.list() 24 | return Response(data=payload, status=status) 25 | 26 | 27 | class CurrencyExchangeRateViewSet(ViewSet): 28 | viewset_factory = None 29 | 30 | @property 31 | def controller(self) -> CurrencyExchangeRateController: 32 | return self.viewset_factory.create() 33 | 34 | def convert(self, request: Request, *args, **kwargs) -> Response: 35 | query_params = request.query_params 36 | payload, status = self.controller.convert(query_params) 37 | return Response(data=payload, status=status) 38 | 39 | def list(self, request: Request, *args, **kwargs) -> Response: 40 | query_params = request.query_params 41 | payload, status = self.controller.list(query_params) 42 | return Response(data=payload, status=status) 43 | 44 | def calculate_twr(self, request: Request, *args, **kwargs) -> Response: 45 | query_params = request.query_params 46 | payload, status = self.controller.calculate_twr(query_params) 47 | return Response(data=payload, status=status) 48 | -------------------------------------------------------------------------------- /src/infrastructure/clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/clients/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from src.infrastructure.clients.provider.exchange_rate_api.drivers import ExchangeRateAPIDriver 4 | from src.infrastructure.clients.provider.fixer.drivers import FixerDriver 5 | from src.infrastructure.clients.provider.mock.drivers import MockDriver 6 | from src.infrastructure.clients.provider.xchange_api.drivers import XChangeAPIDriver 7 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/base.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from typing import Any, List 4 | 5 | import requests 6 | from requests import Response 7 | from requests.exceptions import RequestException 8 | from rest_framework import status 9 | 10 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 11 | from src.domain.provider import ProviderEntity 12 | 13 | 14 | class ProviderBaseDriver: 15 | HEADERS = {'Accept': 'application/json'} 16 | ENDPOINTS = {} 17 | 18 | def __init__(self, provider: ProviderEntity): 19 | self.name = provider.name 20 | self.priority = provider.priority 21 | 22 | def _get_endpoint_info(self, endpoint: str) -> dict: 23 | return self.ENDPOINTS.get(endpoint, {}) 24 | 25 | def _get_headers(self) -> dict: 26 | return self.HEADERS 27 | 28 | def _get_url(self, endpoint: str, url_params: dict) -> str: 29 | path = self._get_endpoint_info(endpoint).get('path') 30 | url = f'{self.api_url}{path}' 31 | return url.format(**url_params) if url_params else url 32 | 33 | def _get_data_key(self, endpoint_info: dict) -> str: 34 | return 'json' if endpoint_info.get('json', False) else 'data' 35 | 36 | def _build_request(self, endpoint: str, data: dict, params: dict, 37 | url_params: dict) -> dict: 38 | endpoint_info = self._get_endpoint_info(endpoint) 39 | request = { 40 | 'method': endpoint_info.get('method'), 41 | 'url': self._get_url(endpoint, url_params), 42 | 'headers': self._get_headers(), 43 | } 44 | if params: 45 | request.update({'params': params}) 46 | if data: 47 | data_key = self._get_data_key(endpoint_info) 48 | request[data_key] = data 49 | return request 50 | 51 | def _has_response_error(self, response: Response) -> bool: 52 | raise NotImplementedError 53 | 54 | def _handle_response_error(self, error: RequestException): 55 | raise NotImplementedError 56 | 57 | def _process_response_error(self, data: dict, status_code: int): 58 | raise NotImplementedError 59 | 60 | def _process_response(self, response: Response) -> dict: 61 | data = None 62 | status_code = response.status_code 63 | try: 64 | data = response.json() 65 | except ValueError as err: 66 | raise err 67 | if not status.is_success(status_code) or self._has_response_error(response): 68 | self._process_response_error(data, status_code) 69 | return data 70 | 71 | def _request(self, endpoint: str, data: dict = None, params: dict = None, 72 | url_params: dict = None) -> dict: 73 | request = self._build_request(endpoint, data, params, url_params) 74 | try: 75 | response = requests.request(**request) 76 | response.raise_for_status() 77 | except RequestException as err: 78 | if not self._has_response_error(err.response): 79 | self._handle_response_error(err) 80 | except Exception as err: 81 | raise err 82 | return self._process_response(response) 83 | 84 | def _get_serializer_class(self, endpoint: str) -> Any: 85 | endpoint_info = self._get_endpoint_info(endpoint) 86 | return endpoint_info.get('serializer_class') 87 | 88 | def _deserialize_response(self, endpoint: str, response: dict) -> Any: 89 | serializer_class = self._get_serializer_class(endpoint) 90 | return serializer_class().load(response) 91 | 92 | def get_currencies(self) -> List[CurrencyEntity]: 93 | raise NotImplementedError 94 | 95 | def get_exchange_rate(self, source_currency: str, exchanged_currency: str, 96 | date: str) -> CurrencyExchangeRateEntity: 97 | raise NotImplementedError 98 | 99 | def get_time_series(self, source_currency: str, exchanged_currency: str, 100 | date_from: str, date_to: str) -> List[CurrencyExchangeRateEntity]: 101 | raise NotImplementedError 102 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/decorators.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import asyncio 4 | from functools import wraps 5 | 6 | 7 | def async_event_loop(method): 8 | @wraps(method) 9 | def result(self, *args, **kwargs): 10 | response = asyncio.run(method(self, *args, **kwargs)) 11 | return response 12 | return result 13 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/drivers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from http import HTTPStatus 4 | from typing import Any, List 5 | 6 | from src.domain.provider import ProviderEntity 7 | from src.infrastructure.clients.provider.base import ProviderBaseDriver 8 | from src.infrastructure.clients.provider.utils import get_available_drivers 9 | from src.usecases.provider import ProviderInteractor 10 | 11 | 12 | class ProviderMasterDriver: 13 | ACTIONS = { 14 | 'currency_get': 'get_currencies', 15 | 'currency_list': 'get_currencies', 16 | 'exchange_rate_calculate_twr': 'get_time_series', 17 | 'exchange_rate_convert': 'get_exchange_rate', 18 | 'exchange_rate_list': 'get_time_series', 19 | } 20 | 21 | def __init__(self, provider_interactor: ProviderInteractor): 22 | self.provider_interactor = provider_interactor 23 | self.drivers = get_available_drivers() 24 | 25 | @property 26 | def providers(self) -> List[ProviderEntity]: 27 | return self.provider_interactor.get_by_priority() 28 | 29 | def _get_driver_class(self, provider: ProviderEntity) -> ProviderBaseDriver: 30 | driver_name = provider.driver 31 | return self.drivers.get(driver_name) 32 | 33 | def _get_driver_by_priority(self) -> ProviderBaseDriver: 34 | for provider in self.providers: 35 | driver_class = self._get_driver_class(provider) 36 | yield driver_class(provider) 37 | 38 | def fetch_data(self, action: str, **kwargs: dict) -> Any: 39 | error = 'Unable to fetch data from remote server' 40 | for driver in self._get_driver_by_priority(): 41 | method = getattr(driver, self.ACTIONS.get(action, None)) 42 | try: 43 | response = method(**kwargs) 44 | except Exception as err: 45 | error = err 46 | else: 47 | if response: 48 | break 49 | else: 50 | response = { 51 | 'error': error.message if hasattr( 52 | error, 'message') else str(error), 53 | 'status_code': error.code if hasattr( 54 | error, 'code') else HTTPStatus.INTERNAL_SERVER_ERROR.value 55 | } 56 | return response 57 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/exceptions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | class ProviderDriverError(Exception): 4 | 5 | def __init__(self, message: str, code: int, *args, **kwargs): 6 | super().__init__(*args, **kwargs) 7 | self.message = message 8 | self.code = code 9 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/exchange_rate_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/clients/provider/exchange_rate_api/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/exchange_rate_api/drivers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import asyncio 4 | from itertools import chain, repeat 5 | from typing import List 6 | 7 | from requests import Response 8 | from requests.exceptions import RequestException 9 | 10 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 11 | from src.domain.provider import ProviderEntity 12 | from src.infrastructure.clients.provider.base import ProviderBaseDriver 13 | from src.infrastructure.clients.provider.decorators import async_event_loop 14 | from src.infrastructure.clients.provider.utils import ( 15 | get_business_days, get_last_business_day) 16 | from src.infrastructure.clients.provider.exchange_rate_api.exceptions import ( 17 | ExchangeRateAPIDriverError) 18 | from src.infrastructure.clients.provider.exchange_rate_api.serializers import ( 19 | CurrencySerializer, ExchangeRateSerializer) 20 | 21 | 22 | class ExchangeRateAPIDriver(ProviderBaseDriver): 23 | CURRENCIES = 'currencies' 24 | HISTORICAL_RATE = 'historical' 25 | ENDPOINTS = { 26 | CURRENCIES: { 27 | 'method': 'get', 28 | 'path': 'codes', 29 | 'serializer_class': CurrencySerializer, 30 | }, 31 | HISTORICAL_RATE: { 32 | 'method': 'get', 33 | 'path': 'history/{currency}/{year}/{month}/{day}', 34 | 'serializer_class': ExchangeRateSerializer, 35 | } 36 | } 37 | 38 | def __init__(self, provider: ProviderEntity): 39 | super().__init__(provider) 40 | self.api_url = provider.settings.get('api_url').value 41 | self.api_key = provider.settings.get('api_key').value 42 | 43 | def _get_headers(self) -> dict: 44 | headers = super()._get_headers() 45 | headers.update({'Authorization': f'Bearer {self.api_key}'}) 46 | return headers 47 | 48 | def _has_response_error(self, response: Response) -> bool: 49 | try: 50 | data = response.json() 51 | except ValueError: 52 | return False 53 | return data.get('error-type') is not None 54 | 55 | def _handle_response_error(self, error: RequestException): 56 | has_response = error.response is not None 57 | message = error.response.reason if has_response else str(error) 58 | status_code = error.response.status_code if has_response else None 59 | raise ExchangeRateAPIDriverError(message=message, code=status_code) 60 | 61 | def _process_response_error(self, data: dict, status_code: int): 62 | message = data.get('error-type', '') 63 | raise ExchangeRateAPIDriverError(message=message, code=status_code) 64 | 65 | def get_currencies(self) -> List[CurrencyEntity]: 66 | response = self._request(self.CURRENCIES) 67 | currencies = self._deserialize_response(self.CURRENCIES, response) 68 | return currencies 69 | 70 | def get_exchange_rate(self, source_currency: str, exchanged_currency: str, 71 | date: str = None) -> CurrencyExchangeRateEntity: 72 | date = date or get_last_business_day() 73 | year, month, day = date.split('-') 74 | url_params = { 75 | 'currency': source_currency, 76 | 'year': year, 77 | 'month': month, 78 | 'day': day, 79 | } 80 | response = self._request(self.HISTORICAL_RATE, url_params=url_params) 81 | response.update({'symbols': exchanged_currency}) 82 | exchange_rate = self._deserialize_response(self.HISTORICAL_RATE, response) 83 | return exchange_rate[0] if len(exchange_rate) > 0 else None 84 | 85 | @async_event_loop 86 | async def get_time_series(self, source_currency: str, exchanged_currency: str, 87 | date_from: str, date_to: str) -> List[CurrencyExchangeRateEntity]: 88 | async def request(endpoint: str, params: dict, url_params: dict) -> dict: 89 | response = self._request(endpoint, url_params=url_params) 90 | response.update(params) 91 | return response 92 | 93 | url_params = [] 94 | for business_day in get_business_days(date_from, date_to): 95 | year, month, day = business_day.split('-') 96 | url_params.append({ 97 | 'currency': source_currency, 98 | 'year': year, 99 | 'month': month, 100 | 'day': day, 101 | }) 102 | params = {'symbols': exchanged_currency} 103 | responses = await asyncio.gather(*list( 104 | map(request, repeat(self.HISTORICAL_RATE), repeat(params), url_params))) 105 | timeseries = list(chain(*map( 106 | self._deserialize_response, repeat(self.HISTORICAL_RATE), responses))) 107 | return timeseries 108 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/exchange_rate_api/exceptions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from src.infrastructure.clients.provider.exceptions import ProviderDriverError 4 | 5 | 6 | class ExchangeRateAPIDriverError(ProviderDriverError): 7 | pass 8 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/exchange_rate_api/serializers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from typing import List 4 | 5 | from marshmallow import Schema, fields, EXCLUDE 6 | from marshmallow.decorators import post_load 7 | 8 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 9 | 10 | 11 | class CurrencySerializer(Schema): 12 | 13 | class Meta: 14 | unknown = EXCLUDE 15 | 16 | currencies = fields.List(fields.List(fields.String()), 17 | data_key='supported_codes', 18 | required=True) 19 | 20 | @post_load 21 | def make_currencies(self, data: dict, **kwargs) -> List[CurrencyEntity]: 22 | currencies = data.get('currencies') 23 | return [ 24 | CurrencyEntity(code=code, name=name) for code, name in currencies 25 | ] 26 | 27 | 28 | class ExchangeRateSerializer(Schema): 29 | 30 | class Meta: 31 | unknown = EXCLUDE 32 | 33 | source_currency = fields.String(data_key='base_code', required=True) 34 | year = fields.Integer(strict=True, required=True) 35 | month = fields.Integer(strict=True, required=True) 36 | day = fields.Integer(strict=True, required=True) 37 | rates = fields.Dict(data_key='conversion_rates', 38 | keys=fields.String(), 39 | values=fields.Float(), 40 | required=True) 41 | 42 | @post_load(pass_original=True) 43 | def make_exchange_rates(self, data: dict, original_data: dict, 44 | **kwargs) -> List[CurrencyExchangeRateEntity]: 45 | exchanged_currencies = original_data.get('symbols').split(',') 46 | data['valuation_date'] = ( 47 | f'{data.pop("year")}-{data.pop("month")}-{data.pop("day")}') 48 | return [ 49 | CurrencyExchangeRateEntity( 50 | source_currency=data.get('source_currency'), 51 | exchanged_currency=exchanged_currency, 52 | valuation_date=data.get('valuation_date'), 53 | rate_value=round( 54 | float(data.get('rates').get(exchanged_currency)), 6) 55 | ) 56 | for exchanged_currency in exchanged_currencies 57 | if data.get('rates').get(exchanged_currency) is not None 58 | ] 59 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/fixer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/clients/provider/fixer/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/fixer/drivers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import datetime 4 | from typing import List 5 | 6 | from requests import Response 7 | from requests.exceptions import RequestException 8 | 9 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 10 | from src.domain.provider import ProviderEntity 11 | from src.infrastructure.clients.provider.base import ProviderBaseDriver 12 | from src.infrastructure.clients.provider.fixer.exceptions import FixerDriverError 13 | from src.infrastructure.clients.provider.fixer.serializers import ( 14 | CurrencySerializer, ExchangeRateSerializer, TimeSeriesSerializer) 15 | 16 | 17 | class FixerDriver(ProviderBaseDriver): 18 | CURRENCIES = 'currencies' 19 | HISTORICAL_RATE = 'historical' 20 | TIME_SERIES = 'timeseries' 21 | ENDPOINTS = { 22 | CURRENCIES: { 23 | 'method': 'get', 24 | 'path': 'symbols', 25 | 'serializer_class': CurrencySerializer, 26 | }, 27 | HISTORICAL_RATE: { 28 | 'method': 'get', 29 | 'path': '{date}', 30 | 'serializer_class': ExchangeRateSerializer, 31 | }, 32 | TIME_SERIES: { 33 | 'method': 'get', 34 | 'path': 'timeseries', 35 | 'serializer_class': TimeSeriesSerializer, 36 | } 37 | } 38 | 39 | def __init__(self, provider: ProviderEntity): 40 | super().__init__(provider) 41 | self.api_url = provider.settings.get('api_url').value 42 | self.access_key = provider.settings.get('access_key').value 43 | 44 | def _add_access_key(self, params: dict) -> dict: 45 | params = params or {} 46 | params.update({'access_key': self.access_key}) 47 | return params 48 | 49 | def _build_request(self, endpoint: str, data: dict, params: dict, 50 | url_params: dict) -> dict: 51 | params = self._add_access_key(params) 52 | return super()._build_request(endpoint, data, params, url_params) 53 | 54 | def _has_response_error(self, response: Response) -> bool: 55 | try: 56 | data = response.json() 57 | except ValueError: 58 | return False 59 | return data.get('error') is not None 60 | 61 | def _handle_response_error(self, error: RequestException): 62 | has_response = error.response is not None 63 | message = error.response.reason if has_response else str(error) 64 | status_code = error.response.status_code if has_response else None 65 | raise FixerDriverError(message=message, code=status_code) 66 | 67 | def _process_response_error(self, data: dict, status_code: int): 68 | message = data.get('error', {}).get('info') if data else '' 69 | raise FixerDriverError(message=message, code=status_code) 70 | 71 | def get_currencies(self) -> List[CurrencyEntity]: 72 | response = self._request(self.CURRENCIES) 73 | currencies = self._deserialize_response(self.CURRENCIES, response) 74 | return currencies 75 | 76 | def get_exchange_rate(self, source_currency: str, exchanged_currency: str, 77 | date: str = None) -> CurrencyExchangeRateEntity: 78 | url_params = {'date': date or datetime.date.today().strftime('%Y-%m-%d')} 79 | params = {'base': source_currency, 'symbols': exchanged_currency} 80 | response = self._request(self.HISTORICAL_RATE, params=params, url_params=url_params) 81 | exchange_rate = self._deserialize_response(self.HISTORICAL_RATE, response) 82 | return exchange_rate 83 | 84 | def get_time_series(self, source_currency: str, exchanged_currency: str, 85 | date_from: str, date_to: str) -> List[CurrencyExchangeRateEntity]: 86 | params = { 87 | 'base': source_currency, 88 | 'symbols': exchanged_currency, 89 | 'start_date': date_from, 90 | 'end_date': date_to, 91 | } 92 | response = self._request(self.TIME_SERIES, params=params) 93 | timeseries = self._deserialize_response(self.TIME_SERIES, response) 94 | return timeseries 95 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/fixer/exceptions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from src.infrastructure.clients.provider.exceptions import ProviderDriverError 4 | 5 | 6 | class FixerDriverError(ProviderDriverError): 7 | pass 8 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/fixer/serializers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from typing import List 4 | 5 | from marshmallow import Schema, fields, EXCLUDE 6 | from marshmallow.decorators import post_load, pre_load 7 | 8 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 9 | 10 | 11 | class CurrencySerializer(Schema): 12 | 13 | class Meta: 14 | unknown = EXCLUDE 15 | 16 | currencies = fields.Dict( 17 | data_key='symbols', keys=fields.String(), values=fields.String(), required=True) 18 | 19 | @post_load 20 | def make_currencies(self, data: dict, **kwargs) -> List[CurrencyEntity]: 21 | return [ 22 | CurrencyEntity(code=code, name=name) 23 | for code, name in data.get('currencies').items() 24 | ] 25 | 26 | 27 | class ExchangeRateSerializer(Schema): 28 | 29 | class Meta: 30 | unknown = EXCLUDE 31 | 32 | source_currency = fields.String(data_key='base', required=True) 33 | exchanged_currency = fields.String(required=True) 34 | valuation_date = fields.Date(data_key='date', required=True) 35 | rate_value = fields.Float(required=True) 36 | 37 | @pre_load 38 | def process_rates(self, in_data: dict, **kwargs) -> dict: 39 | rates = in_data.pop('rates') 40 | exchanged_currency, rate_value = tuple(rates.items())[0] 41 | in_data['exchanged_currency'] = exchanged_currency 42 | in_data['rate_value'] = round(float(rate_value), 6) 43 | return in_data 44 | 45 | @post_load 46 | def make_exchange_rate(self, data: dict, **kwargs) -> CurrencyExchangeRateEntity: 47 | return CurrencyExchangeRateEntity(**data) 48 | 49 | 50 | class TimeSeriesSerializer(Schema): 51 | 52 | class Meta: 53 | unknown = EXCLUDE 54 | 55 | source_currency = fields.String(data_key='base', required=True) 56 | rates = fields.Dict( 57 | keys=fields.String(), 58 | values=fields.Dict(keys=fields.String(), values=fields.Float()), 59 | required=True) 60 | 61 | @post_load 62 | def make_exchange_rates(self, data: dict, **kwargs) -> List[CurrencyExchangeRateEntity]: 63 | source_currency = data.get('source_currency') 64 | rates = data.get('rates') 65 | return [ 66 | CurrencyExchangeRateEntity( 67 | source_currency=source_currency, 68 | exchanged_currency=exchanged_currency, 69 | valuation_date=date, 70 | rate_value=round(float(rate_value), 6) 71 | ) 72 | for date, exchange_rates in rates.items() 73 | for exchanged_currency, rate_value in exchange_rates.items() 74 | ] 75 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/mock/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/clients/provider/mock/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/mock/drivers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import time 4 | from typing import List 5 | 6 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 7 | from src.infrastructure.clients.provider.mock import requests 8 | from src.infrastructure.clients.provider.utils import get_last_business_day 9 | 10 | 11 | class MockDriver: 12 | SLEEP_TIME = 2 13 | CURRENCIES = 'currencies' 14 | HISTORICAL_RATE = 'historical' 15 | TIME_SERIES = 'timeseries' 16 | ACTION_MAP = { 17 | CURRENCIES: requests.currencies, 18 | HISTORICAL_RATE: requests.historical_rate, 19 | TIME_SERIES: requests.timeseries_rates, 20 | } 21 | 22 | def _request(self, endpoint: str, data: dict = None) -> dict: 23 | time.sleep(self.SLEEP_TIME) 24 | request = self.ACTION_MAP.get(endpoint) 25 | return request(data) 26 | 27 | def get_currencies(self) -> List[CurrencyEntity]: 28 | return self._request(self.CURRENCIES) 29 | 30 | def get_exchange_rate(self, source_currency: str, exchanged_currency: str, 31 | date: str = None) -> CurrencyExchangeRateEntity: 32 | data = { 33 | 'source_currency': source_currency, 34 | 'exchanged_currency': exchanged_currency, 35 | 'valuation_date': date or get_last_business_day(), 36 | } 37 | return self._request(self.HISTORICAL_RATE, data) 38 | 39 | def get_time_series(self, source_currency: str, exchanged_currency: str, 40 | date_from: str, date_to: str) -> List[CurrencyExchangeRateEntity]: 41 | data = { 42 | 'source_currency': source_currency, 43 | 'exchanged_currency': exchanged_currency, 44 | 'date_from': date_from, 45 | 'date_to': date_to, 46 | } 47 | return self._request(self.TIME_SERIES, data) 48 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/mock/requests.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import json 4 | import random 5 | from typing import List 6 | 7 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 8 | from src.infrastructure.clients.provider.utils import ( 9 | get_business_days, get_last_business_day) 10 | 11 | 12 | def currencies() -> List[CurrencyEntity]: 13 | with open('../xchange_api/currencies.json', 'r') as currencies_file: 14 | data = json.load(currencies_file) 15 | return [CurrencyEntity(**currency) for currency in data['availableCurrencies']] 16 | 17 | 18 | def historical_rate(data: dict) -> CurrencyExchangeRateEntity: 19 | return CurrencyExchangeRateEntity( 20 | source_currency=data.get('source_currency'), 21 | exchanged_currency=data.get('exchanged_currency'), 22 | valuation_date=get_last_business_day(data.get('valuation_date')), 23 | rate_value=round(random.uniform(0.5, 1.5), 6) 24 | ) 25 | 26 | 27 | def timeseries_rates(data: dict) -> List[CurrencyExchangeRateEntity]: 28 | source_currency = data.get('source_currency') 29 | exchanged_currencies = data.get('exchanged_currency').split(',') 30 | business_days = get_business_days(data.get('date_from'), data.get('date_to')) 31 | return [ 32 | CurrencyExchangeRateEntity( 33 | source_currency=source_currency, 34 | exchanged_currency=currency, 35 | valuation_date=business_day, 36 | rate_value=round(random.uniform(0.5, 1.5), 6) 37 | ) 38 | for business_day in business_days for currency in exchanged_currencies 39 | ] 40 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/utils.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import datetime 4 | from datetime import datetime as dt 5 | from importlib import import_module 6 | 7 | import numpy as np 8 | import pandas as pd 9 | 10 | 11 | def get_available_drivers() -> dict: 12 | try: 13 | module = import_module('src.infrastructure.clients.provider') 14 | except ImportError: 15 | raise ImportError('Unable to import providers module') 16 | drivers = { 17 | name: item for name, item in module.__dict__.items() \ 18 | if name.endswith('Driver') and callable(item) 19 | } 20 | return drivers 21 | 22 | 23 | def get_drivers_names() -> tuple: 24 | return tuple(get_available_drivers().keys()) 25 | 26 | 27 | def get_drivers_choices() -> tuple: 28 | return tuple(zip(*(get_drivers_names(),) * 2)) 29 | 30 | 31 | def get_business_days(date_from: str, date_to: str) -> tuple: 32 | bdays = pd.bdate_range(start=date_from, end=date_to).values 33 | return tuple([np.datetime_as_string(bday, unit='D') for bday in bdays]) 34 | 35 | 36 | def get_last_business_day(date: str = None) -> str: 37 | date = date or datetime.date.today().strftime('%Y-%m-%d') 38 | is_business_day = bool(len(pd.bdate_range(start=date, end=date))) 39 | if not is_business_day: 40 | offset = pd.tseries.offsets.BusinessDay(n=1) 41 | date = (dt.strptime(date, '%Y-%m-%d') - offset).strftime('%Y-%m-%d') 42 | return date 43 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/xchange_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/clients/provider/xchange_api/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/xchange_api/currencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "availableCurrencies": [ 3 | { 4 | "code": "AUD", 5 | "name": "Austalian Dollar" 6 | }, 7 | { 8 | "code": "BGN", 9 | "name": "Bulgarian Lev" 10 | }, 11 | { 12 | "code": "BRL", 13 | "name": "Brazilian Real" 14 | }, 15 | { 16 | "code": "CAD", 17 | "name": "Canadian Dollar" 18 | }, 19 | { 20 | "code": "CHF", 21 | "name": "Swiss Franc" 22 | }, 23 | { 24 | "code": "CNY", 25 | "name": "Chinese Yuan" 26 | }, 27 | { 28 | "code": "CZK", 29 | "name": "Czech Koruna" 30 | }, 31 | { 32 | "code": "DKK", 33 | "name": "Danish Krone" 34 | }, 35 | { 36 | "code": "EUR", 37 | "name": "Euro" 38 | }, 39 | { 40 | "code": "GBP", 41 | "name": "Pound Sterling" 42 | }, 43 | { 44 | "code": "HKD", 45 | "name": "Hong Kong Dollar" 46 | }, 47 | { 48 | "code": "HUF", 49 | "name": "Hungarian Forint" 50 | }, 51 | { 52 | "code": "ILS", 53 | "name": "Israeli New Shekel" 54 | }, 55 | { 56 | "code": "INR", 57 | "name": "Indian Rupee" 58 | }, 59 | { 60 | "code": "JPY", 61 | "name": "Japanese Yen" 62 | }, 63 | { 64 | "code": "MXN", 65 | "name": "Mexican Peso" 66 | }, 67 | { 68 | "code": "NOK", 69 | "name": "Norwegian Krone" 70 | }, 71 | { 72 | "code": "NZD", 73 | "name": "New Zealand Dollar" 74 | }, 75 | { 76 | "code": "PLN", 77 | "name": "Polish Zloty" 78 | }, 79 | { 80 | "code": "RON", 81 | "name": "Romanian Leu" 82 | }, 83 | { 84 | "code": "RUB", 85 | "name": "Russian Ruble" 86 | }, 87 | { 88 | "code": "SEK", 89 | "name": "Swedish Krona" 90 | }, 91 | { 92 | "code": "SGD", 93 | "name": "Singapore Dollar" 94 | }, 95 | { 96 | "code": "TRY", 97 | "name": "Turkish Lira" 98 | }, 99 | { 100 | "code": "UAH", 101 | "name": "Ukrainian Hryvnia" 102 | }, 103 | { 104 | "code": "USD", 105 | "name": "United States Dollar" 106 | }, 107 | { 108 | "code": "ZAR", 109 | "name": "South African Rand" 110 | } 111 | ] 112 | } 113 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/xchange_api/drivers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import asyncio 4 | import json 5 | from itertools import chain, repeat 6 | from typing import List 7 | 8 | from requests import Response 9 | from requests.exceptions import RequestException 10 | 11 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 12 | from src.domain.provider import ProviderEntity 13 | from src.infrastructure.clients.provider.base import ProviderBaseDriver 14 | from src.infrastructure.clients.provider.decorators import async_event_loop 15 | from src.infrastructure.clients.provider.utils import ( 16 | get_business_days, get_last_business_day) 17 | from src.infrastructure.clients.provider.xchange_api.exceptions import ( 18 | XChangeAPIDriverError) 19 | from src.infrastructure.clients.provider.xchange_api.serializers import ( 20 | ExchangeRateSerializer) 21 | 22 | 23 | class XChangeAPIDriver(ProviderBaseDriver): 24 | HISTORICAL_RATE = 'historical' 25 | ENDPOINTS = { 26 | HISTORICAL_RATE: { 27 | 'method': 'get', 28 | 'path': 'historical/{date}', 29 | 'serializer_class': ExchangeRateSerializer, 30 | } 31 | } 32 | 33 | def __init__(self, provider: ProviderEntity): 34 | super().__init__(provider) 35 | self.api_url = provider.settings.get('api_url').value 36 | self.api_key = provider.settings.get('api_key').value 37 | 38 | def _get_headers(self) -> dict: 39 | headers = super()._get_headers() 40 | headers.update({'api-key': self.api_key}) 41 | return headers 42 | 43 | def _has_response_error(self, response: Response) -> bool: 44 | try: 45 | data = response.json() 46 | except ValueError: 47 | return False 48 | return data.get('message') is not None 49 | 50 | def _handle_response_error(self, error: RequestException): 51 | has_response = error.response is not None 52 | message = error.response.reason if has_response else str(error) 53 | status_code = error.response.status_code if has_response else None 54 | raise XChangeAPIDriverError(message=message, code=status_code) 55 | 56 | def _process_response_error(self, data: dict, status_code: int): 57 | message = data.get('message', '') 58 | raise XChangeAPIDriverError(message=message, code=status_code) 59 | 60 | def get_currencies(self) -> List[CurrencyEntity]: 61 | with open('./currencies.json', 'r') as currencies_file: 62 | data = json.load(currencies_file) 63 | currencies = data.get('availableCurrencies') 64 | return list(map(lambda currency: CurrencyEntity(**currency), currencies)) 65 | 66 | def get_exchange_rate(self, source_currency: str, exchanged_currency: str, 67 | date: str = None) -> CurrencyExchangeRateEntity: 68 | url_params = {'date': date or get_last_business_day()} 69 | params = {'base': source_currency} 70 | response = self._request(self.HISTORICAL_RATE, params=params, url_params=url_params) 71 | response.update({'symbols': exchanged_currency}) 72 | exchange_rate = self._deserialize_response(self.HISTORICAL_RATE, response) 73 | return exchange_rate[0] if len(exchange_rate) > 0 else None 74 | 75 | @async_event_loop 76 | async def get_time_series(self, source_currency: str, exchanged_currency: str, 77 | date_from: str, date_to: str) -> List[CurrencyExchangeRateEntity]: 78 | async def request(endpoint: str, params: dict, url_params: dict) -> dict: 79 | symbols = params.get('symbols') 80 | response = self._request(endpoint, params=params, url_params=url_params) 81 | response.update({'symbols': symbols}) 82 | return response 83 | 84 | business_days = get_business_days(date_from, date_to) 85 | url_params = [{'date': business_day} for business_day in business_days] 86 | params = {'base': source_currency, 'symbols': exchanged_currency} 87 | responses = await asyncio.gather(*list( 88 | map(request, repeat(self.HISTORICAL_RATE), repeat(params), url_params))) 89 | timeseries = list(chain(*map( 90 | self._deserialize_response, repeat(self.HISTORICAL_RATE), responses))) 91 | return timeseries 92 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/xchange_api/exceptions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from src.infrastructure.clients.provider.exceptions import ProviderDriverError 4 | 5 | 6 | class XChangeAPIDriverError(ProviderDriverError): 7 | pass 8 | -------------------------------------------------------------------------------- /src/infrastructure/clients/provider/xchange_api/serializers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from datetime import date 4 | from typing import List 5 | 6 | from marshmallow import Schema, fields, EXCLUDE 7 | from marshmallow.decorators import post_load, pre_load 8 | 9 | from src.domain.exchange_rate import CurrencyExchangeRateEntity 10 | 11 | 12 | class ExchangeRateSerializer(Schema): 13 | 14 | class Meta: 15 | unknown = EXCLUDE 16 | 17 | source_currency = fields.String(data_key='base', required=True) 18 | valuation_date = fields.Date(required=True) 19 | rates = fields.Dict( 20 | keys=fields.String(), values=fields.Float(), required=True) 21 | 22 | @pre_load 23 | def process_date(self, in_data: dict, **kwargs) -> dict: 24 | timestamp = in_data.pop('timestamp') 25 | in_data['valuation_date'] = date.fromtimestamp( 26 | timestamp).strftime('%Y-%m-%d') 27 | return in_data 28 | 29 | @post_load(pass_original=True) 30 | def make_exchange_rates(self, data: dict, original_data: dict, 31 | **kwargs) -> List[CurrencyExchangeRateEntity]: 32 | exchanged_currencies = original_data.get('symbols').split(',') 33 | return [ 34 | CurrencyExchangeRateEntity( 35 | source_currency=data.get('source_currency'), 36 | exchanged_currency=exchanged_currency, 37 | valuation_date=data.get('valuation_date'), 38 | rate_value=round( 39 | float(data.get('rates').get(exchanged_currency)), 6) 40 | ) 41 | for exchanged_currency in exchanged_currencies 42 | if data.get('rates').get(exchanged_currency) is not None 43 | ] 44 | -------------------------------------------------------------------------------- /src/infrastructure/factories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/factories/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/factories/exchange_rates.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from src.infrastructure.factories.provider import ProviderClientInteractorFactory 4 | from src.infrastructure.orm.cache.exchange_rate.repositories import ( 5 | CurrencyCacheRepository, CurrencyExchangeRateCacheRepository) 6 | from src.infrastructure.orm.db.exchange_rate.repositories import ( 7 | CurrencyDatabaseRepository, CurrencyExchangeRateDatabaseRepository) 8 | from src.interface.controllers.exchange_rate import ( 9 | CurrencyController, CurrencyExchangeRateController) 10 | from src.interface.repositories.exchange_rate import ( 11 | CurrencyRepository, CurrencyExchangeRateRepository) 12 | from src.usecases.exchange_rate import ( 13 | CurrencyInteractor, CurrencyExchangeRateInteractor) 14 | 15 | 16 | class CurrencyDatabaseRepositoryFactory: 17 | 18 | @staticmethod 19 | def get() -> CurrencyDatabaseRepository: 20 | return CurrencyDatabaseRepository() 21 | 22 | 23 | class CurrencyCacheRepositoryFactory: 24 | 25 | @staticmethod 26 | def get() -> CurrencyCacheRepository: 27 | return CurrencyCacheRepository() 28 | 29 | 30 | class CurrencyRepositoryFactory: 31 | 32 | @staticmethod 33 | def get() -> CurrencyRepository: 34 | db_repo = CurrencyDatabaseRepositoryFactory.get() 35 | cache_repo = CurrencyCacheRepositoryFactory.get() 36 | return CurrencyRepository(db_repo, cache_repo) 37 | 38 | 39 | class CurrencyInteractorFactory: 40 | 41 | @staticmethod 42 | def get() -> CurrencyInteractor: 43 | currency_repo = CurrencyRepositoryFactory.get() 44 | return CurrencyInteractor(currency_repo) 45 | 46 | 47 | class CurrencyViewSetFactory: 48 | 49 | @staticmethod 50 | def create() -> CurrencyController: 51 | currency_interactor = CurrencyInteractorFactory.get() 52 | provider_client_interactor = ProviderClientInteractorFactory.get() 53 | return CurrencyController( 54 | currency_interactor, 55 | provider_client_interactor 56 | ) 57 | 58 | 59 | class CurrencyExchangeRateDatabaseRepositoryFactory: 60 | 61 | @staticmethod 62 | def get() -> CurrencyExchangeRateDatabaseRepository: 63 | return CurrencyExchangeRateDatabaseRepository() 64 | 65 | 66 | class CurrencyExchangeRateCacheRepositoryFactory: 67 | 68 | @staticmethod 69 | def get() -> CurrencyExchangeRateCacheRepository: 70 | return CurrencyExchangeRateCacheRepository() 71 | 72 | 73 | class CurrencyExchangeRateRepositoryFactory: 74 | 75 | @staticmethod 76 | def get() -> CurrencyExchangeRateRepository: 77 | db_repo = CurrencyExchangeRateDatabaseRepositoryFactory.get() 78 | cache_repo = CurrencyExchangeRateCacheRepositoryFactory.get() 79 | return CurrencyExchangeRateRepository(db_repo, cache_repo) 80 | 81 | 82 | class CurrencyExchangeRateInteractorFactory: 83 | 84 | @staticmethod 85 | def get() -> CurrencyExchangeRateInteractor: 86 | exchange_rate_repo = CurrencyExchangeRateRepositoryFactory.get() 87 | return CurrencyExchangeRateInteractor(exchange_rate_repo) 88 | 89 | 90 | class CurrencyExchangeRateViewSetFactory: 91 | 92 | @staticmethod 93 | def create() -> CurrencyExchangeRateController: 94 | exchange_rate_interactor = CurrencyExchangeRateInteractorFactory.get() 95 | provider_client_interactor = ProviderClientInteractorFactory.get() 96 | return CurrencyExchangeRateController( 97 | exchange_rate_interactor, 98 | provider_client_interactor 99 | ) 100 | -------------------------------------------------------------------------------- /src/infrastructure/factories/provider.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from src.infrastructure.clients.provider.drivers import ProviderMasterDriver 4 | from src.infrastructure.orm.cache.provider.repositories import ProviderCacheRepository 5 | from src.infrastructure.orm.db.provider.repositories import ProviderDatabaseRepository 6 | from src.interface.clients.provider import ProviderClient 7 | from src.interface.repositories.provider import ProviderRepository 8 | from src.usecases.provider import ProviderInteractor, ProviderClientInteractor 9 | 10 | 11 | class ProviderDatabaseRepositoryFactory: 12 | 13 | @staticmethod 14 | def get(): 15 | return ProviderDatabaseRepository() 16 | 17 | 18 | class ProviderCacheRepositoryFactory: 19 | 20 | @staticmethod 21 | def get(): 22 | return ProviderCacheRepository() 23 | 24 | 25 | class ProviderRepositoryFactory: 26 | 27 | @staticmethod 28 | def get(): 29 | db_repo = ProviderDatabaseRepositoryFactory.get() 30 | cache_repo = ProviderCacheRepositoryFactory.get() 31 | return ProviderRepository(db_repo, cache_repo) 32 | 33 | 34 | class ProviderInteractorFactory: 35 | 36 | @staticmethod 37 | def get(): 38 | provider_repo = ProviderRepositoryFactory.get() 39 | return ProviderInteractor(provider_repo) 40 | 41 | 42 | class ProviderDriverFactory: 43 | 44 | @staticmethod 45 | def get(): 46 | provider_interactor = ProviderInteractorFactory.get() 47 | return ProviderMasterDriver(provider_interactor) 48 | 49 | 50 | class ProviderClientFactory: 51 | 52 | @staticmethod 53 | def get(): 54 | provider_driver = ProviderDriverFactory.get() 55 | return ProviderClient(provider_driver) 56 | 57 | 58 | class ProviderClientInteractorFactory: 59 | 60 | @staticmethod 61 | def get(): 62 | provider_client = ProviderClientFactory.get() 63 | return ProviderClientInteractor(provider_client) 64 | -------------------------------------------------------------------------------- /src/infrastructure/orm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/orm/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/orm/cache/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/orm/cache/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/orm/cache/exchange_rate/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/orm/cache/exchange_rate/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/orm/cache/exchange_rate/constants.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | CACHE_AVAILABLE_CURRENCIES_KEY = 'cache_available_currencies_key' 4 | CACHE_EXCHANGE_RATE_KEY = '{source_currency}_{exchanged_currency}_{valuation_date}' 5 | -------------------------------------------------------------------------------- /src/infrastructure/orm/cache/exchange_rate/repositories.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from typing import List, Union 4 | 5 | from django.core.cache import cache 6 | 7 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 8 | from src.infrastructure.orm.cache.exchange_rate.constants import ( 9 | CACHE_AVAILABLE_CURRENCIES_KEY, CACHE_EXCHANGE_RATE_KEY) 10 | 11 | 12 | class CurrencyCacheRepository: 13 | 14 | def get(self, key: str) -> CurrencyEntity: 15 | return cache.get(key) 16 | 17 | def get_availables(self) -> List[CurrencyEntity]: 18 | return self.get(CACHE_AVAILABLE_CURRENCIES_KEY) 19 | 20 | def save(self, key: str, value: Union[CurrencyEntity, list]): 21 | cache.set(key, value) 22 | 23 | def save_availables(self, currencies: List[CurrencyEntity]): 24 | self.save(CACHE_AVAILABLE_CURRENCIES_KEY, currencies) 25 | 26 | 27 | class CurrencyExchangeRateCacheRepository: 28 | 29 | @staticmethod 30 | def get_exchange_rate_key(source_currency: str, exchanged_currency: str, 31 | valuation_date: str) -> str: 32 | return CACHE_EXCHANGE_RATE_KEY.format( 33 | source_currency=source_currency, 34 | exchanged_currency=exchanged_currency, 35 | valuation_date=valuation_date 36 | ) 37 | 38 | def get(self, source_currency: str, exchanged_currency: str, 39 | valuation_date: str) -> CurrencyExchangeRateEntity: 40 | key = self.get_exchange_rate_key( 41 | source_currency, exchanged_currency, valuation_date) 42 | return cache.get(key) 43 | 44 | def save(self, exchange_rate: CurrencyExchangeRateEntity): 45 | key = self.get_exchange_rate_key( 46 | exchange_rate.source_currency, 47 | exchange_rate.exchanged_currency, 48 | exchange_rate.valuation_date) 49 | cache.set(key, exchange_rate) 50 | -------------------------------------------------------------------------------- /src/infrastructure/orm/cache/provider/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/orm/cache/provider/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/orm/cache/provider/constants.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | CACHE_PROVIDERS_BY_PRIORITY_KEY = 'cache_providers_by_priority_key' 4 | -------------------------------------------------------------------------------- /src/infrastructure/orm/cache/provider/repositories.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from typing import List 4 | 5 | from django.core.cache import cache 6 | 7 | from src.domain.provider import ProviderEntity 8 | from src.infrastructure.orm.cache.provider.constants import ( 9 | CACHE_PROVIDERS_BY_PRIORITY_KEY) 10 | 11 | 12 | class ProviderCacheRepository: 13 | 14 | def get_by_priority(self) -> List[ProviderEntity]: 15 | return cache.get(CACHE_PROVIDERS_BY_PRIORITY_KEY) 16 | 17 | def save(self, providers: List[ProviderEntity]): 18 | cache.set(CACHE_PROVIDERS_BY_PRIORITY_KEY, providers) 19 | -------------------------------------------------------------------------------- /src/infrastructure/orm/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/orm/db/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/orm/db/apps.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class ExchangeRateConfig(AppConfig): 7 | label = 'exchange_rate' 8 | name = 'src.infrastructure.orm.db.exchange_rate' 9 | verbose_name = 'Exchange rate' 10 | 11 | 12 | class ProviderConfig(AppConfig): 13 | label = 'provider' 14 | name = 'src.infrastructure.orm.db.provider' 15 | verbose_name = 'Provider' 16 | -------------------------------------------------------------------------------- /src/infrastructure/orm/db/exchange_rate/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | default_app_config = 'src.infrastructure.orm.db.apps.ExchangeRateConfig' 4 | -------------------------------------------------------------------------------- /src/infrastructure/orm/db/exchange_rate/admin.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from django.contrib import admin 4 | 5 | from src.infrastructure.adminsite.exchange_rate.admin import ( 6 | CurrencyAdmin, CurrencyExchangeRateAdmin) 7 | from src.infrastructure.orm.db.exchange_rate.models import ( 8 | Currency, CurrencyExchangeRate) 9 | 10 | 11 | admin.site.register(Currency, CurrencyAdmin) 12 | admin.site.register(CurrencyExchangeRate, CurrencyExchangeRateAdmin) 13 | -------------------------------------------------------------------------------- /src/infrastructure/orm/db/exchange_rate/migrations/0001_create_currency_and_exchangerate.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-08-10 20:58 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Currency', 16 | fields=[ 17 | ('code', models.CharField(max_length=3, primary_key=True, serialize=False, unique=True)), 18 | ('name', models.CharField(max_length=50, blank=True, null=True)), 19 | ('symbol', models.CharField(max_length=1, blank=True, null=True)), 20 | ], 21 | options={ 22 | 'verbose_name': 'currency', 23 | 'verbose_name_plural': 'currencies', 24 | 'ordering': ('code',), 25 | }, 26 | ), 27 | migrations.CreateModel( 28 | name='CurrencyExchangeRate', 29 | fields=[ 30 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('valuation_date', models.DateField(db_index=True)), 32 | ('rate_value', models.DecimalField(decimal_places=6, max_digits=18)), 33 | ('exchanged_currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exchange_rate.currency')), 34 | ('source_currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exchanges', to='exchange_rate.currency')), 35 | ], 36 | options={ 37 | 'verbose_name': 'currency exchange rate', 38 | 'verbose_name_plural': 'currency exchange rates', 39 | 'ordering': ('-valuation_date', 'source_currency'), 40 | }, 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /src/infrastructure/orm/db/exchange_rate/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/orm/db/exchange_rate/migrations/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/orm/db/exchange_rate/models.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from django.db import models 4 | 5 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 6 | 7 | 8 | class Currency(models.Model): 9 | code = models.CharField(max_length=3, primary_key=True, unique=True) 10 | name = models.CharField(max_length=50, blank=True, null=True) 11 | symbol = models.CharField(max_length=1, blank=True, null=True) 12 | 13 | class Meta: 14 | verbose_name = 'currency' 15 | verbose_name_plural = 'currencies' 16 | ordering = ('code',) 17 | 18 | def __str__(self) -> str: 19 | return CurrencyEntity.to_string(self) 20 | 21 | 22 | class CurrencyExchangeRate(models.Model): 23 | source_currency = models.ForeignKey( 24 | Currency, db_index=True, on_delete=models.CASCADE, related_name='exchanges') 25 | exchanged_currency = models.ForeignKey( 26 | Currency, db_index=True, on_delete=models.CASCADE) 27 | valuation_date = models.DateField(db_index=True) 28 | rate_value = models.DecimalField(decimal_places=6, max_digits=18) 29 | 30 | class Meta: 31 | verbose_name = 'currency exchange rate' 32 | verbose_name_plural = 'currency exchange rates' 33 | ordering = ('-valuation_date', 'source_currency') 34 | 35 | def __str__(self) -> str: 36 | return CurrencyExchangeRateEntity.to_string(self) 37 | -------------------------------------------------------------------------------- /src/infrastructure/orm/db/exchange_rate/repositories.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import dataclasses 4 | import json 5 | from typing import List 6 | 7 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 8 | from src.infrastructure.orm.db.exchange_rate.models import ( 9 | Currency, CurrencyExchangeRate) 10 | from src.infrastructure.orm.db.exchange_rate.tasks import ( 11 | bulk_save_currencies, bulk_save_exchange_rates, save_currency, 12 | save_exchange_rate) 13 | from src.interface.repositories.exceptions import EntityDoesNotExist 14 | 15 | 16 | class CurrencyDatabaseRepository: 17 | 18 | def get(self, code: str) -> CurrencyEntity: 19 | currency = Currency.objects.filter(code=code).values().first() 20 | if not currency: 21 | raise EntityDoesNotExist(f'{code} currency code does not exist') 22 | return CurrencyEntity(**currency) 23 | 24 | def get_availables(self) -> List[CurrencyEntity]: 25 | return list(map(lambda x: CurrencyEntity(**x), Currency.objects.values())) 26 | 27 | def save(self, currency: CurrencyEntity): 28 | currency_json = json.dumps(dataclasses.asdict(currency)) 29 | save_currency.apply_async(kwargs={'currency_json': currency_json}) 30 | 31 | def bulk_save(self, currencies: List[CurrencyEntity]): 32 | currencies_json = json.dumps(list(map(dataclasses.asdict, currencies))) 33 | bulk_save_currencies.apply_async(kwargs={'currencies_json': currencies_json}) 34 | 35 | 36 | class CurrencyExchangeRateDatabaseRepository: 37 | 38 | def get(self, source_currency: str, exchanged_currency: str, 39 | valuation_date: str) -> CurrencyExchangeRateEntity: 40 | exchange_rate = CurrencyExchangeRate.objects.filter( 41 | source_currency=source_currency, 42 | exchanged_currency=exchanged_currency, 43 | valuation_date=valuation_date 44 | ).values( 45 | 'source_currency', 'exchanged_currency', 'valuation_date', 'rate_value' 46 | ).first() 47 | if not exchange_rate: 48 | raise EntityDoesNotExist( 49 | f'Exchange rate {source_currency}/{exchanged_currency} ' 50 | f'for {valuation_date} does not exist') 51 | return CurrencyExchangeRateEntity(**exchange_rate) 52 | 53 | def get_rate_series(self, source_currency: str, exchanged_currency: str, 54 | date_from: str, date_to: str) -> List[float]: 55 | rate_series = CurrencyExchangeRate.objects.filter( 56 | source_currency=source_currency, 57 | exchanged_currency=exchanged_currency, 58 | valuation_date__range=[date_from, date_to] 59 | ).values_list('rate_value', flat=True) 60 | return list(map(float, rate_series)) 61 | 62 | def get_time_series(self, source_currency: str, exchanged_currency: str, 63 | date_from: str, date_to: str) -> List[CurrencyExchangeRateEntity]: 64 | timeseries = CurrencyExchangeRate.objects.filter( 65 | source_currency=source_currency, 66 | exchanged_currency__in=exchanged_currency.split(','), 67 | valuation_date__range=[date_from, date_to] 68 | ).values( 69 | 'source_currency', 'exchanged_currency', 'valuation_date', 'rate_value') 70 | return list(map(lambda x: CurrencyExchangeRateEntity(**x), timeseries)) 71 | 72 | def save(self, exchange_rate: CurrencyExchangeRateEntity): 73 | exchange_rate_json = json.dumps(dataclasses.asdict(exchange_rate)) 74 | save_exchange_rate.apply_async( 75 | kwargs={'exchange_rate_json': exchange_rate_json}) 76 | 77 | def bulk_save(self, exchange_rates: List[CurrencyExchangeRateEntity]): 78 | exchange_rates_json = json.dumps( 79 | list(map(dataclasses.asdict, exchange_rates))) 80 | bulk_save_exchange_rates.apply_async( 81 | kwargs={'exchange_rates_json': exchange_rates_json}) 82 | -------------------------------------------------------------------------------- /src/infrastructure/orm/db/exchange_rate/tasks.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import json 4 | 5 | from celery import shared_task 6 | 7 | from src.infrastructure.orm.db.exchange_rate.models import ( 8 | Currency, CurrencyExchangeRate) 9 | 10 | 11 | def get_currency(code: str, name: str = None) -> Currency: 12 | attrs = {'code': code} 13 | try: 14 | currency = Currency.objects.get(**attrs) 15 | except Currency.DoesNotExist: 16 | if name is not None: 17 | attrs.update({'name': name}) 18 | currency = Currency.objects.create(**attrs) 19 | return currency 20 | 21 | 22 | @shared_task 23 | def save_currency(currency_json: str): 24 | currency = json.loads(currency_json) 25 | Currency.objects.create( 26 | code=currency.get('code'), 27 | name=currency.get('name'), 28 | symbol=currency.get('symbol') 29 | ) 30 | 31 | 32 | @shared_task 33 | def save_exchange_rate(exchange_rate_json: str): 34 | exchange_rate = json.loads(exchange_rate_json) 35 | source_currency = get_currency(exchange_rate.get('source_currency')) 36 | exchanged_currency = get_currency(exchange_rate.get('exchanged_currency')) 37 | 38 | CurrencyExchangeRate.objects.create( 39 | source_currency=source_currency, 40 | exchanged_currency=exchanged_currency, 41 | valuation_date=exchange_rate.get('valuation_date'), 42 | rate_value=exchange_rate.get('rate_value') 43 | ) 44 | 45 | 46 | @shared_task 47 | def bulk_save_currencies(currencies_json: str): 48 | currencies = json.loads(currencies_json) 49 | batch = [ 50 | Currency( 51 | code=currency.get('code'), 52 | name=currency.get('name'), 53 | symbol=currency.get('symbol') 54 | ) 55 | for currency in currencies 56 | ] 57 | Currency.objects.bulk_create(batch, ignore_conflicts=True) 58 | 59 | 60 | @shared_task 61 | def bulk_save_exchange_rates(exchange_rates_json: str): 62 | exchange_rates = json.loads(exchange_rates_json) 63 | source_currency = get_currency(exchange_rates[0].get('source_currency')) 64 | exchanged_codes = set(list( 65 | map(lambda x: x.get('exchanged_currency'), exchange_rates))) 66 | exchanged_currencies = { 67 | code: get_currency(code) for code in list(exchanged_codes) 68 | } 69 | 70 | batch = [ 71 | CurrencyExchangeRate( 72 | source_currency=source_currency, 73 | exchanged_currency=exchanged_currencies.get( 74 | exchange_rate.get('exchanged_currency')), 75 | valuation_date=exchange_rate.get('valuation_date'), 76 | rate_value=exchange_rate.get('rate_value') 77 | ) 78 | for exchange_rate in exchange_rates 79 | ] 80 | CurrencyExchangeRate.objects.bulk_create(batch, ignore_conflicts=True) 81 | -------------------------------------------------------------------------------- /src/infrastructure/orm/db/provider/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | default_app_config = 'src.infrastructure.orm.db.apps.ProviderConfig' 4 | -------------------------------------------------------------------------------- /src/infrastructure/orm/db/provider/admin.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from django.contrib import admin 4 | 5 | from src.infrastructure.adminsite.provider.admin import ProviderAdmin 6 | from src.infrastructure.orm.db.provider.models import Provider 7 | 8 | 9 | admin.site.register(Provider, ProviderAdmin) 10 | -------------------------------------------------------------------------------- /src/infrastructure/orm/db/provider/constants.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from src.domain.constants import ( 4 | BOOLEAN_SETTING_TYPE, FLOAT_SETTING_TYPE, INTEGER_SETTING_TYPE, 5 | SECRET_SETTING_TYPE, TEXT_SETTING_TYPE, URL_SETTING_TYPE) 6 | 7 | 8 | # Provider setting types choices 9 | SETTING_TYPE_CHOICES = ( 10 | (BOOLEAN_SETTING_TYPE, 'boolean'), 11 | (FLOAT_SETTING_TYPE, 'float'), 12 | (INTEGER_SETTING_TYPE, 'integer'), 13 | (SECRET_SETTING_TYPE, 'secret'), 14 | (TEXT_SETTING_TYPE, 'text'), 15 | (URL_SETTING_TYPE, 'url') 16 | ) 17 | -------------------------------------------------------------------------------- /src/infrastructure/orm/db/provider/migrations/0001_create_provider_and_providersetting.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-09-22 12:20 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Provider', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('name', models.CharField(max_length=25, unique=True)), 19 | ('driver', models.CharField(max_length=25, unique=True)), 20 | ('priority', models.PositiveSmallIntegerField(unique=True)), 21 | ('enabled', models.BooleanField(default=True)), 22 | ], 23 | options={ 24 | 'verbose_name': 'provider', 25 | 'verbose_name_plural': 'providers', 26 | 'ordering': ('priority',), 27 | }, 28 | ), 29 | migrations.CreateModel( 30 | name='ProviderSetting', 31 | fields=[ 32 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 33 | ('setting_type', models.CharField(choices=[('boolean', 'boolean'), ('float', 'float'), ('integer', 'integer'), ('secret', 'secret'), ('text', 'text'), ('url', 'url')], max_length=10)), 34 | ('key', models.SlugField(max_length=64)), 35 | ('value', models.CharField(max_length=255)), 36 | ('description', models.TextField(blank=True, null=True)), 37 | ('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='provider.provider')), 38 | ], 39 | options={ 40 | 'verbose_name': 'provider setting', 41 | 'verbose_name_plural': 'provider settings', 42 | 'unique_together': {('provider', 'key')}, 43 | }, 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /src/infrastructure/orm/db/provider/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/orm/db/provider/migrations/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/orm/db/provider/models.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.db import models 5 | 6 | from src.infrastructure.clients.provider.utils import get_drivers_names 7 | from src.domain.provider import ProviderEntity, ProviderSettingEntity 8 | from src.infrastructure.orm.db.provider.constants import SETTING_TYPE_CHOICES 9 | 10 | 11 | class Provider(models.Model): 12 | name = models.CharField(max_length=25, blank=False, null=False, unique=True) 13 | driver = models.CharField(max_length=25, blank=False, null=False, unique=True) 14 | priority = models.PositiveSmallIntegerField(blank=False, null=False, unique=True) 15 | enabled = models.BooleanField(default=True) 16 | 17 | class Meta: 18 | verbose_name = 'provider' 19 | verbose_name_plural = 'providers' 20 | ordering = ('priority',) 21 | 22 | def __str__(self) -> str: 23 | return ProviderEntity.to_string(self) 24 | 25 | def clean(self): 26 | if self.driver not in get_drivers_names(): 27 | raise ValidationError({'driver': 'Invalid driver name'}) 28 | super().clean() 29 | 30 | def save(self, *args, **kwargs): 31 | self.full_clean() 32 | super().save(*args, **kwargs) 33 | 34 | 35 | class ProviderSetting(models.Model): 36 | provider = models.ForeignKey( 37 | Provider, db_index=True, on_delete=models.CASCADE, related_name='settings') 38 | setting_type = models.CharField(max_length=10, choices=SETTING_TYPE_CHOICES) 39 | key = models.SlugField(max_length=64, blank=False, null=False) 40 | value = models.CharField(max_length=255, blank=False, null=False) 41 | description = models.TextField(blank=True, null=True) 42 | 43 | class Meta: 44 | verbose_name = 'provider setting' 45 | verbose_name_plural = 'provider settings' 46 | unique_together = ('provider', 'key') 47 | 48 | def __str__(self) -> str: 49 | return ProviderSettingEntity.to_string(self) 50 | -------------------------------------------------------------------------------- /src/infrastructure/orm/db/provider/repositories.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from typing import List 4 | 5 | from django.db.models import Prefetch 6 | 7 | from src.domain.provider import ProviderEntity, ProviderSettingEntity 8 | from src.infrastructure.orm.db.provider.models import Provider, ProviderSetting 9 | 10 | 11 | class ProviderDatabaseRepository: 12 | 13 | def get_by_priority(self) -> List[ProviderEntity]: 14 | prefetch = Prefetch('settings', queryset=ProviderSetting.objects.all()) 15 | providers = Provider.objects.prefetch_related(prefetch).filter(enabled=True) 16 | return [ 17 | ProviderEntity( 18 | name=provider.name, 19 | driver=provider.driver, 20 | priority=provider.priority, 21 | settings={ 22 | setting.get('key'): ProviderSettingEntity(**setting) 23 | for setting in provider.settings.values('setting_type', 'key', 'value') 24 | } 25 | ) for provider in providers 26 | ] 27 | -------------------------------------------------------------------------------- /src/infrastructure/server/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from src.infrastructure.server.celery import app as celery_app 4 | 5 | 6 | __all__ = ('celery_app',) 7 | -------------------------------------------------------------------------------- /src/infrastructure/server/celery.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import os 4 | 5 | from celery import Celery 6 | 7 | 8 | env = os.environ.get('DJANGO_ENV') 9 | settings_module = f'src.infrastructure.settings.{env}' 10 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings_module) 11 | 12 | app = Celery('forex') 13 | app.config_from_object('django.conf:settings', namespace='CELERY') 14 | app.autodiscover_tasks() 15 | -------------------------------------------------------------------------------- /src/infrastructure/server/urls.py: -------------------------------------------------------------------------------- 1 | """forex URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | 17 | from django.conf import settings 18 | from django.contrib import admin 19 | from django.urls import path 20 | from django.urls.conf import include 21 | 22 | 23 | urlpatterns = [ 24 | path('admin/', admin.site.urls), 25 | path('api/', include((f'{settings.API_ROUTES}.urls', 'api'), namespace='api')) 26 | ] 27 | -------------------------------------------------------------------------------- /src/infrastructure/server/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for forex project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | 15 | env = os.environ.get('DJANGO_ENV') 16 | settings_module = f'src.infrastructure.settings.{env}' 17 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings_module) 18 | 19 | application = get_wsgi_application() 20 | -------------------------------------------------------------------------------- /src/infrastructure/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/infrastructure/settings/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/settings/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for forex project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-e-)^!t4%*e$63q&@q5pjk4479+d7(ttg-=9#@cfr26$==ud75x' 24 | 25 | ALLOWED_HOSTS = [] 26 | 27 | 28 | # Application definition 29 | 30 | INSTALLED_APPS = [ 31 | 'django.contrib.admin', 32 | 'django.contrib.auth', 33 | 'django.contrib.contenttypes', 34 | 'django.contrib.sessions', 35 | 'django.contrib.messages', 36 | 'django.contrib.staticfiles', 37 | # third-party 38 | 'rest_framework', 39 | # project 40 | 'src.infrastructure.orm.db.exchange_rate', 41 | 'src.infrastructure.orm.db.provider', 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'src.infrastructure.server.urls' 55 | 56 | API_ROUTES = 'src.infrastructure.api.routes' 57 | 58 | TEMPLATES = [ 59 | { 60 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 61 | 'DIRS': [], 62 | 'APP_DIRS': True, 63 | 'OPTIONS': { 64 | 'context_processors': [ 65 | 'django.template.context_processors.debug', 66 | 'django.template.context_processors.request', 67 | 'django.contrib.auth.context_processors.auth', 68 | 'django.contrib.messages.context_processors.messages', 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | WSGI_APPLICATION = 'src.infrastructure.server.wsgi.application' 75 | 76 | 77 | # Password validation 78 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 79 | 80 | AUTH_PASSWORD_VALIDATORS = [ 81 | { 82 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 83 | }, 84 | { 85 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 86 | }, 87 | { 88 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 89 | }, 90 | { 91 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 92 | }, 93 | ] 94 | 95 | 96 | # Loggging 97 | # https://docs.djangoproject.com/en/3.2/topics/logging/ 98 | 99 | LOGGING = { 100 | 'version': 1, 101 | 'disable_existing_loggers': False, 102 | 'formatters': { 103 | 'verbose': { 104 | 'format': '[%(levelname)s] %(asctime)s [%(name)s %(funcName)s %(lineno)d] %(message)s' 105 | }, 106 | 'simple': { 107 | 'format': '[%(levelname)s] %(message)s' 108 | }, 109 | }, 110 | 'handlers': { 111 | 'console': { 112 | 'level': 'INFO', 113 | 'class': 'logging.StreamHandler', 114 | 'formatter': 'verbose', 115 | } 116 | }, 117 | 'loggers': { 118 | 'django': { 119 | 'handlers': ['console'], 120 | 'propagate': False, 121 | }, 122 | '': { 123 | 'handlers': ['console'], 124 | 'level': 'INFO', 125 | 'propagate': True, 126 | } 127 | }, 128 | } 129 | 130 | 131 | # Celery application definition 132 | # https://docs.celeryproject.org/en/stable/django/ 133 | 134 | CELERY_BROKER_URL = 'redis://cache:6379' 135 | 136 | CELERY_RESULT_BACKEND = 'redis://cache:6379' 137 | 138 | CELERY_ACCEPT_CONTENT = ['application/json'] 139 | 140 | CELERY_RESULT_SERIALIZER = 'json' 141 | 142 | CELERY_TASK_SERIALIZER = 'json' 143 | 144 | 145 | # Internationalization 146 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 147 | 148 | LANGUAGE_CODE = 'en-us' 149 | 150 | TIME_ZONE = 'UTC' 151 | 152 | USE_I18N = True 153 | 154 | USE_L10N = True 155 | 156 | USE_TZ = True 157 | 158 | 159 | # Static files (CSS, JavaScript, Images) 160 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 161 | 162 | STATIC_URL = '/static/' 163 | 164 | # Default primary key field type 165 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 166 | 167 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 168 | -------------------------------------------------------------------------------- /src/infrastructure/settings/development.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from src.infrastructure.settings.base import * 4 | 5 | 6 | # SECURITY WARNING: don't run with debug turned on in production! 7 | DEBUG = True 8 | 9 | 10 | # Database 11 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 12 | 13 | DATABASES = { 14 | 'default': { 15 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 16 | 'NAME': 'forex', 17 | 'USER': 'postgres', 18 | 'PASSWORD': 'postgres', 19 | 'HOST': 'db', 20 | 'PORT': '5432', 21 | } 22 | } 23 | 24 | 25 | # Cache 26 | # https://docs.djangoproject.com/en/3.2/topics/cache/ 27 | 28 | CACHES = { 29 | 'default': { 30 | 'BACKEND': 'django_redis.cache.RedisCache', 31 | 'LOCATION': 'redis://cache:6379/1', 32 | 'TIMEOUT': 60 * 60 * 24, 33 | 'OPTIONS': { 34 | 'CLIENT_CLASS': 'django_redis.client.DefaultClient', 35 | 'SOCKET_CONNECT_TIMEOUT': 5, 36 | 'SOCKET_TIMEOUT': 5, 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/infrastructure/settings/production.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import os 4 | 5 | from src.infrastructure.settings.base import * 6 | 7 | 8 | # SECURITY WARNING: don't run with debug turned on in production! 9 | DEBUG = False 10 | 11 | 12 | # Database 13 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 14 | 15 | DATABASES = { 16 | 'default': { 17 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 18 | 'NAME': os.environ.get('POSTGRES_DB'), 19 | 'USER': os.environ.get('POSTGRES_USER'), 20 | 'PASSWORD': os.environ.get('POSTGRES_PASSWORD'), 21 | 'HOST': 'db', 22 | 'PORT': os.environ.get('POSTGRES_PORT'), 23 | } 24 | } 25 | 26 | 27 | # Cache 28 | # https://docs.djangoproject.com/en/3.2/topics/cache/ 29 | 30 | CACHES = { 31 | 'default': { 32 | 'BACKEND': 'django_redis.cache.RedisCache', 33 | 'LOCATION': f'redis://cache:{os.environ.get("REDIS_PORT")}/1', 34 | 'TIMEOUT': 60 * 60 * 24, 35 | 'OPTIONS': { 36 | 'CLIENT_CLASS': 'django_redis.client.DefaultClient', 37 | 'SOCKET_CONNECT_TIMEOUT': 5, 38 | 'SOCKET_TIMEOUT': 5, 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/infrastructure/settings/test.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from src.infrastructure.settings.base import * 4 | 5 | 6 | # SECURITY WARNING: don't run with debug turned on in production! 7 | DEBUG = True 8 | 9 | 10 | # Database 11 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 12 | 13 | DATABASES = { 14 | 'default': { 15 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 16 | 'NAME': 'forex_test', 17 | 'USER': 'postgres', 18 | 'PASSWORD': 'postgres', 19 | 'HOST': 'db', 20 | 'PORT': '5432', 21 | } 22 | } 23 | 24 | 25 | # Cache 26 | # https://docs.djangoproject.com/en/3.2/topics/cache/ 27 | 28 | CACHES = { 29 | 'default': { 30 | 'BACKEND': 'django_redis.cache.RedisCache', 31 | 'LOCATION': 'redis://cache:6379/0', 32 | 'OPTIONS': { 33 | 'CLIENT_CLASS': 'django_redis.client.DefaultClient', 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/interface/clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/interface/clients/__init__.py -------------------------------------------------------------------------------- /src/interface/clients/provider.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from typing import Any 4 | 5 | 6 | class ProviderClient: 7 | 8 | def __init__(self, provider_driver: object): 9 | self.provider_driver = provider_driver 10 | 11 | def fetch_data(self, action: str, **kwargs: dict) -> Any: 12 | return self.provider_driver.fetch_data(action, **kwargs) 13 | -------------------------------------------------------------------------------- /src/interface/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/interface/controllers/__init__.py -------------------------------------------------------------------------------- /src/interface/controllers/exchange_rate.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import logging 4 | from http import HTTPStatus 5 | from typing import Tuple 6 | 7 | from src.domain.exchange_rate import CurrencyExchangeRateEntity 8 | from src.interface.controllers.utils import ( 9 | calculate_time_weighted_rate, calculate_exchanged_amount, filter_currencies, 10 | get_rate_series) 11 | from src.interface.repositories.exceptions import EntityDoesNotExist 12 | from src.interface.serializers.exchange_rate import ( 13 | CurrencySerializer, CurrencyExchangeRateAmountSerializer, 14 | CurrencyExchangeRateConvertSerializer, CurrencyExchangeRateListSerializer, 15 | CurrencyExchangeRateSerializer, TimeWeightedRateListSerializer, 16 | TimeWeightedRateSerializer) 17 | from src.usecases.exchange_rate import CurrencyInteractor, CurrencyExchangeRateInteractor 18 | from src.usecases.provider import ProviderClientInteractor 19 | 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class CurrencyController: 25 | 26 | def __init__(self, currency_interator: CurrencyInteractor, 27 | provider_client_interactor: ProviderClientInteractor): 28 | self.currency_interator = currency_interator 29 | self.provider_client_interactor = provider_client_interactor 30 | 31 | def get(self, code: str) -> Tuple[dict, int]: 32 | code = code.upper() 33 | logger.info('Retrieving %s currency info', code) 34 | try: 35 | currency = self.currency_interator.get(code) 36 | except EntityDoesNotExist as err: 37 | currency = filter_currencies( 38 | code, self.provider_client_interactor.fetch_data('currency_get')) 39 | if currency is None: 40 | logger.error('Failure retrieving %s currency: %s', code, err.message) 41 | return {'error': err.message}, HTTPStatus.NOT_FOUND.value 42 | self.currency_interator.save(currency) 43 | logger.info('%s currency successfully retrieved', currency.code) 44 | return CurrencySerializer().dump(currency), HTTPStatus.OK.value 45 | 46 | def list(self) -> Tuple[list, int]: 47 | logger.info('Retrieving available currencies list') 48 | currencies = self.currency_interator.get_availables() 49 | if not currencies: 50 | currencies = self.provider_client_interactor.fetch_data('currency_list') 51 | if 'error' in currencies: 52 | logger.warning('Available currencies list empty') 53 | currencies = [] 54 | else: 55 | self.currency_interator.bulk_save(currencies) 56 | logger.info('Available currencies list succesfully retrieved') 57 | return ( 58 | CurrencySerializer(many=True).dump(currencies), 59 | HTTPStatus.OK.value 60 | ) 61 | 62 | 63 | class CurrencyExchangeRateController: 64 | 65 | def __init__(self, exchange_rate_interactor: CurrencyExchangeRateInteractor, 66 | provider_client_interactor: ProviderClientInteractor): 67 | self.exchange_rate_interactor = exchange_rate_interactor 68 | self.provider_client_interactor = provider_client_interactor 69 | 70 | def convert(self, params: dict) -> Tuple[dict, int]: 71 | logger.info('Converting currency for params: %s', str(params)) 72 | data = CurrencyExchangeRateConvertSerializer().load(params) 73 | if 'errors' in data: 74 | logger.error('Error deserializing params: %s', str(data['errors'])) 75 | return data, HTTPStatus.BAD_REQUEST.value 76 | amount = data.pop('amount') 77 | try: 78 | exchange_rate = self.exchange_rate_interactor.get_latest(**data) 79 | except EntityDoesNotExist as err: 80 | exchange_rate = self.provider_client_interactor.fetch_data( 81 | 'exchange_rate_convert', **data) 82 | if not isinstance(exchange_rate, CurrencyExchangeRateEntity): 83 | logger.error('Failure converting currency: %s', err.message) 84 | return {'error': err.message}, HTTPStatus.NOT_FOUND.value 85 | self.exchange_rate_interactor.save(exchange_rate) 86 | exchanged_amount = calculate_exchanged_amount(exchange_rate, amount) 87 | logger.info('Currency successfully converted: %s', str(exchanged_amount)) 88 | return ( 89 | CurrencyExchangeRateAmountSerializer().dump(exchanged_amount), 90 | HTTPStatus.OK.value 91 | ) 92 | 93 | def list(self, params: dict) -> Tuple[list, int]: 94 | logger.info('Retrieving currency exchange rate time series: %s', str(params)) 95 | data = CurrencyExchangeRateListSerializer().load(params) 96 | if 'errors' in data: 97 | logger.error('Error deserializing params: %s', str(data['errors'])) 98 | return data, HTTPStatus.BAD_REQUEST.value 99 | timeseries = self.exchange_rate_interactor.get_time_series(**data) 100 | if not timeseries: 101 | timeseries = self.provider_client_interactor.fetch_data( 102 | 'exchange_rate_list', **data) 103 | if 'error' in timeseries: 104 | logger.warning('Currency exchange rate time series empty') 105 | timeseries = [] 106 | else: 107 | self.exchange_rate_interactor.bulk_save(timeseries) 108 | logger.info('Currency exchange rate time series successfully retrieved') 109 | return ( 110 | CurrencyExchangeRateSerializer(many=True).dump(timeseries), 111 | HTTPStatus.OK.value 112 | ) 113 | 114 | def calculate_twr(self, params: dict) -> Tuple[dict, int]: 115 | logger.info('Calculating time weighted rate for params: %s', str(params)) 116 | data = TimeWeightedRateListSerializer().load(params) 117 | if 'errors' in data: 118 | logger.error('Error deserializing params: %s', str(data['errors'])) 119 | return data, HTTPStatus.BAD_REQUEST.value 120 | rate_series = self.exchange_rate_interactor.get_rate_series(**data) 121 | if not rate_series: 122 | timeseries = self.provider_client_interactor.fetch_data( 123 | 'exchange_rate_calculate_twr', **data) 124 | if 'error' in timeseries: 125 | logger.error('Error calculating time weighted rate: %s', str(timeseries['error'])) 126 | return {'error': timeseries['error']}, timeseries['status_code'] 127 | self.exchange_rate_interactor.bulk_save(timeseries) 128 | rate_series = get_rate_series(timeseries) 129 | time_weighted_rate = calculate_time_weighted_rate(rate_series) 130 | logger.info('Time weighted rate successfully calculated') 131 | return ( 132 | TimeWeightedRateSerializer().dump(time_weighted_rate), 133 | HTTPStatus.OK.value 134 | ) 135 | -------------------------------------------------------------------------------- /src/interface/controllers/utils.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import operator 4 | from functools import reduce 5 | from typing import List 6 | 7 | from src.domain.exchange_rate import ( 8 | CurrencyEntity, CurrencyExchangeAmountEntity, CurrencyExchangeRateEntity, 9 | TimeWeightedRateEntity) 10 | 11 | 12 | def calculate_time_weighted_rate(rate_series: list) -> TimeWeightedRateEntity: 13 | twr = reduce(operator.mul, rate_series)**(1.0 / len(rate_series)) 14 | return TimeWeightedRateEntity(time_weighted_rate=twr) 15 | 16 | 17 | def calculate_exchanged_amount(exchange_rate: CurrencyExchangeRateEntity, 18 | amount: float) -> CurrencyExchangeAmountEntity: 19 | return CurrencyExchangeAmountEntity( 20 | exchanged_currency=exchange_rate.exchanged_currency, 21 | exchanged_amount=exchange_rate.calculate_amount(amount), 22 | rate_value=exchange_rate.rate_value 23 | ) 24 | 25 | 26 | def filter_currencies(code: str, currencies: list) -> CurrencyEntity: 27 | currency = list(filter( 28 | lambda x: x.code == code if hasattr(x, 'code') else False, currencies)) 29 | return currency[0] if len(currency) > 0 else None 30 | 31 | 32 | def get_rate_series(timeseries: list) -> List[float]: 33 | return list(map(lambda x: round(x.rate_value, 6), timeseries)) 34 | -------------------------------------------------------------------------------- /src/interface/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/interface/repositories/__init__.py -------------------------------------------------------------------------------- /src/interface/repositories/exceptions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | class EntityError(Exception): 4 | 5 | def __init__(self, message: str = None, *args, **kwargs): 6 | super().__init__(*args, **kwargs) 7 | self.message = message 8 | 9 | def __str__(self) -> str: 10 | return f'{self.__class__.__name__}: {self.message}' 11 | 12 | 13 | class EntityDoesNotExist(EntityError): 14 | pass 15 | -------------------------------------------------------------------------------- /src/interface/repositories/exchange_rate.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from typing import List 4 | 5 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 6 | 7 | 8 | class CurrencyRepository: 9 | 10 | def __init__(self, db_repo: object, cache_repo: object): 11 | self.db_repo = db_repo 12 | self.cache_repo = cache_repo 13 | 14 | def get(self, code: str) -> CurrencyEntity: 15 | currency = self.cache_repo.get(code) 16 | if not currency: 17 | currency = self.db_repo.get(code) 18 | self.cache_repo.save(code, currency) 19 | return currency 20 | 21 | def get_availables(self) -> List[CurrencyEntity]: 22 | currencies = self.cache_repo.get_availables() 23 | if not currencies: 24 | currencies = self.db_repo.get_availables() 25 | self.cache_repo.save_availables(currencies) 26 | return currencies 27 | 28 | def save(self, currency: CurrencyEntity): 29 | self.db_repo.save(currency) 30 | 31 | def bulk_save(self, currencies: List[CurrencyEntity]): 32 | self.db_repo.bulk_save(currencies) 33 | 34 | 35 | class CurrencyExchangeRateRepository: 36 | 37 | def __init__(self, db_repo: object, cache_repo: object): 38 | self.db_repo = db_repo 39 | self.cache_repo = cache_repo 40 | 41 | def get(self, source_currency: str, exchanged_currency: str, 42 | valuation_date: str) -> CurrencyExchangeRateEntity: 43 | exchange_rate = self.cache_repo.get( 44 | source_currency, exchanged_currency, valuation_date) 45 | if not exchange_rate: 46 | exchange_rate = self.db_repo.get( 47 | source_currency, exchanged_currency, valuation_date) 48 | self.cache_repo.save(exchange_rate) 49 | return exchange_rate 50 | 51 | def get_rate_series(self, source_currency: str, exchanged_currency: str, 52 | date_from: str, date_to: str) -> List[CurrencyExchangeRateEntity]: 53 | return self.db_repo.get_rate_series( 54 | source_currency, exchanged_currency, date_from, date_to) 55 | 56 | def get_time_series(self, source_currency: str, exchanged_currency: str, 57 | date_from: str, date_to: str) -> List[CurrencyExchangeRateEntity]: 58 | return self.db_repo.get_time_series( 59 | source_currency, exchanged_currency, date_from, date_to) 60 | 61 | def save(self, exchange_rate: CurrencyExchangeRateEntity): 62 | self.db_repo.save(exchange_rate) 63 | 64 | def bulk_save(self, exchange_rates: List[CurrencyExchangeRateEntity]): 65 | self.db_repo.bulk_save(exchange_rates) 66 | -------------------------------------------------------------------------------- /src/interface/repositories/provider.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from typing import List 4 | 5 | from src.domain.provider import ProviderEntity 6 | 7 | 8 | class ProviderRepository: 9 | 10 | def __init__(self, db_repo: object, cache_repo: object): 11 | self.db_repo = db_repo 12 | self.cache_repo = cache_repo 13 | 14 | def get_by_priority(self) -> List[ProviderEntity]: 15 | providers = self.cache_repo.get_by_priority() 16 | if not providers: 17 | providers = self.db_repo.get_by_priority() 18 | self.cache_repo.save(providers) 19 | return providers 20 | -------------------------------------------------------------------------------- /src/interface/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/interface/routes/__init__.py -------------------------------------------------------------------------------- /src/interface/routes/constants.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | # Exchange rate routes prefixes 4 | CURRENCIES_PREFIX = 'currencies' 5 | EXCHANGE_RATE_PREFIX = 'exchange-rates' 6 | -------------------------------------------------------------------------------- /src/interface/routes/exchange_rate.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from src.domain.core.constants import HTTP_VERB_GET 4 | from src.domain.core.routing import Route, Router 5 | from src.interface.controllers.exchange_rate import ( 6 | CurrencyController, CurrencyExchangeRateController) 7 | from src.interface.routes.constants import ( 8 | CURRENCIES_PREFIX, EXCHANGE_RATE_PREFIX) 9 | 10 | 11 | currency_router = Router() 12 | currency_router.register([ 13 | Route( 14 | http_verb=HTTP_VERB_GET, 15 | path=fr'^{EXCHANGE_RATE_PREFIX}/{CURRENCIES_PREFIX}/(?P[a-zA-Z]+)/$', 16 | controller=CurrencyController, 17 | method='get', 18 | name='currencies_get', 19 | ), 20 | Route( 21 | http_verb=HTTP_VERB_GET, 22 | path=fr'^{EXCHANGE_RATE_PREFIX}/{CURRENCIES_PREFIX}/$', 23 | controller=CurrencyController, 24 | method='list', 25 | name='currencies_list', 26 | ), 27 | ]) 28 | 29 | 30 | exchange_rate_router = Router() 31 | exchange_rate_router.register([ 32 | Route( 33 | http_verb=HTTP_VERB_GET, 34 | path=fr'^{EXCHANGE_RATE_PREFIX}/time-weighted/$', 35 | controller=CurrencyExchangeRateController, 36 | method='calculate_twr', 37 | name='exchange_rate_calculate_twr', 38 | ), 39 | Route( 40 | http_verb=HTTP_VERB_GET, 41 | path=fr'^{EXCHANGE_RATE_PREFIX}/convert/$', 42 | controller=CurrencyExchangeRateController, 43 | method='convert', 44 | name='exchange_rate_convert', 45 | ), 46 | Route( 47 | http_verb=HTTP_VERB_GET, 48 | path=fr'^{EXCHANGE_RATE_PREFIX}/$', 49 | controller=CurrencyExchangeRateController, 50 | method='list', 51 | name='exchange_rate_list', 52 | ), 53 | ]) 54 | -------------------------------------------------------------------------------- /src/interface/serializers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/interface/serializers/__init__.py -------------------------------------------------------------------------------- /src/interface/serializers/exchange_rate.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from marshmallow import Schema, fields 4 | from marshmallow.decorators import post_dump, post_load 5 | from marshmallow.exceptions import ValidationError 6 | 7 | 8 | class CurrencySerializer(Schema): 9 | code = fields.String(required=True) 10 | name = fields.String(required=True) 11 | symbol = fields.String(required=False) 12 | 13 | 14 | class CurrencyExchangeRateConvertSerializer(Schema): 15 | source_currency = fields.String(required=True) 16 | exchanged_currency = fields.String(required=True) 17 | amount = fields.Float(required=True) 18 | 19 | def load(self, data: dict) -> dict: 20 | try: 21 | data = super().load(data) 22 | except ValidationError as err: 23 | data = {'errors': err.messages} 24 | return data 25 | 26 | @post_load 27 | def make_upper_code(self, data: dict, **kwargs) -> dict: 28 | data['source_currency'] = data['source_currency'].upper() 29 | data['exchanged_currency'] = data['exchanged_currency'].upper() 30 | return data 31 | 32 | 33 | class CurrencyExchangeRateAmountSerializer(Schema): 34 | exchanged_currency = fields.String(required=True) 35 | exchanged_amount = fields.Float(required=True) 36 | rate_value = fields.Float(required=True) 37 | 38 | 39 | class CurrencyExchangeRateListSerializer(Schema): 40 | source_currency = fields.String(required=True) 41 | exchanged_currency = fields.String(required=True) 42 | date_from = fields.Date(required=True) 43 | date_to = fields.Date(required=True) 44 | 45 | def load(self, data: dict) -> dict: 46 | try: 47 | data = super().load(data) 48 | except ValidationError as err: 49 | data = {'errors': err.messages} 50 | return data 51 | 52 | @post_load 53 | def make_upper_code(self, data: dict, **kwargs) -> dict: 54 | data['source_currency'] = data['source_currency'].upper() 55 | data['exchanged_currency'] = ','.join( 56 | list(map(str.upper, data['exchanged_currency'].split(',')))) 57 | return data 58 | 59 | 60 | class CurrencyExchangeRateSerializer(Schema): 61 | exchanged_currency = fields.String(required=True) 62 | valuation_date = fields.String(required=True) 63 | rate_value = fields.Float(required=True) 64 | 65 | 66 | class TimeWeightedRateListSerializer(CurrencyExchangeRateListSerializer): 67 | 68 | @post_load 69 | def make_upper_code(self, data: dict, **kwargs) -> dict: 70 | data['source_currency'] = data['source_currency'].upper() 71 | data['exchanged_currency'] = data['exchanged_currency'].upper() 72 | return data 73 | 74 | 75 | class TimeWeightedRateSerializer(Schema): 76 | time_weighted_rate = fields.Float(required=True) 77 | 78 | @post_dump 79 | def round_float(self, data: dict, **kwargs) -> dict: 80 | data['time_weighted_rate'] = round(data['time_weighted_rate'], 6) 81 | return data 82 | -------------------------------------------------------------------------------- /src/usecases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/src/usecases/__init__.py -------------------------------------------------------------------------------- /src/usecases/exchange_rate.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import datetime 4 | from typing import List 5 | 6 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 7 | 8 | 9 | class CurrencyInteractor: 10 | 11 | def __init__(self, currency_repo: object): 12 | self.currency_repo = currency_repo 13 | 14 | def get(self, code: str) -> CurrencyEntity: 15 | return self.currency_repo.get(code) 16 | 17 | def get_availables(self) -> List[CurrencyEntity]: 18 | return self.currency_repo.get_availables() 19 | 20 | def save(self, currency: CurrencyEntity): 21 | self.currency_repo.save(currency) 22 | 23 | def bulk_save(self, currencies: List[CurrencyEntity]): 24 | self.currency_repo.bulk_save(currencies) 25 | 26 | 27 | class CurrencyExchangeRateInteractor: 28 | 29 | def __init__(self, exchange_rate_repo: object): 30 | self.exchange_rate_repo = exchange_rate_repo 31 | 32 | def get(self, source_currency: str, exchanged_currency: str, 33 | valuation_date: str) -> CurrencyExchangeRateEntity: 34 | return self.exchange_rate_repo.get( 35 | source_currency, exchanged_currency, valuation_date) 36 | 37 | def get_latest(self, source_currency: str, 38 | exchanged_currency: str) -> CurrencyExchangeRateEntity: 39 | today = datetime.date.today().strftime('%Y-%m-%d') 40 | return self.get(source_currency, exchanged_currency, today) 41 | 42 | def get_rate_series(self, source_currency: str, exchanged_currency: str, 43 | date_from: str, date_to: str) -> List[float]: 44 | return self.exchange_rate_repo.get_rate_series( 45 | source_currency, exchanged_currency, date_from, date_to) 46 | 47 | def get_time_series(self, source_currency: str, exchanged_currency: str, 48 | date_from: str, date_to: str) -> List[CurrencyExchangeRateEntity]: 49 | return self.exchange_rate_repo.get_time_series( 50 | source_currency, exchanged_currency, date_from, date_to) 51 | 52 | def save(self, exchange_rate: CurrencyExchangeRateEntity): 53 | self.exchange_rate_repo.save(exchange_rate) 54 | 55 | def bulk_save(self, exchange_rates: List[CurrencyExchangeRateEntity]): 56 | self.exchange_rate_repo.bulk_save(exchange_rates) 57 | -------------------------------------------------------------------------------- /src/usecases/provider.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from typing import Any, List 4 | 5 | from src.domain.provider import ProviderEntity 6 | 7 | 8 | class ProviderInteractor: 9 | 10 | def __init__(self, provider_repo: object): 11 | self.provider_repo = provider_repo 12 | 13 | def get_by_priority(self) -> List[ProviderEntity]: 14 | return self.provider_repo.get_by_priority() 15 | 16 | 17 | class ProviderClientInteractor: 18 | 19 | def __init__(self, provider_client: object): 20 | self.provider_client = provider_client 21 | 22 | def fetch_data(self, action: str, **kwargs: dict) -> Any: 23 | return self.provider_client.fetch_data(action, **kwargs) 24 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/__init__.py -------------------------------------------------------------------------------- /tests/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/domain/__init__.py -------------------------------------------------------------------------------- /tests/domain/test_exchange_rate_entities.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import datetime 4 | import decimal 5 | import random 6 | 7 | import pytest 8 | 9 | from src.domain.exchange_rate import ( 10 | CurrencyEntity, CurrencyExchangeRateEntity, 11 | CurrencyExchangeAmountEntity, TimeWeightedRateEntity) 12 | from tests.fixtures import ( 13 | currency, exchange_rate, exchange_amount, time_weighted_rate) 14 | 15 | 16 | @pytest.mark.unit 17 | def test_currency_entity_attrs(currency): 18 | assert isinstance(currency.code, str) 19 | assert isinstance(currency.name, str) 20 | assert isinstance(currency.symbol, str) 21 | 22 | 23 | @pytest.mark.unit 24 | def test_currency_entity_representation(currency): 25 | entity_str = CurrencyEntity.to_string(currency) 26 | assert isinstance(entity_str, str) 27 | assert currency.code in entity_str 28 | assert currency.name in entity_str 29 | assert currency.symbol in entity_str 30 | 31 | 32 | @pytest.mark.unit 33 | def test_currency_exchange_rate_entity_attrs(exchange_rate): 34 | assert isinstance(exchange_rate.source_currency, str) 35 | assert isinstance(exchange_rate.exchanged_currency, str) 36 | assert isinstance(exchange_rate.valuation_date, str) 37 | assert isinstance(exchange_rate.rate_value, float) 38 | 39 | 40 | @pytest.mark.unit 41 | def test_currency_exchange_rate_entity_post_init(): 42 | source_currency = 'USD' 43 | exchanged_currency = 'EUR' 44 | valuation_date = datetime.date.today() 45 | rate_value = decimal.Decimal('1.2364380312') 46 | exchange_rate = CurrencyExchangeRateEntity( 47 | source_currency=source_currency, 48 | exchanged_currency=exchanged_currency, 49 | valuation_date=valuation_date, 50 | rate_value=rate_value 51 | ) 52 | assert exchange_rate.source_currency == source_currency 53 | assert exchange_rate.exchanged_currency == exchanged_currency 54 | assert exchange_rate.valuation_date == valuation_date.strftime('%Y-%m-%d') 55 | assert exchange_rate.rate_value == round(float(rate_value), 6) 56 | 57 | 58 | @pytest.mark.unit 59 | def test_currency_exchange_rate_entity_representation(exchange_rate): 60 | entity_str = CurrencyExchangeRateEntity.to_string(exchange_rate) 61 | assert isinstance(entity_str, str) 62 | assert exchange_rate.source_currency in entity_str 63 | assert exchange_rate.exchanged_currency in entity_str 64 | assert exchange_rate.valuation_date in entity_str 65 | assert str(exchange_rate.rate_value) in entity_str 66 | 67 | 68 | @pytest.mark.unit 69 | def test_currency_exchange_rate_entity_calculate_amount(exchange_rate): 70 | amount = round(random.uniform(10, 50), 2) 71 | result = exchange_rate.calculate_amount(amount) 72 | assert result == round(amount * exchange_rate.rate_value, 2) 73 | 74 | 75 | @pytest.mark.unit 76 | def test_currency_exchange_amount_entity_attrs(exchange_amount): 77 | assert isinstance(exchange_amount.exchanged_currency, str) 78 | assert isinstance(exchange_amount.exchanged_amount, float) 79 | assert isinstance(exchange_amount.rate_value, float) 80 | 81 | 82 | @pytest.mark.unit 83 | def test_currency_exchange_amount_entity_post_init(currency): 84 | exchanged_currency = currency 85 | exchanged_amount = decimal.Decimal('17.3423') 86 | rate_value = decimal.Decimal('1.2364380312') 87 | exchange_amount = CurrencyExchangeAmountEntity( 88 | exchanged_currency=exchanged_currency, 89 | exchanged_amount=exchanged_amount, 90 | rate_value=rate_value 91 | ) 92 | assert exchange_amount.exchanged_currency == currency.code 93 | assert exchange_amount.exchanged_amount == round(float(exchanged_amount), 2) 94 | assert exchange_amount.rate_value == round(float(rate_value), 6) 95 | 96 | 97 | @pytest.mark.unit 98 | def test_currency_exchange_amount_entity_representation(exchange_amount): 99 | entity_str = CurrencyExchangeAmountEntity.to_string(exchange_amount) 100 | assert isinstance(entity_str, str) 101 | assert exchange_amount.exchanged_currency in entity_str 102 | assert str(exchange_amount.exchanged_amount) in entity_str 103 | assert str(exchange_amount.rate_value) in entity_str 104 | 105 | 106 | @pytest.mark.unit 107 | def test_time_weighted_rate_entity_attrs(time_weighted_rate): 108 | assert isinstance(time_weighted_rate.time_weighted_rate, float) 109 | 110 | 111 | @pytest.mark.unit 112 | def test_time_weighted_rate_entity_representation(time_weighted_rate): 113 | entity_str = TimeWeightedRateEntity.to_string(time_weighted_rate) 114 | assert isinstance(entity_str, str) 115 | assert str(time_weighted_rate.time_weighted_rate) in entity_str 116 | -------------------------------------------------------------------------------- /tests/domain/test_provider_entities.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import pytest 4 | 5 | from src.domain.constants import ( 6 | BOOLEAN_SETTING_TYPE, FLOAT_SETTING_TYPE, INTEGER_SETTING_TYPE, 7 | SECRET_SETTING_TYPE, TEXT_SETTING_TYPE, URL_SETTING_TYPE) 8 | from src.domain.provider import ProviderEntity, ProviderSettingEntity 9 | from tests.fixtures import provider, provider_setting 10 | 11 | 12 | @pytest.mark.unit 13 | def test_provider_entity_attrs(provider): 14 | assert isinstance(provider.name, str) 15 | assert isinstance(provider.driver, str) 16 | assert isinstance(provider.priority, int) 17 | assert isinstance(provider.enabled, bool) 18 | assert isinstance(provider.settings, dict) 19 | 20 | 21 | @pytest.mark.unit 22 | def test_provider_entity_representation(provider): 23 | entity_str = ProviderEntity.to_string(provider) 24 | assert isinstance(entity_str, str) 25 | assert provider.name in entity_str 26 | assert provider.driver in entity_str 27 | assert str(provider.priority) in entity_str 28 | 29 | 30 | @pytest.mark.unit 31 | def test_provider_setting_entity_attrs(provider_setting): 32 | assert isinstance(provider_setting.provider, ProviderEntity) 33 | assert isinstance(provider_setting.setting_type, str) 34 | assert isinstance(provider_setting.key, str) 35 | assert isinstance(provider_setting.description, str) 36 | 37 | 38 | @pytest.mark.unit 39 | def test_provider_setting_entity_post_init(provider_setting): 40 | if provider_setting.setting_type == BOOLEAN_SETTING_TYPE: 41 | assert isinstance(provider_setting.value, bool) 42 | elif provider_setting.setting_type == INTEGER_SETTING_TYPE: 43 | assert isinstance(provider_setting.value, int) 44 | elif provider_setting.setting_type == FLOAT_SETTING_TYPE: 45 | assert isinstance(provider_setting.value, float) 46 | elif provider_setting.setting_type == SECRET_SETTING_TYPE: 47 | assert isinstance(provider_setting.value, str) 48 | elif provider_setting.setting_type in (TEXT_SETTING_TYPE, URL_SETTING_TYPE): 49 | assert isinstance(provider_setting.value, str) 50 | 51 | 52 | @pytest.mark.unit 53 | def test_provider_setting_entity_encode_and_decode_secret(provider): 54 | secret_value = 'secret_value' 55 | encoded_secret_value = ProviderSettingEntity.encode_secret(secret_value) 56 | provider_setting = ProviderSettingEntity( 57 | provider=provider, 58 | setting_type=SECRET_SETTING_TYPE, 59 | key='secret_key', 60 | value=encoded_secret_value 61 | ) 62 | 63 | assert isinstance(provider_setting.value, str) 64 | assert provider_setting.value == secret_value 65 | 66 | 67 | @pytest.mark.unit 68 | def test_provider_setting_entity_representation(provider_setting): 69 | entity_str = ProviderSettingEntity.to_string(provider_setting) 70 | value = provider_setting.value 71 | if provider_setting.setting_type == SECRET_SETTING_TYPE: 72 | value = '*' * 10 73 | 74 | assert isinstance(entity_str, str) 75 | assert str(provider_setting.provider.name) in entity_str 76 | assert provider_setting.key in entity_str 77 | assert str(value) in entity_str 78 | -------------------------------------------------------------------------------- /tests/domain/test_routing.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from typing import Callable 4 | 5 | import pytest 6 | 7 | from src.domain.core.routing import Route 8 | from tests.fixtures import route, router 9 | 10 | 11 | @pytest.mark.unit 12 | def test_route_attrs(route): 13 | assert isinstance(route.http_verb, str) 14 | assert isinstance(route.path, str) 15 | assert isinstance(route.controller, Callable) 16 | assert isinstance(route.method, str) 17 | assert isinstance(route.name, str) 18 | 19 | 20 | @pytest.mark.unit 21 | def test_route_url_property(route): 22 | assert hasattr(route, 'url') 23 | assert route.path == route.url 24 | 25 | 26 | @pytest.mark.unit 27 | def test_route_mapping_property(route): 28 | assert hasattr(route, 'mapping') 29 | assert route.mapping == {route.http_verb: route.method} 30 | 31 | 32 | @pytest.mark.unit 33 | def test_route_invalid_http_verb(route): 34 | invalid_http_verb = 'invalid_http_verb' 35 | error_message = f'Invalid http verb {invalid_http_verb}' 36 | with pytest.raises(AssertionError) as err: 37 | Route( 38 | http_verb=invalid_http_verb, 39 | path=route.path, 40 | controller=route.controller, 41 | method=route.method, 42 | name=route.name 43 | ) 44 | assert error_message in str(err.value) 45 | 46 | 47 | @pytest.mark.unit 48 | def test_route_invalid_method(route): 49 | fake_method = 'fake_method' 50 | error_message = f'Invalid method {fake_method} for {route.controller}' 51 | with pytest.raises(AssertionError) as err: 52 | Route( 53 | http_verb=route.http_verb, 54 | path=route.path, 55 | controller=route.controller, 56 | method=fake_method, 57 | name=route.name 58 | ) 59 | assert error_message in str(err.value) 60 | 61 | 62 | @pytest.mark.unit 63 | def test_router_registry(route, router): 64 | assert isinstance(router.registry, dict) 65 | assert route.name in router.registry 66 | 67 | 68 | @pytest.mark.unit 69 | def test_router_invalid_duplicate_route_in_registry(route, router): 70 | error_message = f'{route.name} route already registered' 71 | assert route.name in router.registry 72 | with pytest.raises(AssertionError) as err: 73 | router.register(route) 74 | assert error_message in str(err.value) 75 | 76 | 77 | @pytest.mark.unit 78 | def test_router_get_route(route, router): 79 | invalid_route_name = 'fake_route' 80 | assert router.get_route(route.name) == route 81 | assert router.get_route(invalid_route_name) is None 82 | 83 | 84 | @pytest.mark.unit 85 | def test_router_get_url(route, router): 86 | invalid_route_name = 'fake_route' 87 | assert router.get_url(route.name) == route.url 88 | assert router.get_route(invalid_route_name) is None 89 | 90 | 91 | @pytest.mark.unit 92 | def test_router_get_urls(route, router): 93 | assert router.get_urls() == [route.url] 94 | 95 | 96 | @pytest.mark.unit 97 | def test_router_map(route, router): 98 | invalid_route_name = 'fake_route' 99 | assert router.map(route.name) == {route.http_verb: route.method} 100 | assert router.map(invalid_route_name) is None 101 | -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import datetime 4 | import random 5 | import string 6 | 7 | import pytest 8 | 9 | from src.domain.core.constants import HTTP_VERBS 10 | from src.domain.core.routing import Route, Router 11 | from src.domain.exchange_rate import ( 12 | CurrencyEntity, CurrencyExchangeRateEntity, 13 | CurrencyExchangeAmountEntity, TimeWeightedRateEntity) 14 | from src.domain.constants import ( 15 | BOOLEAN_SETTING_TYPE, FLOAT_SETTING_TYPE, INTEGER_SETTING_TYPE, 16 | SECRET_SETTING_TYPE, TEXT_SETTING_TYPE, URL_SETTING_TYPE) 17 | from src.domain.provider import ProviderEntity, ProviderSettingEntity 18 | from src.infrastructure.clients.provider.utils import get_drivers_names 19 | 20 | 21 | def generate_random_string(size: int) -> str: 22 | return ''.join([random.choice(string.ascii_letters) for _ in range(size)]) 23 | 24 | 25 | @pytest.fixture 26 | def currency() -> CurrencyEntity: 27 | currency_attrs = random.choice([ 28 | { 29 | 'code': 'EUR', 30 | 'name': 'Euro', 31 | 'symbol': '€' 32 | }, 33 | { 34 | 'code': 'USD', 35 | 'name': 'US Dollar', 36 | 'symbol': '$' 37 | } 38 | ]) 39 | return CurrencyEntity( 40 | code=currency_attrs.get('code'), 41 | name=currency_attrs.get('name'), 42 | symbol=currency_attrs.get('symbol') 43 | ) 44 | 45 | 46 | @pytest.fixture 47 | def exchange_rate(currency) -> CurrencyExchangeRateEntity: 48 | return CurrencyExchangeRateEntity( 49 | source_currency=currency.code, 50 | exchanged_currency='GBP', 51 | valuation_date=datetime.date.today().strftime('%Y-%m-%d'), 52 | rate_value=round(random.uniform(0.75, 1.5), 6) 53 | ) 54 | 55 | 56 | @pytest.fixture 57 | def exchange_amount(exchange_rate) -> CurrencyExchangeAmountEntity: 58 | return CurrencyExchangeAmountEntity( 59 | exchanged_currency=exchange_rate.exchanged_currency, 60 | exchanged_amount=round(random.uniform(10, 100), 2), 61 | rate_value=exchange_rate.rate_value 62 | ) 63 | 64 | 65 | @pytest.fixture 66 | def time_weighted_rate() -> TimeWeightedRateEntity: 67 | return TimeWeightedRateEntity( 68 | time_weighted_rate=round(random.uniform(0.75, 1.5), 6) 69 | ) 70 | 71 | 72 | @pytest.fixture 73 | def provider() -> ProviderEntity: 74 | name = generate_random_string(10) 75 | return ProviderEntity( 76 | name=name, 77 | driver=random.choice(get_drivers_names()), 78 | priority=random.randint(1, 9), 79 | enabled=random.choice([True, False]), 80 | settings=dict() 81 | ) 82 | 83 | 84 | @pytest.fixture 85 | def provider_setting(provider) -> ProviderSettingEntity: 86 | setting_type, value = random.choice([ 87 | (BOOLEAN_SETTING_TYPE, random.choice(['True', 'False'])), 88 | (INTEGER_SETTING_TYPE, random.randint(1, 99)), 89 | (FLOAT_SETTING_TYPE, random.uniform(1.00, 99.99)), 90 | (SECRET_SETTING_TYPE, ProviderSettingEntity.encode_secret('secret')), 91 | (TEXT_SETTING_TYPE, generate_random_string(10)), 92 | (URL_SETTING_TYPE, generate_random_string(10)), 93 | ]) 94 | return ProviderSettingEntity( 95 | provider=provider, 96 | setting_type=setting_type, 97 | key=generate_random_string(10), 98 | value=value, 99 | description=generate_random_string(25) 100 | ) 101 | 102 | 103 | @pytest.fixture 104 | def route() -> Route: 105 | 106 | class TestController: 107 | def test_method(self) -> str: 108 | return 'test_method' 109 | 110 | return Route( 111 | http_verb=random.choice(list(HTTP_VERBS)), 112 | path='/api/path/to/fake/endpoint/', 113 | controller=TestController, 114 | method='test_method', 115 | name='test_route', 116 | ) 117 | 118 | 119 | @pytest.fixture 120 | def router(route) -> Router: 121 | router = Router() 122 | router.register(route) 123 | return router 124 | -------------------------------------------------------------------------------- /tests/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/infrastructure/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/infrastructure/api/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/api/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/infrastructure/api/views/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/api/views/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/infrastructure/api/views/integration/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/api/views/unit/__init.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/infrastructure/api/views/unit/__init.py -------------------------------------------------------------------------------- /tests/infrastructure/api/views/unit/test_exchange_rate_views.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import datetime 4 | import random 5 | from http import HTTPStatus 6 | from unittest.mock import Mock 7 | 8 | from django.test.client import RequestFactory 9 | 10 | import pytest 11 | 12 | from src.infrastructure.api.views.exchange_rate import ( 13 | CurrencyViewSet, CurrencyExchangeRateViewSet) 14 | from tests.fixtures import currency, exchange_rate 15 | 16 | 17 | @pytest.mark.unit 18 | def test_currency_viewset_get(currency): 19 | viewset = CurrencyViewSet() 20 | viewset.viewset_factory = Mock() 21 | viewset.viewset_factory.create.return_value = Mock() 22 | viewset.viewset_factory.create.return_value.get.return_value = ( 23 | vars(currency), 24 | HTTPStatus.OK.value 25 | ) 26 | response = viewset.get(RequestFactory(), currency.code) 27 | assert hasattr(response, 'status_code') 28 | assert response.status_code == HTTPStatus.OK.value 29 | assert hasattr(response, 'data') 30 | assert isinstance(response.data, dict) 31 | 32 | 33 | @pytest.mark.unit 34 | def test_currency_viewset_list(currency): 35 | viewset = CurrencyViewSet() 36 | viewset.viewset_factory = Mock() 37 | viewset.viewset_factory.create.return_value = Mock() 38 | viewset.viewset_factory.create.return_value.list.return_value = ( 39 | [vars(currency) for _ in range(random.randint(1, 10))], 40 | HTTPStatus.OK.value 41 | ) 42 | response = viewset.list(RequestFactory(), currency.code) 43 | assert hasattr(response, 'status_code') 44 | assert response.status_code == HTTPStatus.OK.value 45 | assert hasattr(response, 'data') 46 | assert isinstance(response.data, list) 47 | 48 | 49 | @pytest.mark.unit 50 | def test_currency_exchange_rate_viewset_convert(exchange_rate): 51 | viewset = CurrencyExchangeRateViewSet() 52 | viewset.viewset_factory = Mock() 53 | viewset.viewset_factory.create.return_value = Mock() 54 | viewset.viewset_factory.create.return_value.convert.return_value = ( 55 | { 56 | 'exchanged_currency': exchange_rate.exchanged_currency, 57 | 'exchanged_amount': round(random.uniform(10, 100), 2), 58 | 'rate_value': round(random.uniform(0.5, 1.5), 6) 59 | }, 60 | HTTPStatus.OK.value 61 | ) 62 | request = RequestFactory() 63 | request.query_params = { 64 | 'source_currency': exchange_rate.source_currency, 65 | 'exchanged_currency': exchange_rate.exchanged_currency, 66 | 'amount': round(random.uniform(10, 100), 2) 67 | } 68 | response = viewset.convert(request) 69 | assert hasattr(response, 'status_code') 70 | assert response.status_code == HTTPStatus.OK.value 71 | assert hasattr(response, 'data') 72 | assert isinstance(response.data, dict) 73 | 74 | 75 | @pytest.mark.unit 76 | def test_currency_exchange_rate_viewset_list(exchange_rate): 77 | series_length = random.randint(1, 10) 78 | viewset = CurrencyExchangeRateViewSet() 79 | viewset.viewset_factory = Mock() 80 | viewset.viewset_factory.create.return_value = Mock() 81 | viewset.viewset_factory.create.return_value.list.return_value = ( 82 | [exchange_rate for _ in range(series_length)], 83 | HTTPStatus.OK.value 84 | ) 85 | request = RequestFactory() 86 | request.query_params = { 87 | 'source_currency': exchange_rate.source_currency, 88 | 'date_from': ( 89 | datetime.date.today() + datetime.timedelta(days=-series_length) 90 | ).strftime('%Y-%m-%d'), 91 | 'date_to': datetime.date.today().strftime('%Y-%m-%d'), 92 | } 93 | response = viewset.list(request) 94 | assert hasattr(response, 'status_code') 95 | assert response.status_code == HTTPStatus.OK.value 96 | assert hasattr(response, 'data') 97 | assert isinstance(response.data, list) 98 | 99 | 100 | @pytest.mark.unit 101 | def test_currency_exchange_rate_viewset_calculate_twr(exchange_rate): 102 | viewset = CurrencyExchangeRateViewSet() 103 | viewset.viewset_factory = Mock() 104 | viewset.viewset_factory.create.return_value = Mock() 105 | viewset.viewset_factory.create.return_value.calculate_twr.return_value = ( 106 | {'time_weighted_rate': round(random.uniform(0.5, 1.5), 6)}, 107 | HTTPStatus.OK.value 108 | ) 109 | request = RequestFactory() 110 | request.query_params = { 111 | 'source_currency': exchange_rate.source_currency, 112 | 'exchanged_currency': exchange_rate.exchanged_currency, 113 | 'date_from': ( 114 | datetime.date.today() + datetime.timedelta(days=-5) 115 | ).strftime('%Y-%m-%d'), 116 | 'date_to': datetime.date.today().strftime('%Y-%m-%d'), 117 | } 118 | response = viewset.calculate_twr(request) 119 | assert hasattr(response, 'status_code') 120 | assert response.status_code == HTTPStatus.OK.value 121 | assert hasattr(response, 'data') 122 | assert isinstance(response.data, dict) 123 | -------------------------------------------------------------------------------- /tests/infrastructure/orm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/infrastructure/orm/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/orm/cache/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/infrastructure/orm/cache/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/orm/cache/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/infrastructure/orm/cache/integration/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/orm/cache/integration/test_exchange_rate_repositories.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import random 4 | 5 | from django.core.cache import cache 6 | 7 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 8 | from src.infrastructure.orm.cache.exchange_rate.constants import ( 9 | CACHE_AVAILABLE_CURRENCIES_KEY) 10 | from src.infrastructure.orm.cache.exchange_rate.repositories import ( 11 | CurrencyCacheRepository, CurrencyExchangeRateCacheRepository) 12 | from tests.fixtures import currency, exchange_rate 13 | 14 | 15 | def test_currency_cache_repository_get(currency): 16 | cache.set(currency.code, currency) 17 | result = CurrencyCacheRepository().get(currency.code) 18 | assert isinstance(result, CurrencyEntity) 19 | assert result.code == currency.code 20 | assert result.name == currency.name 21 | assert result.symbol == currency.symbol 22 | assert CurrencyEntity.to_string(result) == CurrencyEntity.to_string(currency) 23 | 24 | 25 | def test_currency_cache_repository_get_availables(currency): 26 | num_of_currencies = random.randint(1, 10) 27 | currencies = [currency for _ in range(num_of_currencies)] 28 | cache.set(CACHE_AVAILABLE_CURRENCIES_KEY, currencies) 29 | result = CurrencyCacheRepository().get_availables() 30 | assert isinstance(result, list) 31 | assert len(result) == num_of_currencies 32 | assert all([isinstance(currency, CurrencyEntity) for currency in result]) 33 | 34 | 35 | def test_currency_cache_repository_save(currency): 36 | CurrencyCacheRepository().save(currency.code, currency) 37 | result = cache.get(currency.code) 38 | assert isinstance(result, CurrencyEntity) 39 | assert result.code == currency.code 40 | assert result.name == currency.name 41 | assert result.symbol == currency.symbol 42 | assert CurrencyEntity.to_string(result) == CurrencyEntity.to_string(currency) 43 | 44 | 45 | def test_currency_cache_repository_save_availables(currency): 46 | num_of_currencies = random.randint(1, 10) 47 | currencies = [currency for _ in range(num_of_currencies)] 48 | CurrencyCacheRepository().save_availables(currencies) 49 | result = cache.get(CACHE_AVAILABLE_CURRENCIES_KEY) 50 | assert isinstance(result, list) 51 | assert len(result) == num_of_currencies 52 | assert all([isinstance(currency, CurrencyEntity) for currency in result]) 53 | 54 | 55 | def test_currency_exchange_rate_cache_repository_get(exchange_rate): 56 | source_currency = exchange_rate.source_currency 57 | exchanged_currency = exchange_rate.exchanged_currency 58 | valuation_date = exchange_rate.valuation_date 59 | key = CurrencyExchangeRateCacheRepository.get_exchange_rate_key( 60 | source_currency, exchanged_currency, valuation_date) 61 | cache.set(key, exchange_rate) 62 | result = CurrencyExchangeRateCacheRepository().get( 63 | source_currency, exchanged_currency, valuation_date) 64 | assert isinstance(result, CurrencyExchangeRateEntity) 65 | assert result.source_currency == exchange_rate.source_currency 66 | assert result.exchanged_currency == exchange_rate.exchanged_currency 67 | assert result.valuation_date == exchange_rate.valuation_date 68 | assert result.rate_value == exchange_rate.rate_value 69 | assert CurrencyExchangeRateEntity.to_string( 70 | result) == CurrencyExchangeRateEntity.to_string(exchange_rate) 71 | 72 | 73 | def test_currency_exchange_rate_cache_repository_save(exchange_rate): 74 | source_currency = exchange_rate.source_currency 75 | exchanged_currency = exchange_rate.exchanged_currency 76 | valuation_date = exchange_rate.valuation_date 77 | key = CurrencyExchangeRateCacheRepository.get_exchange_rate_key( 78 | source_currency, exchanged_currency, valuation_date) 79 | CurrencyExchangeRateCacheRepository().save(exchange_rate) 80 | result = cache.get(key) 81 | assert isinstance(result, CurrencyExchangeRateEntity) 82 | assert result.source_currency == exchange_rate.source_currency 83 | assert result.exchanged_currency == exchange_rate.exchanged_currency 84 | assert result.valuation_date == exchange_rate.valuation_date 85 | assert result.rate_value == exchange_rate.rate_value 86 | assert CurrencyExchangeRateEntity.to_string( 87 | result) == CurrencyExchangeRateEntity.to_string(exchange_rate) 88 | -------------------------------------------------------------------------------- /tests/infrastructure/orm/cache/integration/test_provider_repositories.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import random 4 | 5 | from django.core.cache import cache 6 | 7 | from src.domain.provider import ProviderEntity 8 | from src.infrastructure.orm.cache.provider.constants import ( 9 | CACHE_PROVIDERS_BY_PRIORITY_KEY) 10 | from src.infrastructure.orm.cache.provider.repositories import ProviderCacheRepository 11 | from tests.fixtures import provider 12 | 13 | 14 | def test_provider_cache_repository_get_by_priority(provider): 15 | number_of_providers = random.randint(1, 5) 16 | providers = [provider for _ in range(number_of_providers)] 17 | cache.set(CACHE_PROVIDERS_BY_PRIORITY_KEY, providers) 18 | result = ProviderCacheRepository().get_by_priority() 19 | assert isinstance(result, list) 20 | assert len(result) == number_of_providers 21 | assert all([isinstance(provider, ProviderEntity) for provider in result]) 22 | 23 | 24 | def test_provider_cache_repository_save(provider): 25 | number_of_providers = random.randint(1, 5) 26 | providers = [provider for _ in range(number_of_providers)] 27 | ProviderCacheRepository().save(providers) 28 | result = cache.get(CACHE_PROVIDERS_BY_PRIORITY_KEY) 29 | assert isinstance(result, list) 30 | assert len(result) == number_of_providers 31 | assert all([isinstance(provider, ProviderEntity) for provider in result]) 32 | -------------------------------------------------------------------------------- /tests/infrastructure/orm/cache/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/infrastructure/orm/cache/unit/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/orm/cache/unit/test_exchange_rate_repositories.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import random 4 | from unittest.mock import patch 5 | 6 | from django.core.cache import cache 7 | 8 | import pytest 9 | 10 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 11 | from src.infrastructure.orm.cache.exchange_rate.repositories import ( 12 | CurrencyCacheRepository, CurrencyExchangeRateCacheRepository) 13 | from tests.fixtures import currency, exchange_rate 14 | 15 | 16 | @pytest.mark.unit 17 | @patch.object(cache, 'get') 18 | def test_currency_cache_repository_get(mock_get, currency): 19 | mock_get.return_value = currency 20 | result = CurrencyCacheRepository().get(currency.code) 21 | assert mock_get.called 22 | assert isinstance(result, CurrencyEntity) 23 | assert result.code == currency.code 24 | assert result.name == currency.name 25 | assert result.symbol == currency.symbol 26 | assert CurrencyEntity.to_string(result) == CurrencyEntity.to_string(currency) 27 | 28 | 29 | @pytest.mark.unit 30 | @patch.object(cache, 'get') 31 | def test_currency_cache_repository_get_availables(mock_get, currency): 32 | num_of_currencies = random.randint(1, 10) 33 | mock_get.return_value = [currency for _ in range(num_of_currencies)] 34 | result = CurrencyCacheRepository().get_availables() 35 | assert mock_get.called 36 | assert isinstance(result, list) 37 | assert all([isinstance(currency, CurrencyEntity) for currency in result]) 38 | 39 | 40 | @pytest.mark.unit 41 | @patch.object(cache, 'set') 42 | def test_currency_cache_repository_save(mock_set, currency): 43 | mock_set.return_value = None 44 | result = CurrencyCacheRepository().save(currency.code, currency) 45 | assert mock_set.called 46 | assert result is None 47 | 48 | 49 | @pytest.mark.unit 50 | @patch.object(cache, 'set') 51 | def test_currency_cache_repository_save_availables(mock_set, currency): 52 | mock_set.return_value = None 53 | num_of_currencies = random.randint(1, 10) 54 | currencies = [currency for _ in range(num_of_currencies)] 55 | result = CurrencyCacheRepository().save_availables(currencies) 56 | assert mock_set.called 57 | assert result is None 58 | 59 | 60 | @pytest.mark.unit 61 | @patch.object(cache, 'get') 62 | def test_currency_exchange_rate_cache_repository_get(mock_get, exchange_rate): 63 | mock_get.return_value = exchange_rate 64 | result = CurrencyExchangeRateCacheRepository().get( 65 | exchange_rate.source_currency, 66 | exchange_rate.exchanged_currency, 67 | exchange_rate.valuation_date 68 | ) 69 | assert mock_get.called 70 | assert isinstance(result, CurrencyExchangeRateEntity) 71 | assert result.source_currency == exchange_rate.source_currency 72 | assert result.exchanged_currency == exchange_rate.exchanged_currency 73 | assert result.valuation_date == exchange_rate.valuation_date 74 | assert result.rate_value == exchange_rate.rate_value 75 | assert CurrencyExchangeRateEntity.to_string( 76 | result) == CurrencyExchangeRateEntity.to_string(exchange_rate) 77 | 78 | 79 | @pytest.mark.unit 80 | @patch.object(cache, 'set') 81 | def test_currency_exchange_rate_cache_repository_save(mock_set, exchange_rate): 82 | mock_set.return_value = None 83 | result = CurrencyExchangeRateCacheRepository().save(exchange_rate) 84 | assert mock_set.called 85 | assert result is None 86 | -------------------------------------------------------------------------------- /tests/infrastructure/orm/cache/unit/test_provider_repositories.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import random 4 | from unittest.mock import patch 5 | 6 | from django.core.cache import cache 7 | 8 | import pytest 9 | 10 | from src.domain.provider import ProviderEntity 11 | from src.infrastructure.orm.cache.provider.repositories import ProviderCacheRepository 12 | from tests.fixtures import provider 13 | 14 | 15 | @pytest.mark.unit 16 | @patch.object(cache, 'get') 17 | def test_provider_cache_repository_get_by_priority(mock_get, provider): 18 | number_of_providers = random.randint(1, 5) 19 | mock_get.return_value = [provider for _ in range(number_of_providers)] 20 | result = ProviderCacheRepository().get_by_priority() 21 | assert mock_get.called 22 | assert isinstance(result, list) 23 | assert all([isinstance(provider, ProviderEntity) for provider in result]) 24 | 25 | 26 | @pytest.mark.unit 27 | @patch.object(cache, 'set') 28 | def test_provider_cache_repository_save(mock_set, provider): 29 | mock_set.return_value = None 30 | number_of_providers = random.randint(1, 5) 31 | providers = [provider for _ in range(number_of_providers)] 32 | result = ProviderCacheRepository().save(providers) 33 | assert mock_set.called 34 | assert result is None 35 | -------------------------------------------------------------------------------- /tests/infrastructure/orm/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/infrastructure/orm/db/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/orm/db/factories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/infrastructure/orm/db/factories/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/orm/db/factories/exchange_rate.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import datetime 4 | import string 5 | 6 | import factory 7 | from factory import django, fuzzy 8 | 9 | from src.infrastructure.orm.db.exchange_rate.models import ( 10 | Currency, CurrencyExchangeRate) 11 | 12 | 13 | class CurrencyFactory(django.DjangoModelFactory): 14 | 15 | class Meta: 16 | model = Currency 17 | 18 | code = fuzzy.FuzzyText(length=3, chars=string.ascii_uppercase) 19 | name = fuzzy.FuzzyText(length=15, chars=string.ascii_letters) 20 | symbol = fuzzy.FuzzyText(length=1) 21 | 22 | 23 | class CurrencyExchangeRateFactory(django.DjangoModelFactory): 24 | 25 | class Meta: 26 | model = CurrencyExchangeRate 27 | 28 | source_currency = factory.SubFactory(CurrencyFactory) 29 | exchanged_currency = factory.SubFactory(CurrencyFactory) 30 | valuation_date = fuzzy.FuzzyDate( 31 | datetime.date.today() + datetime.timedelta(days=-10), 32 | datetime.date.today()) 33 | rate_value = fuzzy.FuzzyDecimal(0.5, 1.5, 6) 34 | -------------------------------------------------------------------------------- /tests/infrastructure/orm/db/factories/provider.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import random 4 | import string 5 | 6 | import factory 7 | from factory import django, fuzzy 8 | 9 | from src.domain.constants import ( 10 | BOOLEAN_SETTING_TYPE, FLOAT_SETTING_TYPE, INTEGER_SETTING_TYPE, 11 | SECRET_SETTING_TYPE, TEXT_SETTING_TYPE, URL_SETTING_TYPE) 12 | from src.domain.provider import ProviderSettingEntity 13 | from src.infrastructure.clients.provider.utils import get_drivers_names 14 | from src.infrastructure.orm.db.provider.models import ( 15 | Provider, ProviderSetting) 16 | from tests.fixtures import generate_random_string 17 | 18 | 19 | setting_type, value = random.choice([ 20 | (BOOLEAN_SETTING_TYPE, random.choice(['True', 'False'])), 21 | (INTEGER_SETTING_TYPE, str(random.randint(1, 99))), 22 | (FLOAT_SETTING_TYPE, str(random.uniform(1.00, 99.99))), 23 | (SECRET_SETTING_TYPE, ProviderSettingEntity.encode_secret('secret')), 24 | (TEXT_SETTING_TYPE, generate_random_string(10)), 25 | (URL_SETTING_TYPE, generate_random_string(10)), 26 | ]) 27 | 28 | 29 | class ProviderFactory(django.DjangoModelFactory): 30 | 31 | class Meta: 32 | model = Provider 33 | 34 | name = fuzzy.FuzzyText(length=15, chars=string.ascii_letters) 35 | driver = fuzzy.FuzzyChoice(choices=get_drivers_names()) 36 | priority = fuzzy.FuzzyInteger(low=1, high=10) 37 | enabled = True 38 | 39 | 40 | class ProviderSettingFactory(django.DjangoModelFactory): 41 | 42 | class Meta: 43 | model = ProviderSetting 44 | 45 | provider = factory.SubFactory(ProviderFactory) 46 | setting_type = setting_type 47 | key = fuzzy.FuzzyText(length=15, chars=string.ascii_lowercase) 48 | value = value 49 | description = fuzzy.FuzzyText(length=50, chars=string.ascii_letters) 50 | -------------------------------------------------------------------------------- /tests/infrastructure/orm/db/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/infrastructure/orm/db/integration/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/orm/db/integration/test_exchange_rate_repositories.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import datetime 4 | import random 5 | 6 | import pytest 7 | 8 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 9 | from src.infrastructure.orm.db.exchange_rate.repositories import ( 10 | CurrencyDatabaseRepository, CurrencyExchangeRateDatabaseRepository) 11 | from src.interface.repositories.exceptions import EntityDoesNotExist 12 | from tests.fixtures import currency, exchange_rate 13 | from tests.infrastructure.orm.db.factories.exchange_rate import ( 14 | CurrencyFactory, CurrencyExchangeRateFactory) 15 | 16 | 17 | @pytest.mark.django_db 18 | def test_currency_db_repository_get(): 19 | currency = CurrencyFactory.create() 20 | result = CurrencyDatabaseRepository().get(currency.code) 21 | assert isinstance(result, CurrencyEntity) 22 | assert result.code == currency.code 23 | assert result.name == currency.name 24 | assert result.symbol == currency.symbol 25 | assert CurrencyEntity.to_string(result) == str(currency) 26 | 27 | 28 | @pytest.mark.django_db 29 | def test_currency_db_repository_get_entity_does_not_exist(currency): 30 | with pytest.raises(EntityDoesNotExist) as err: 31 | CurrencyDatabaseRepository().get(currency.code) 32 | assert f'{currency.code} currency code does not exist' in str(err.value) 33 | 34 | 35 | @pytest.mark.django_db 36 | def test_currency_db_repository_get_availables(): 37 | batch_number = random.randint(1, 10) 38 | currencies = CurrencyFactory.create_batch(batch_number) 39 | result = CurrencyDatabaseRepository().get_availables() 40 | assert isinstance(result, list) 41 | assert len(currencies) == batch_number 42 | assert all([isinstance(currency, CurrencyEntity) for currency in result]) 43 | 44 | 45 | @pytest.mark.django_db 46 | def test_exchange_rate_db_repository_get(): 47 | exchange_rate = CurrencyExchangeRateFactory.create() 48 | result = CurrencyExchangeRateDatabaseRepository().get( 49 | source_currency=exchange_rate.source_currency, 50 | exchanged_currency= exchange_rate.exchanged_currency, 51 | valuation_date=exchange_rate.valuation_date 52 | ) 53 | assert isinstance(result, CurrencyExchangeRateEntity) 54 | assert result.source_currency == exchange_rate.source_currency.code 55 | assert result.exchanged_currency == exchange_rate.exchanged_currency.code 56 | assert result.valuation_date == exchange_rate.valuation_date.strftime('%Y-%m-%d') 57 | assert result.rate_value == float(exchange_rate.rate_value) 58 | assert CurrencyExchangeRateEntity.to_string(result) == str(exchange_rate) 59 | 60 | 61 | @pytest.mark.django_db 62 | def test_exchange_rate_db_repository_get_entity_does_not_exist(exchange_rate): 63 | error_message = ( 64 | f'Exchange rate {exchange_rate.source_currency}/{exchange_rate.exchanged_currency} ' 65 | f'for {exchange_rate.valuation_date} does not exist' 66 | ) 67 | with pytest.raises(EntityDoesNotExist) as err: 68 | CurrencyExchangeRateDatabaseRepository().get( 69 | source_currency=exchange_rate.source_currency, 70 | exchanged_currency= exchange_rate.exchanged_currency, 71 | valuation_date=exchange_rate.valuation_date 72 | ) 73 | assert error_message in str(err.value) 74 | 75 | 76 | @pytest.mark.django_db 77 | def test_exchange_rate_db_repository_get_rate_series(): 78 | batch_number = random.randint(1, 10) 79 | currencies = CurrencyFactory.create_batch(2) 80 | CurrencyExchangeRateFactory.create_batch( 81 | batch_number, 82 | source_currency=currencies[0], 83 | exchanged_currency=currencies[1] 84 | ) 85 | result = CurrencyExchangeRateDatabaseRepository().get_rate_series( 86 | source_currency=currencies[0], 87 | exchanged_currency= currencies[1], 88 | date_from=datetime.date.today() + datetime.timedelta(days=-batch_number), 89 | date_to=datetime.date.today() 90 | ) 91 | assert isinstance(result, list) 92 | assert all([isinstance(rate_value, float) for rate_value in result]) 93 | 94 | 95 | @pytest.mark.django_db 96 | def test_exchange_rate_db_repository_get_time_series(): 97 | batch_number = random.randint(1, 10) 98 | currencies = CurrencyFactory.create_batch(2) 99 | CurrencyExchangeRateFactory.create_batch( 100 | batch_number, 101 | source_currency=currencies[0], 102 | exchanged_currency=currencies[1] 103 | ) 104 | result = CurrencyExchangeRateDatabaseRepository().get_time_series( 105 | source_currency=currencies[0], 106 | exchanged_currency= currencies[1].code, 107 | date_from=datetime.date.today() + datetime.timedelta(days=-batch_number), 108 | date_to=datetime.date.today() 109 | ) 110 | assert isinstance(result, list) 111 | assert all([isinstance(exchange_rate, CurrencyExchangeRateEntity) 112 | for exchange_rate in result]) 113 | -------------------------------------------------------------------------------- /tests/infrastructure/orm/db/integration/test_exchange_rate_tasks.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import dataclasses 4 | import json 5 | import random 6 | 7 | import pytest 8 | 9 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 10 | from src.infrastructure.orm.db.exchange_rate.models import ( 11 | Currency, CurrencyExchangeRate) 12 | from src.infrastructure.orm.db.exchange_rate.tasks import ( 13 | bulk_save_currencies, bulk_save_exchange_rates, save_currency, save_exchange_rate) 14 | from tests.fixtures import currency, exchange_rate 15 | from tests.infrastructure.orm.db.factories.exchange_rate import ( 16 | CurrencyFactory, CurrencyExchangeRateFactory) 17 | 18 | 19 | @pytest.mark.django_db 20 | def test_save_currency_task(currency): 21 | currency_json = json.dumps(dataclasses.asdict(currency)) 22 | result = save_currency(currency_json) 23 | assert result is None 24 | assert Currency.objects.count() == 1 25 | 26 | 27 | @pytest.mark.django_db 28 | def test_save_exchange_rate_task(exchange_rate): 29 | exchange_rate_json = json.dumps(dataclasses.asdict(exchange_rate)) 30 | result = save_exchange_rate(exchange_rate_json) 31 | assert result is None 32 | assert CurrencyExchangeRate.objects.count() == 1 33 | 34 | 35 | @pytest.mark.django_db 36 | def test_bulk_save_currencies_task(): 37 | num_of_currencies = random.randint(1, 10) 38 | currencies = [ 39 | CurrencyEntity(**{ 40 | field: value for field, value in currency.__dict__.items() \ 41 | if not field.startswith('_') 42 | }) 43 | for currency in CurrencyFactory.build_batch(num_of_currencies) 44 | ] 45 | currencies_json = json.dumps(list(map(dataclasses.asdict, currencies))) 46 | result = bulk_save_currencies(currencies_json) 47 | assert result is None 48 | assert Currency.objects.count() == num_of_currencies 49 | 50 | 51 | @pytest.mark.django_db 52 | def test_bulk_save_exchange_rates_task(): 53 | num_of_rates = random.randint(1, 10) 54 | exchange_rates = [ 55 | CurrencyExchangeRateEntity(**{ 56 | field.replace('_id', ''): value for field, value in exchange_rate.__dict__.items() \ 57 | if field != 'id' and not field.startswith('_') 58 | }) 59 | for exchange_rate in CurrencyExchangeRateFactory.build_batch(num_of_rates) 60 | ] 61 | exchange_rates_json = json.dumps( 62 | list(map(dataclasses.asdict, exchange_rates))) 63 | result = bulk_save_exchange_rates(exchange_rates_json) 64 | assert result is None 65 | assert CurrencyExchangeRate.objects.count() == num_of_rates 66 | -------------------------------------------------------------------------------- /tests/infrastructure/orm/db/integration/test_provider_repositories.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import random 4 | 5 | import pytest 6 | 7 | from src.domain.provider import ProviderEntity, ProviderSettingEntity 8 | from src.infrastructure.orm.db.provider.repositories import ProviderDatabaseRepository 9 | from tests.infrastructure.orm.db.factories.provider import ( 10 | ProviderFactory, ProviderSettingFactory) 11 | 12 | 13 | @pytest.mark.django_db 14 | def test_provider_db_repository_get_by_priority(): 15 | batch_size = random.randint(1, 5) 16 | provider = ProviderFactory.create() 17 | provider_settings = ProviderSettingFactory.create_batch( 18 | batch_size, provider=provider) 19 | result = ProviderDatabaseRepository().get_by_priority() 20 | assert isinstance(result, list) 21 | assert all([isinstance(provider, ProviderEntity) for provider in result]) 22 | assert all([isinstance(setting, ProviderSettingEntity) 23 | for _, setting in result[0].settings.items()]) 24 | assert len(provider_settings) == len(result[0].settings) 25 | assert ProviderEntity.to_string(result[0]) == str(provider) 26 | -------------------------------------------------------------------------------- /tests/infrastructure/orm/db/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/infrastructure/orm/db/unit/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/orm/db/unit/test_exchange_rate_models.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import pytest 4 | 5 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 6 | from src.infrastructure.orm.db.exchange_rate.models import ( 7 | Currency, CurrencyExchangeRate) 8 | from tests.fixtures import currency, exchange_rate 9 | 10 | 11 | def create_currency_model(currency): 12 | return Currency( 13 | code=currency.code, 14 | name=currency.name, 15 | symbol=currency.symbol 16 | ) 17 | 18 | 19 | def create_exchange_rate_model(currency, exchange_rate): 20 | return CurrencyExchangeRate( 21 | source_currency=create_currency_model(currency), 22 | exchanged_currency=create_currency_model(currency), 23 | valuation_date=exchange_rate.valuation_date, 24 | rate_value=exchange_rate.rate_value 25 | ) 26 | 27 | 28 | @pytest.mark.unit 29 | def test_currency_attrs(currency): 30 | model = create_currency_model(currency) 31 | assert isinstance(model, Currency) 32 | assert isinstance(model.code, str) 33 | assert isinstance(model.name, str) 34 | assert isinstance(model.symbol, str) 35 | 36 | 37 | @pytest.mark.unit 38 | def test_currency_representation(currency): 39 | model = create_currency_model(currency) 40 | assert str(model) == CurrencyEntity.to_string(currency) 41 | 42 | 43 | @pytest.mark.unit 44 | def test_currency_exchange_rate_attrs(currency, exchange_rate): 45 | model = create_exchange_rate_model(currency, exchange_rate) 46 | assert isinstance(model, CurrencyExchangeRate) 47 | assert isinstance(model.source_currency, Currency) 48 | assert isinstance(model.exchanged_currency, Currency) 49 | assert isinstance(model.valuation_date, str) 50 | assert isinstance(model.rate_value, float) 51 | 52 | 53 | @pytest.mark.unit 54 | def test_currency_exchange_rate_entity_representation(currency, exchange_rate): 55 | model = create_exchange_rate_model(currency, exchange_rate) 56 | entity_str = CurrencyExchangeRateEntity.to_string(exchange_rate) 57 | assert model.source_currency.code in entity_str 58 | assert model.valuation_date in entity_str 59 | assert str(model.rate_value) in entity_str 60 | -------------------------------------------------------------------------------- /tests/infrastructure/orm/db/unit/test_exchange_rate_tasks.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import dataclasses 4 | import json 5 | import random 6 | from unittest.mock import patch 7 | 8 | import pytest 9 | 10 | from src.infrastructure.orm.db.exchange_rate.models import ( 11 | Currency, CurrencyExchangeRate) 12 | from src.infrastructure.orm.db.exchange_rate.tasks import ( 13 | bulk_save_currencies, bulk_save_exchange_rates, save_currency, save_exchange_rate) 14 | from tests.fixtures import currency, exchange_rate 15 | from tests.infrastructure.orm.db.factories.exchange_rate import CurrencyFactory 16 | 17 | 18 | @pytest.mark.unit 19 | @patch.object(Currency, 'objects') 20 | def test_save_currency_task(mock_objects, currency): 21 | mock_create = mock_objects.create 22 | mock_create.return_value = None 23 | currency_json = json.dumps(dataclasses.asdict(currency)) 24 | result = save_currency(currency_json) 25 | assert result is None 26 | assert mock_create.called 27 | 28 | 29 | @pytest.mark.unit 30 | @patch.object(CurrencyExchangeRate, 'objects') 31 | @patch('src.infrastructure.orm.db.exchange_rate.tasks.get_currency') 32 | def test_save_exchange_rate_task(mock_get_currency, mock_objects, exchange_rate): 33 | mock_create = mock_objects.create 34 | mock_create.return_value = None 35 | mock_get_currency.return_value = CurrencyFactory.build() 36 | exchange_rate_json = json.dumps(dataclasses.asdict(exchange_rate)) 37 | result = save_exchange_rate(exchange_rate_json) 38 | assert result is None 39 | assert mock_create.called 40 | 41 | 42 | @pytest.mark.unit 43 | @patch.object(Currency, 'objects') 44 | def test_bulk_save_currencies_task(mock_objects, currency): 45 | mock_bulk_create = mock_objects.bulk_create 46 | mock_bulk_create.return_value = None 47 | currencies = [currency for _ in range(random.randint(1, 10))] 48 | currencies_json = json.dumps(list(map(dataclasses.asdict, currencies))) 49 | result = bulk_save_currencies(currencies_json) 50 | assert result is None 51 | assert mock_bulk_create.called 52 | 53 | 54 | @pytest.mark.unit 55 | @patch.object(CurrencyExchangeRate, 'objects') 56 | @patch('src.infrastructure.orm.db.exchange_rate.tasks.get_currency') 57 | def test_bulk_save_exchange_rates_task(mock_get_currency, mock_objects, exchange_rate): 58 | mock_bulk_create = mock_objects.bulk_create 59 | mock_bulk_create.return_value = None 60 | mock_get_currency.return_value = CurrencyFactory.build() 61 | exchange_rates = [exchange_rate for _ in range(random.randint(1, 10))] 62 | exchange_rate_json = json.dumps(list(map(dataclasses.asdict, exchange_rates))) 63 | result = bulk_save_exchange_rates(exchange_rate_json) 64 | assert result is None 65 | assert mock_bulk_create.called 66 | -------------------------------------------------------------------------------- /tests/infrastructure/orm/db/unit/test_provider_models.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import pytest 4 | 5 | from src.domain.constants import ( 6 | BOOLEAN_SETTING_TYPE, FLOAT_SETTING_TYPE, INTEGER_SETTING_TYPE, 7 | SECRET_SETTING_TYPE, TEXT_SETTING_TYPE, URL_SETTING_TYPE) 8 | from src.domain.provider import ProviderEntity, ProviderSettingEntity 9 | from src.infrastructure.orm.db.provider.models import ( 10 | Provider, ProviderSetting) 11 | from tests.fixtures import provider, provider_setting 12 | 13 | 14 | def create_provider_model(provider): 15 | return Provider( 16 | name=provider.name, 17 | driver=provider.driver, 18 | priority=provider.priority, 19 | enabled=provider.enabled 20 | ) 21 | 22 | 23 | def create_provider_settings_model(provider, provider_setting): 24 | return ProviderSetting( 25 | provider=create_provider_model(provider), 26 | setting_type=provider_setting.setting_type, 27 | key=provider_setting.key, 28 | value=provider_setting.value, 29 | description=provider_setting.description 30 | ) 31 | 32 | 33 | @pytest.mark.unit 34 | def test_provider_attrs(provider): 35 | model = create_provider_model(provider) 36 | assert isinstance(model, Provider) 37 | assert isinstance(model.name, str) 38 | assert isinstance(model.driver, str) 39 | assert isinstance(model.priority, int) 40 | assert isinstance(model.enabled, bool) 41 | 42 | 43 | @pytest.mark.unit 44 | def test_provider_representation(provider): 45 | model = create_provider_model(provider) 46 | assert str(model) == ProviderEntity.to_string(provider) 47 | 48 | 49 | @pytest.mark.unit 50 | def test_provider_setting_attrs(provider, provider_setting): 51 | model = create_provider_settings_model(provider, provider_setting) 52 | assert isinstance(model, ProviderSetting) 53 | assert isinstance(model.provider, Provider) 54 | assert isinstance(model.setting_type, str) 55 | assert isinstance(model.key, str) 56 | assert isinstance(model.description, str) 57 | 58 | if model.setting_type == BOOLEAN_SETTING_TYPE: 59 | assert isinstance(model.value, bool) 60 | elif model.setting_type == INTEGER_SETTING_TYPE: 61 | assert isinstance(model.value, int) 62 | elif model.setting_type == FLOAT_SETTING_TYPE: 63 | assert isinstance(model.value, float) 64 | elif model.setting_type == SECRET_SETTING_TYPE: 65 | assert isinstance(model.value, str) 66 | elif model.setting_type in (TEXT_SETTING_TYPE, URL_SETTING_TYPE): 67 | assert isinstance(model.value, str) 68 | 69 | 70 | @pytest.mark.unit 71 | def test_provider_setting_representation(provider, provider_setting): 72 | model = create_provider_settings_model(provider, provider_setting) 73 | assert str(model) == ProviderSettingEntity.to_string(provider_setting) 74 | -------------------------------------------------------------------------------- /tests/infrastructure/orm/db/unit/test_provider_repositories.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | 7 | from src.domain.provider import ProviderEntity 8 | from src.infrastructure.orm.db.provider.models import Provider, ProviderSetting 9 | from src.infrastructure.orm.db.provider.repositories import ProviderDatabaseRepository 10 | from tests.infrastructure.orm.db.factories.provider import ( 11 | ProviderFactory, ProviderSettingFactory) 12 | 13 | 14 | @pytest.mark.unit 15 | @patch.object(Provider, 'objects') 16 | @patch.object(ProviderSetting, 'objects') 17 | def test_provider_db_repository_get_by_priority(mock_setting_objects, 18 | mock_provider_objects): 19 | provider = ProviderFactory.build() 20 | provider_settings = ProviderSettingFactory.build_batch(2, provider=provider) 21 | mock_provider_filter = mock_provider_objects.filter 22 | mock_provider_filter.return_value = [provider] 23 | mock_settings_all = mock_setting_objects.all 24 | mock_settings_all.return_value = provider_settings 25 | result = ProviderDatabaseRepository().get_by_priority() 26 | assert isinstance(result, list) 27 | assert all([isinstance(provider, ProviderEntity) for provider in result]) 28 | -------------------------------------------------------------------------------- /tests/interface/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/interface/__init__.py -------------------------------------------------------------------------------- /tests/interface/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/interface/controllers/__init__.py -------------------------------------------------------------------------------- /tests/interface/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/interface/repositories/__init__.py -------------------------------------------------------------------------------- /tests/interface/repositories/test_provider_repositories.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | 7 | from src.domain.provider import ProviderEntity 8 | from src.interface.repositories.provider import ProviderRepository 9 | from tests.fixtures import provider 10 | 11 | 12 | @pytest.mark.unit 13 | def test_provider_repository_database_get_by_priority(provider): 14 | db_repo = Mock() 15 | db_repo.get_by_priority.return_value = [provider] 16 | cache_repo = Mock() 17 | cache_repo.get_by_priority.return_value = None 18 | cache_repo.save.return_value = None 19 | provider_repo = ProviderRepository(db_repo, cache_repo) 20 | result = provider_repo.get_by_priority() 21 | result_first = result[0] 22 | assert cache_repo.get_by_priority.called 23 | assert cache_repo.save.called 24 | assert db_repo.get_by_priority.called 25 | assert isinstance(result, list) 26 | assert isinstance(result_first, ProviderEntity) 27 | assert result_first.name == provider.name 28 | assert result_first.driver == provider.driver 29 | assert result_first.priority == provider.priority 30 | assert result_first.enabled == provider.enabled 31 | assert ProviderEntity.to_string( 32 | result_first) == ProviderEntity.to_string(provider) 33 | -------------------------------------------------------------------------------- /tests/interface/serializers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/interface/serializers/__init__.py -------------------------------------------------------------------------------- /tests/interface/serializers/test_exchange_rate_serializers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import datetime 4 | import random 5 | 6 | import pytest 7 | 8 | from src.domain.exchange_rate import ( 9 | CurrencyExchangeAmountEntity, TimeWeightedRateEntity) 10 | from src.interface.serializers.exchange_rate import ( 11 | CurrencySerializer, CurrencyExchangeRateConvertSerializer, 12 | CurrencyExchangeRateAmountSerializer, CurrencyExchangeRateListSerializer, 13 | CurrencyExchangeRateSerializer, TimeWeightedRateListSerializer, 14 | TimeWeightedRateSerializer) 15 | from tests.fixtures import currency, exchange_rate 16 | 17 | 18 | @pytest.mark.unit 19 | def test_currency_serializer(currency): 20 | valid_data = CurrencySerializer().dump(currency) 21 | assert valid_data['code'] == currency.code 22 | assert valid_data['name'] == currency.name 23 | assert valid_data['symbol'] == currency.symbol 24 | 25 | 26 | @pytest.mark.unit 27 | def test_currency_exchange_rate_convert_serializer(exchange_rate): 28 | data = { 29 | 'source_currency': exchange_rate.source_currency, 30 | 'exchanged_currency': exchange_rate.exchanged_currency, 31 | 'amount': round(random.uniform(1, 100), 2) 32 | } 33 | valid_data = CurrencyExchangeRateConvertSerializer().load(data) 34 | assert valid_data['source_currency'] == data['source_currency'] 35 | assert valid_data['exchanged_currency'] == data['exchanged_currency'] 36 | assert valid_data['amount'] == data['amount'] 37 | 38 | 39 | @pytest.mark.unit 40 | def test_currency_exchange_rate_convert_serializer_validation_error(exchange_rate): 41 | data = { 42 | 'source_currency': exchange_rate, 43 | 'exchanged_currency': exchange_rate, 44 | 'amount': 'amount' 45 | } 46 | invalid_data = CurrencyExchangeRateConvertSerializer().load(data) 47 | assert 'errors' in invalid_data 48 | assert all([key in invalid_data['errors'].keys() for key in data.keys()]) 49 | 50 | 51 | @pytest.mark.unit 52 | def test_currency_exchange_rate_amount_serializer(exchange_rate): 53 | data = CurrencyExchangeAmountEntity( 54 | exchanged_currency=exchange_rate.exchanged_currency, 55 | exchanged_amount=round(random.uniform(1, 100), 2), 56 | rate_value=exchange_rate.rate_value 57 | ) 58 | valid_data = CurrencyExchangeRateAmountSerializer().dump(data) 59 | assert valid_data['exchanged_currency'] == data.exchanged_currency 60 | assert valid_data['exchanged_amount'] == data.exchanged_amount 61 | assert valid_data['rate_value'] == data.rate_value 62 | 63 | 64 | @pytest.mark.unit 65 | def test_currency_exchange_rate_list_serializer(exchange_rate): 66 | exchanged_currency = exchange_rate.exchanged_currency * random.randint(1, 5) 67 | date_from = datetime.date.today() + datetime.timedelta(days=-5) 68 | date_to = datetime.date.today() 69 | data = { 70 | 'source_currency': exchange_rate.source_currency, 71 | 'exchanged_currency': exchanged_currency, 72 | 'date_from': date_from.strftime('%Y-%m-%d'), 73 | 'date_to': date_to.strftime('%Y-%m-%d') 74 | } 75 | valid_data = CurrencyExchangeRateListSerializer().load(data) 76 | assert valid_data['source_currency'] == data['source_currency'] 77 | assert valid_data['date_from'] == date_from 78 | assert valid_data['date_to'] == date_to 79 | 80 | 81 | @pytest.mark.unit 82 | def test_currency_exchange_rate_list_serializer_validation_error(exchange_rate): 83 | data = { 84 | 'source_currency': exchange_rate, 85 | 'date_from': datetime.date.today() + datetime.timedelta(days=-5), 86 | 'date_to': datetime.date.today() 87 | } 88 | invalid_data = CurrencyExchangeRateListSerializer().load(data) 89 | assert 'errors' in invalid_data 90 | assert all([key in invalid_data['errors'].keys() for key in data.keys()]) 91 | 92 | 93 | @pytest.mark.unit 94 | def test_currency_exchange_rate_serializer(exchange_rate): 95 | data = exchange_rate 96 | valid_data = CurrencyExchangeRateSerializer().dump(data) 97 | assert valid_data['exchanged_currency'] == data.exchanged_currency 98 | assert valid_data['valuation_date'] == data.valuation_date 99 | assert valid_data['rate_value'] == data.rate_value 100 | 101 | 102 | @pytest.mark.unit 103 | def test_time_weighted_rate_list_serializer(exchange_rate): 104 | date_from = datetime.date.today() + datetime.timedelta(days=-5) 105 | date_to = datetime.date.today() 106 | data = { 107 | 'source_currency': exchange_rate.source_currency, 108 | 'exchanged_currency': exchange_rate.exchanged_currency, 109 | 'date_from': date_from.strftime('%Y-%m-%d'), 110 | 'date_to': date_to.strftime('%Y-%m-%d') 111 | } 112 | valid_data = TimeWeightedRateListSerializer().load(data) 113 | assert valid_data['source_currency'] == data['source_currency'] 114 | assert valid_data['exchanged_currency'] == data['exchanged_currency'] 115 | assert valid_data['date_from'] == date_from 116 | assert valid_data['date_to'] == date_to 117 | 118 | 119 | @pytest.mark.unit 120 | def test_time_weighted_rate_list_serializer_validation_error(exchange_rate): 121 | data = { 122 | 'source_currency': exchange_rate, 123 | 'exchanged_currence': exchange_rate, 124 | 'date_from': datetime.date.today(), 125 | 'date_to': datetime.date.today() 126 | } 127 | invalid_data = TimeWeightedRateListSerializer().load(data) 128 | assert 'errors' in invalid_data 129 | assert all([key in invalid_data['errors'].keys() for key in data.keys()]) 130 | 131 | 132 | @pytest.mark.unit 133 | def test_time_weighted_rate_serializer(): 134 | data = TimeWeightedRateEntity( 135 | time_weighted_rate=random.uniform(0.5, 1.5) 136 | ) 137 | valid_data = TimeWeightedRateSerializer().dump(data) 138 | assert valid_data['time_weighted_rate'] == data.time_weighted_rate 139 | -------------------------------------------------------------------------------- /tests/usecases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdediego/django-clean-architecture/915a8d844a8db5a40c726fe4cf9f6d50f7c95275/tests/usecases/__init__.py -------------------------------------------------------------------------------- /tests/usecases/test_exchange_rate_interactors.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import datetime 4 | import random 5 | from unittest.mock import Mock 6 | from django.db import reset_queries 7 | 8 | import pytest 9 | 10 | from src.domain.exchange_rate import CurrencyEntity, CurrencyExchangeRateEntity 11 | from src.usecases.exchange_rate import CurrencyInteractor, CurrencyExchangeRateInteractor 12 | from tests.fixtures import currency, exchange_rate 13 | 14 | 15 | @pytest.mark.unit 16 | def test_currency_interactor_get(currency): 17 | currency_repo = Mock() 18 | currency_repo.get.return_value = currency 19 | currency_interactor = CurrencyInteractor(currency_repo) 20 | result = currency_interactor.get(currency.code) 21 | assert currency_repo.get.called 22 | assert result.code == currency.code 23 | assert result.name == currency.name 24 | assert result.symbol == currency.symbol 25 | assert CurrencyEntity.to_string(result) == CurrencyEntity.to_string(currency) 26 | 27 | 28 | @pytest.mark.unit 29 | def test_currency_interactor_get_availables(currency): 30 | num_of_currencies = random.randint(1, 10) 31 | currencies_available = [currency for _ in range(num_of_currencies)] 32 | currency_repo = Mock() 33 | currency_repo.get_availables.return_value = currencies_available 34 | currency_interactor = CurrencyInteractor(currency_repo) 35 | result = currency_interactor.get_availables() 36 | assert currency_repo.get_availables.called 37 | assert isinstance(result, list) 38 | assert len(result) == num_of_currencies 39 | assert all([isinstance(currency, CurrencyEntity) for currency in result]) 40 | 41 | 42 | @pytest.mark.unit 43 | def test_currency_interactor_save(currency): 44 | currency_repo = Mock() 45 | currency_repo.save.return_value = None 46 | currency_interactor = CurrencyInteractor(currency_repo) 47 | result = currency_interactor.save(currency) 48 | assert currency_repo.save.called 49 | assert result is None 50 | 51 | 52 | @pytest.mark.unit 53 | def test_currency_interactor_bulk_save(currency): 54 | currencies = [currency for _ in range(random.randint(1, 10))] 55 | currency_repo = Mock() 56 | currency_repo.bulk_save.return_value = None 57 | currency_interactor = CurrencyInteractor(currency_repo) 58 | result = currency_interactor.bulk_save(currencies) 59 | assert currency_repo.bulk_save.called 60 | assert result is None 61 | 62 | 63 | @pytest.mark.unit 64 | def test_currency_exchange_rate_interactor_get(exchange_rate): 65 | exchange_rate_repo = Mock() 66 | exchange_rate_repo.get.return_value = exchange_rate 67 | exchange_rate_interactor = CurrencyExchangeRateInteractor(exchange_rate_repo) 68 | filter = { 69 | 'source_currency': exchange_rate.source_currency, 70 | 'exchanged_currency': exchange_rate.exchanged_currency, 71 | 'valuation_date': exchange_rate.valuation_date 72 | } 73 | result = exchange_rate_interactor.get(**filter) 74 | assert exchange_rate_repo.get.called 75 | assert result.source_currency == exchange_rate.source_currency 76 | assert result.exchanged_currency == exchange_rate.exchanged_currency 77 | assert result.valuation_date == exchange_rate.valuation_date 78 | assert result.rate_value == exchange_rate.rate_value 79 | assert CurrencyExchangeRateEntity.to_string( 80 | result) == CurrencyExchangeRateEntity.to_string(exchange_rate) 81 | 82 | 83 | @pytest.mark.unit 84 | def test_currency_exchange_rate_interactor_get_latest(exchange_rate): 85 | exchange_rate_repo = Mock() 86 | exchange_rate_repo.get.return_value = exchange_rate 87 | exchange_rate_interactor = CurrencyExchangeRateInteractor(exchange_rate_repo) 88 | filter = { 89 | 'source_currency': exchange_rate.source_currency, 90 | 'exchanged_currency': exchange_rate.exchanged_currency 91 | } 92 | result = exchange_rate_interactor.get_latest(**filter) 93 | assert exchange_rate_repo.get.called 94 | assert result.source_currency == exchange_rate.source_currency 95 | assert result.exchanged_currency == exchange_rate.exchanged_currency 96 | assert result.valuation_date == datetime.date.today().strftime('%Y-%m-%d') 97 | assert result.rate_value == exchange_rate.rate_value 98 | assert CurrencyExchangeRateEntity.to_string( 99 | result) == CurrencyExchangeRateEntity.to_string(exchange_rate) 100 | 101 | 102 | @pytest.mark.unit 103 | def test_currency_exchange_rate_interactor_get_rate_series(exchange_rate): 104 | num_of_rates = random.randint(1, 10) 105 | rate_series = [round(random.uniform(0.8, 1.2), 6) for _ in range(num_of_rates)] 106 | exchange_rate_repo = Mock() 107 | exchange_rate_repo.get_rate_series.return_value = rate_series 108 | exchange_rate_interactor = CurrencyExchangeRateInteractor(exchange_rate_repo) 109 | filter = { 110 | 'source_currency': exchange_rate.source_currency, 111 | 'exchanged_currency': exchange_rate.exchanged_currency, 112 | 'date_from': datetime.date.today() + datetime.timedelta(days=-num_of_rates), 113 | 'date_to': datetime.date.today() 114 | } 115 | result = exchange_rate_interactor.get_rate_series(**filter) 116 | assert exchange_rate_repo.get_rate_series.called 117 | assert isinstance(result, list) 118 | assert len(result) == num_of_rates 119 | assert all([isinstance(rate, float) for rate in result]) 120 | 121 | 122 | @pytest.mark.unit 123 | def test_currency_exchange_rate_interactor_get_time_series(exchange_rate): 124 | series_length = random.randint(1, 10) 125 | time_series = [exchange_rate for _ in range(series_length)] 126 | exchange_rate_repo = Mock() 127 | exchange_rate_repo.get_time_series.return_value = time_series 128 | exchange_rate_interactor = CurrencyExchangeRateInteractor(exchange_rate_repo) 129 | filter = { 130 | 'source_currency': exchange_rate.source_currency, 131 | 'exchanged_currency': exchange_rate.exchanged_currency, 132 | 'date_from': datetime.date.today() + datetime.timedelta(days=-series_length), 133 | 'date_to': datetime.date.today() 134 | } 135 | result = exchange_rate_interactor.get_time_series(**filter) 136 | assert exchange_rate_repo.get_time_series.called 137 | assert isinstance(result, list) 138 | assert len(result) == series_length 139 | assert all([isinstance(cer, CurrencyExchangeRateEntity) for cer in result]) 140 | 141 | 142 | @pytest.mark.unit 143 | def test_currency_exchange_rate_interactor_save(exchange_rate): 144 | exchange_rate_repo = Mock() 145 | exchange_rate_repo.save.return_value = None 146 | exchange_rate_interactor = CurrencyExchangeRateInteractor(exchange_rate_repo) 147 | result = exchange_rate_interactor.save(exchange_rate) 148 | assert exchange_rate_repo.save.called 149 | assert result is None 150 | 151 | 152 | @pytest.mark.unit 153 | def test_currency_exchange_rate_interactor_bulk_save(exchange_rate): 154 | exchange_rates = [exchange_rate for _ in range(random.randint(1, 10))] 155 | exchange_rate_repo = Mock() 156 | exchange_rate_repo.bulk_save.return_value = None 157 | exchange_rate_interactor = CurrencyExchangeRateInteractor(exchange_rate_repo) 158 | result = exchange_rate_interactor.bulk_save(exchange_rates) 159 | assert exchange_rate_repo.bulk_save.called 160 | assert result is None 161 | -------------------------------------------------------------------------------- /tests/usecases/test_provider_interactors.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | 7 | from src.domain.provider import ProviderEntity 8 | from src.usecases.provider import ProviderInteractor 9 | from tests.fixtures import provider 10 | 11 | 12 | @pytest.mark.unit 13 | def test_provider_interactor_get_by_priority(provider): 14 | provider_repo = Mock() 15 | provider_repo.get_by_priority.return_value = [provider] 16 | provider_interactor = ProviderInteractor(provider_repo) 17 | result = provider_interactor.get_by_priority() 18 | result_first = result[0] 19 | assert provider_repo.get_by_priority.called 20 | assert isinstance(result, list) 21 | assert isinstance(result_first, ProviderEntity) 22 | assert result_first.name == provider.name 23 | assert result_first.driver == provider.driver 24 | assert result_first.priority == provider.priority 25 | assert result_first.enabled == provider.enabled 26 | assert ProviderEntity.to_string( 27 | result_first) == ProviderEntity.to_string(provider) 28 | --------------------------------------------------------------------------------