From b9ecb74328e4ab80525d9e4e8799459dc5969fdf Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Fri, 23 Apr 2021 15:14:00 +0300 Subject: [PATCH] Initial commit --- .coveragerc | 4 + .flake8 | 7 + .github/workflows/docker-hub-image-build.yml | 48 +++ .gitignore | 10 + .pre-commit-config.yaml | 9 + .travis.yml | 19 + Dockerfile | 6 + Dockerfile.prod | 20 + README.md | 86 +++++ RELEASE_NOTES.md | 43 +++ alembic.ini | 82 +++++ app/__init__.py | 0 app/alembic/README | 1 + app/alembic/env.py | 71 ++++ app/alembic/script.py.mako | 24 ++ .../0b9ae1eb0b30_initial_migration.py | 91 +++++ ..._alter_hyper_file_filename_field_to_be_.py | 31 ++ .../8a3e2f1927b8_add_file_status_field.py | 32 ++ .../e28d24caaf56_add_meta_data_field.py | 28 ++ app/common_tags.py | 11 + app/database.py | 11 + app/jobs/jobs.py | 26 ++ app/jobs/scheduler.py | 47 +++ app/jobs/settings.py | 15 + app/jobs/worker.py | 25 ++ app/libs/s3/client.py | 64 ++++ app/libs/tableau/client.py | 50 +++ app/main.py | 111 ++++++ app/models.py | 231 ++++++++++++ app/routers/__init__.py | 0 app/routers/configuration.py | 142 ++++++++ app/routers/file.py | 309 ++++++++++++++++ app/routers/oauth.py | 149 ++++++++ app/routers/server.py | 63 ++++ app/schemas.py | 157 ++++++++ app/settings.py | 49 +++ app/tests/conftest.py | 64 ++++ app/tests/routes/test_configuration.py | 101 +++++ app/tests/routes/test_file.py | 344 ++++++++++++++++++ app/tests/routes/test_oauth.py | 134 +++++++ app/tests/routes/test_server.py | 55 +++ app/tests/test_base.py | 35 ++ app/tests/test_main.py | 14 + app/tests/utils/test_hyper_utils.py | 117 ++++++ app/tests/utils/test_onadata_utils.py | 45 +++ app/utils/__init__.py | 0 app/utils/auth_utils.py | 106 ++++++ app/utils/hyper_utils.py | 251 +++++++++++++ app/utils/onadata_utils.py | 250 +++++++++++++ app/utils/utils.py | 23 ++ dev-requirements.in | 9 + dev-requirements.pip | 134 +++++++ docker-compose.yml | 76 ++++ .../managed-hyper-database-flow.png | Bin 0 -> 60167 bytes .../one-off-hyper-database-flow.png | Bin 0 -> 39770 bytes .../server-registration-flow.png | Bin 0 -> 55037 bytes init_scheduler.sh | 3 + prestart.sh | 6 + requirements.in | 18 + requirements.pip | 202 ++++++++++ scripts/make-migrations.sh | 3 + scripts/migrate.sh | 3 + scripts/run-tests.sh | 3 + scripts/start.sh | 3 + setup.py | 12 + tox.ini | 15 + 66 files changed, 4098 insertions(+) create mode 100644 .coveragerc create mode 100644 .flake8 create mode 100644 .github/workflows/docker-hub-image-build.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .travis.yml create mode 100644 Dockerfile create mode 100644 Dockerfile.prod create mode 100644 README.md create mode 100644 RELEASE_NOTES.md create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/alembic/README create mode 100644 app/alembic/env.py create mode 100644 app/alembic/script.py.mako create mode 100644 app/alembic/versions/0b9ae1eb0b30_initial_migration.py create mode 100644 app/alembic/versions/60383c2a9b44_alter_hyper_file_filename_field_to_be_.py create mode 100644 app/alembic/versions/8a3e2f1927b8_add_file_status_field.py create mode 100644 app/alembic/versions/e28d24caaf56_add_meta_data_field.py create mode 100644 app/common_tags.py create mode 100644 app/database.py create mode 100644 app/jobs/jobs.py create mode 100644 app/jobs/scheduler.py create mode 100644 app/jobs/settings.py create mode 100644 app/jobs/worker.py create mode 100644 app/libs/s3/client.py create mode 100644 app/libs/tableau/client.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/configuration.py create mode 100644 app/routers/file.py create mode 100644 app/routers/oauth.py create mode 100644 app/routers/server.py create mode 100644 app/schemas.py create mode 100644 app/settings.py create mode 100644 app/tests/conftest.py create mode 100644 app/tests/routes/test_configuration.py create mode 100644 app/tests/routes/test_file.py create mode 100644 app/tests/routes/test_oauth.py create mode 100644 app/tests/routes/test_server.py create mode 100644 app/tests/test_base.py create mode 100644 app/tests/test_main.py create mode 100644 app/tests/utils/test_hyper_utils.py create mode 100644 app/tests/utils/test_onadata_utils.py create mode 100644 app/utils/__init__.py create mode 100644 app/utils/auth_utils.py create mode 100644 app/utils/hyper_utils.py create mode 100644 app/utils/onadata_utils.py create mode 100644 app/utils/utils.py create mode 100644 dev-requirements.in create mode 100644 dev-requirements.pip create mode 100644 docker-compose.yml create mode 100644 docs/flow-diagrams/managed-hyper-database-flow.png create mode 100644 docs/flow-diagrams/one-off-hyper-database-flow.png create mode 100644 docs/flow-diagrams/server-registration-flow.png create mode 100755 init_scheduler.sh create mode 100755 prestart.sh create mode 100644 requirements.in create mode 100644 requirements.pip create mode 100755 scripts/make-migrations.sh create mode 100755 scripts/migrate.sh create mode 100755 scripts/run-tests.sh create mode 100755 scripts/start.sh create mode 100644 setup.py create mode 100644 tox.ini diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..e54c5a6 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[run] +omit = + # Omit any tests + */tests* diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..449e476 --- /dev/null +++ b/.flake8 @@ -0,0 +1,7 @@ +[flake8] +extend-ignore = E203, E266, E501 +# line length is intentionally set to 80 here because black uses Bugbear +# See https://github.com/psf/black/blob/master/docs/the_black_code_style.md#line-length for more details +max-line-length = 80 +max-complexity = 18 +select = B,C,E,F,W,T4,B9 diff --git a/.github/workflows/docker-hub-image-build.yml b/.github/workflows/docker-hub-image-build.yml new file mode 100644 index 0000000..1194a03 --- /dev/null +++ b/.github/workflows/docker-hub-image-build.yml @@ -0,0 +1,48 @@ +name: Build image for Docker Hub + +on: + release: + types: + - "released" + +jobs: + main: + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Get the version + id: get_version + run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile.prod + platforms: linux/amd64 + build-args: | + release_version=${{ steps.get_version.outputs.VERSION }} + push: true + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache + tags: | + onaio/duva:latest + onaio/duva:${{ steps.get_version.outputs.VERSION }} + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f498d23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +media/* +.vscode/ +hyperd*.log +.DS_Store +*.db +MANIFEST +.tox/ +.pytest_cache/ +.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..eae34c4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +repos: + - repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.4 + hooks: + - id: flake8 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e5d6cf2 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +sudo: required +dist: focal +language: python +jobs: + include: + - python: 3.7 + env: TOXENV=py37 + - python: 3.8 + env: TOXENV=py38 + - python: 3.7 + env: TOXENV=lint +services: + - redis-server +install: + - pip install -U pip + - pip install tox +script: tox +notifications: + slack: onaio:snkNXgprD498qQv4DgRREKJF \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fe1acdd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM tiangolo/uvicorn-gunicorn-fastapi:python3.7 + +RUN mkdir -p /root/.aws +COPY . /app + +RUN mkdir -p /app/media && pip install --no-cache-dir -r /app/requirements.pip diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..a1df38e --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,20 @@ +FROM tiangolo/uvicorn-gunicorn-fastapi:python3.7 +ARG release_version=v0.0.1 + +# Create application user +RUN useradd -m duva + +# Create directory for AWS Configurations +RUN mkdir -p /home/duva/.aws + +# Clone Duva application source code +RUN git clone -b ${release_version} https://github.com/onaio/duva.git /app-cloned &&\ + mv -f /app-cloned/* /app &&\ + chown -R duva:duva /app + +# Install application requirements +RUN pip install --no-cache-dir -U pip && pip install --no-cache-dir -r /app/requirements.pip + +EXPOSE 8000 + +CMD ["/start.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a909a50 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# Duva + +[![Build Status](https://travis-ci.com/onaio/duva.svg?branch=main)](https://travis-ci.com/github/onaio/duva) + +Duva is an API built using the [FastAPI](https://github.com/tiangolo/fastapi) framework that provides functionality to create & periodically update Tableau [Hyper](https://www.tableau.com/products/new-features/hyper) databases from CSV files. Currently the application supports connection to an [OnaData](https://github.com/onaio/onadata) server from which it'll pull data from an XLSForm and periodically export to a Tableau Hyper database + +## Requirements + +- Python 3.6+ +- Redis + +## Installation + +### Via Docker + +The application comes with a `docker-compose.yml` file to facilitate easier installation of the project. _Note: The `docker-compose.yml` file is tailored for development environments_ + +To start up the application via [Docker](https://www.docker.com/products/docker-desktop) run the `docker-compose up` command. + +### Alternative Installation + +1. Clone repository + +```sh +$ git clone https://github.com/onaio/duva.git +``` + +2. Create & start [a virtual environment](https://virtualenv.pypa.io/en/latest/installation.html) to install dependencies + +```sh +$ virtualenv duva +$ source duva/bin/activate +``` + +3. Install base dependencies + +```sh +$ pip install -r requirements.pip +``` + +4. (Optional: For developer environments) Install development dependencies. + +```sh +$ pip install -r dev-requirements.pip +``` + +At this point the application can be started. _Note: Ensure the redis server has been started_ + +``` +$ ./scripts/start.sh +``` + +## Configuration + +The application can be configured either by manual editing of the `app/settings.py` file or via environment variables i.e `export APP_NAME="Duva"`. More information on this [here](https://fastapi.tiangolo.com/advanced/settings) + +## API Documentation + +Documentation on the API endpoints provided by the application can be accessed by first running the application and accessing the `/docs` route. + +## Testing + +This project utilizes [tox](https://tox.readthedocs.io/en/latest/) for testing. In order to run the test suite within this project run the following commands: + +``` +$ pip install tox +$ tox +``` + +Alternatively, if you'd like to test the application with only the python version currently installed in your computer follow these steps: + +1. Install the developer dependencies + +```sh +$ pip install -r dev-requirements +``` + +2. Run the test suite using [pytest](https://docs.pytest.org/en/stable/) + +```sh +$ ./scripts/run-tests.sh +``` +>> OR +```sh +$ PYTHONPATH=. pytest -s app/tests +``` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..3c455b2 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,43 @@ +# Release Notes + +All release notes for this project will be documented in this file; this project follows [Semantic Versioning](https://semver.org/). + +## v0.0.1 - 2021-03-15 + +This is the first release :confetti_ball:. + +Project Breakdown: Duva is RESTful API that allows users to easily create & manage [Tableau Hyper](https://www.tableau.com/products/new-features/hyper) databases. + +### Key Features as of v0.0.1: + +- Supports automatic creation and updates of Hyper databases from an [OnaData](https://github.com/onaio/onadata) server; The application utilizes OnaDatas Export functionality to create and update the database. +- Supports creation of Hyper databases from a CSV File. + +### Sample Flows: + +#### One-off Hyper database creation from CSV File: + +The application as mentioned above supports creation of a one-time Hyper database from a CSV File; These databases are not updated after creation. + +![one-off hyper database creation](./docs/flow-diagrams/one-off-hyper-database-flow.png) + +This flow is ideal for one-off hyper database or for Servers where automatic creation & updates are not supported. *NB: As of v0.0.1 the application only supports OnaData servers.* + +#### Automatic creation and updates of Hyper Databases for OnaData servers + +In order for one to use this flow with a desired server, the user has to first register a new `Server` object. Which will be used to authenticate the application and users; allowing the application to pull data on behalf of the user on a scheduled basis in order to update the managed Hyper database. + +Server registration flow(One-time flow for new servers): + +![server registration flow](./docs/flow-diagrams/server-registration-flow.png) + +After a new server is registered users from the registered server are now able to create +managed Hyper database files. + +![managed hyper datase flow](./docs/flow-diagrams/managed-hyper-database-flow.png) + +*During the creation of the managed hyper database, users can specify a Tableau server where the hyper database should be published too after every update of the hyper database. For more information on how to configure this please view the API Docs on a deployed instance of the application(/docs).* + +### Known Limitations of v0.0.1 + +- The application currently uses session cookies to authenticate users; there are plans to phase out session cookies in favor of API Tokens. As of now users may need to clear the cookies in order to unauthenticate. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..39e691a --- /dev/null +++ b/alembic.ini @@ -0,0 +1,82 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = app/alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to app/alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat app/alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +hooks=black +black.type=console_scripts +black.entrypoint=black +black.options=-l 79 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/alembic/README b/app/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/app/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/app/alembic/env.py b/app/alembic/env.py new file mode 100644 index 0000000..d4c623a --- /dev/null +++ b/app/alembic/env.py @@ -0,0 +1,71 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from app.models import Base +from app.settings import settings + + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", settings.database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/app/alembic/script.py.mako b/app/alembic/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/app/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/app/alembic/versions/0b9ae1eb0b30_initial_migration.py b/app/alembic/versions/0b9ae1eb0b30_initial_migration.py new file mode 100644 index 0000000..3a41043 --- /dev/null +++ b/app/alembic/versions/0b9ae1eb0b30_initial_migration.py @@ -0,0 +1,91 @@ +"""Initial migration + +Revision ID: 0b9ae1eb0b30 +Revises: +Create Date: 2021-01-21 15:16:36.435591 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0b9ae1eb0b30" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "server", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("url", sa.String(), nullable=True), + sa.Column("client_id", sa.String(), nullable=True), + sa.Column("client_secret", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("url"), + ) + op.create_table( + "user", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("username", sa.String(), nullable=True), + sa.Column("refresh_token", sa.String(), nullable=True), + sa.Column("server", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["server"], ["server.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("server", "username", name="_server_user_uc"), + ) + op.create_index(op.f("ix_user_id"), "user", ["id"], unique=False) + op.create_table( + "configuration", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("server_address", sa.String(), nullable=True), + sa.Column("site_name", sa.String(), nullable=True), + sa.Column("token_name", sa.String(), nullable=True), + sa.Column("token_value", sa.String(), nullable=True), + sa.Column("project_name", sa.String(), nullable=True), + sa.Column("user", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["user"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "server_address", + "token_name", + "user", + name="_server_token_name_uc", + ), + ) + op.create_index(op.f("ix_configuration_id"), "configuration", ["id"], unique=False) + op.create_table( + "hyper_file", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("filename", sa.String(), nullable=True), + sa.Column("user", sa.Integer(), nullable=True), + sa.Column("form_id", sa.Integer(), nullable=False), + sa.Column("last_updated", sa.DateTime(), nullable=True), + sa.Column("last_synced", sa.DateTime(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=True), + sa.Column("configuration_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["configuration_id"], ["configuration.id"], ondelete="SET NULL" + ), + sa.ForeignKeyConstraint(["user"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("filename", name="hyper_file_filename_key"), + sa.UniqueConstraint("user", "form_id", name="_user_form_id_uc"), + ) + op.create_index(op.f("ix_hyper_file_id"), "hyper_file", ["id"], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_hyper_file_id"), table_name="hyper_file") + op.drop_table("hyper_file") + op.drop_index(op.f("ix_configuration_id"), table_name="configuration") + op.drop_table("configuration") + op.drop_index(op.f("ix_user_id"), table_name="user") + op.drop_table("user") + op.drop_table("server") + # ### end Alembic commands ### diff --git a/app/alembic/versions/60383c2a9b44_alter_hyper_file_filename_field_to_be_.py b/app/alembic/versions/60383c2a9b44_alter_hyper_file_filename_field_to_be_.py new file mode 100644 index 0000000..c1126bb --- /dev/null +++ b/app/alembic/versions/60383c2a9b44_alter_hyper_file_filename_field_to_be_.py @@ -0,0 +1,31 @@ +"""Alter Hyper file filename field to be non-unique + +Revision ID: 60383c2a9b44 +Revises: 0b9ae1eb0b30 +Create Date: 2021-02-12 16:44:45.035342 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "60383c2a9b44" +down_revision = "0b9ae1eb0b30" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + try: + op.drop_constraint("hyper_file_filename_key", "hyper_file") + except NotImplementedError: + with op.batch_alter_table("hyper_file", schema=None) as batch_op: + batch_op.drop_constraint("hyper_file_filename_key") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint("hyper_file_filename_key", "hyper_file", ["filename"]) + # ### end Alembic commands ### diff --git a/app/alembic/versions/8a3e2f1927b8_add_file_status_field.py b/app/alembic/versions/8a3e2f1927b8_add_file_status_field.py new file mode 100644 index 0000000..ab34484 --- /dev/null +++ b/app/alembic/versions/8a3e2f1927b8_add_file_status_field.py @@ -0,0 +1,32 @@ +"""Add file_status field + +Revision ID: 8a3e2f1927b8 +Revises: 60383c2a9b44 +Create Date: 2021-02-23 11:30:23.732253 + +""" +from app.models import ChoiceType, schemas +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "8a3e2f1927b8" +down_revision = "60383c2a9b44" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "hyper_file", + sa.Column("file_status", ChoiceType(schemas.FileStatusEnum), nullable=True), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("hyper_file", "file_status") + # ### end Alembic commands ### diff --git a/app/alembic/versions/e28d24caaf56_add_meta_data_field.py b/app/alembic/versions/e28d24caaf56_add_meta_data_field.py new file mode 100644 index 0000000..819d293 --- /dev/null +++ b/app/alembic/versions/e28d24caaf56_add_meta_data_field.py @@ -0,0 +1,28 @@ +"""Add meta_data field + +Revision ID: e28d24caaf56 +Revises: 8a3e2f1927b8 +Create Date: 2021-04-19 16:42:54.162670 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "e28d24caaf56" +down_revision = "8a3e2f1927b8" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("hyper_file", sa.Column("meta_data", sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("hyper_file", "meta_data") + # ### end Alembic commands ### diff --git a/app/common_tags.py b/app/common_tags.py new file mode 100644 index 0000000..37bb537 --- /dev/null +++ b/app/common_tags.py @@ -0,0 +1,11 @@ +# Common Tags +HYPER_PROCESS_CACHE_KEY = "HYPER_PROCESS" + +EVENT_STATUS_SUFFIX = "-event-status" + +ONADATA_TOKEN_ENDPOINT = "/o/token/" +ONADATA_FORMS_ENDPOINT = "/api/v1/forms" +ONADATA_USER_ENDPOINT = "/api/v1/user" + +SYNC_FAILURES_METADATA = "sync-failures" +JOB_ID_METADATA = "job-id" diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..48c3761 --- /dev/null +++ b/app/database.py @@ -0,0 +1,11 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from app.settings import settings + + +engine = create_engine(settings.database_url, connect_args=settings.db_connect_args) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() diff --git a/app/jobs/jobs.py b/app/jobs/jobs.py new file mode 100644 index 0000000..1ee6c8c --- /dev/null +++ b/app/jobs/jobs.py @@ -0,0 +1,26 @@ +from fastapi_cache import caches + +from fastapi_cache.backends.redis import CACHE_KEY, RedisCacheBackend +from tableauhyperapi import HyperProcess, Telemetry +from app.common_tags import HYPER_PROCESS_CACHE_KEY +from app.settings import settings + +from app.utils.onadata_utils import start_csv_import_to_hyper + + +def csv_import_job(instance_id): + # Connect to redis cache + rc = RedisCacheBackend(settings.redis_url) + caches.set(CACHE_KEY, rc) + + # Check if Hyper Process has started + # Note: Doing this in order to ensure only one + # Hyper process is started. + if not caches.get(HYPER_PROCESS_CACHE_KEY): + caches.set( + HYPER_PROCESS_CACHE_KEY, + HyperProcess(telemetry=Telemetry.SEND_USAGE_DATA_TO_TABLEAU), + ) + process: HyperProcess = caches.get(HYPER_PROCESS_CACHE_KEY) + + start_csv_import_to_hyper(instance_id, process) diff --git a/app/jobs/scheduler.py b/app/jobs/scheduler.py new file mode 100644 index 0000000..9e30a59 --- /dev/null +++ b/app/jobs/scheduler.py @@ -0,0 +1,47 @@ +import os + +from redis import Redis +from rq import Queue +from rq.job import Job +from rq_scheduler import Scheduler +from typing import Callable + +QUEUE_NAME = os.environ.get("QUEUE_NAME", "default") +CRON_SCHEDULE = os.environ.get("CRON_SCHEDULE", "*/15 * * * *") +TASK_TIMEOUT = os.environ.get("TASK_TIMEOUT", "3600") +REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/1") +REDIS_CONN = Redis.from_url(REDIS_URL) +QUEUE = Queue(QUEUE_NAME, connection=REDIS_CONN) +SCHEDULER = Scheduler(queue=QUEUE, connection=REDIS_CONN) + + +def cancel_job(job_id, job_args: list = None, func_name: str = None): + SCHEDULER.cancel(job_id) + + if job_args and func_name: + for job in SCHEDULER.get_jobs(): + if job.func_name == func_name and job.args == job_args: + SCHEDULER.cancel(job) + + print(f"Job {job_id} cancelled ....") + + +def clear_scheduler_queue(): + for job in SCHEDULER.get_jobs(): + cancel_job(job) + + +def schedule_cron_job(job_func: Callable, args_list) -> Job: + job = SCHEDULER.cron( + CRON_SCHEDULE, # A cron string (e.g. "0 0 * * 0") + func=job_func, # Function to be queued + args=args_list, # Arguments passed into function when executed + kwargs={}, # Keyword arguments passed into function when executed + repeat=None, # Repeat this number of times (None means repeat forever) + queue_name=QUEUE_NAME, # In which queue the job should be put in + meta={}, # Arbitrary pickleable data on the job itself + use_local_timezone=False, # Interpret hours in the local timezone + timeout=int(TASK_TIMEOUT), # How long jobs can run for + ) + print(f"Job {job.id} scheduled ....") + return job diff --git a/app/jobs/settings.py b/app/jobs/settings.py new file mode 100644 index 0000000..459ee79 --- /dev/null +++ b/app/jobs/settings.py @@ -0,0 +1,15 @@ +""" +Settings file for RQ Workers +""" +import os +import sentry_sdk +from sentry_sdk.integrations.rq import RqIntegration + +from app.settings import settings + +# Init sentry +if settings.sentry_dsn: + sentry_sdk.init(settings.sentry_dsn, integrations=[RqIntegration()]) + +REDIS_URL = settings.redis_url +QUEUES = [os.environ.get("QUEUE_NAME", "default")] diff --git a/app/jobs/worker.py b/app/jobs/worker.py new file mode 100644 index 0000000..1bd0cc1 --- /dev/null +++ b/app/jobs/worker.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +import os + +import sentry_sdk +from app.settings import settings +from redis import Redis +from rq import Connection, Worker, Queue +from sentry_sdk.integrations.rq import RqIntegration + +# Preload libraries + +QUEUE_NAME = os.environ.get("QUEUE_NAME", "default") + + +redis_conn = Redis.from_url(settings.redis_url) + +# Provide queue names to listen to as arguments to this script, +# similar to rq worker +with Connection(): + if settings.sentry_dsn: + sentry_sdk.init(settings.sentry_dsn, integrations=[RqIntegration()]) + queue = Queue(QUEUE_NAME, connection=redis_conn) + + w = Worker(queue, connection=redis_conn) + w.work() diff --git a/app/libs/s3/client.py b/app/libs/s3/client.py new file mode 100644 index 0000000..43e35b1 --- /dev/null +++ b/app/libs/s3/client.py @@ -0,0 +1,64 @@ +import boto3 + +from botocore.exceptions import ClientError + +from app.settings import settings + + +class S3Client: + """ + This class encapsulates s3 client provided by boto3 + + """ + + def __init__(self): + self.s3 = boto3.resource("s3", region_name=settings.s3_region) + + def upload(self, path, file_name): + """ + uploads file in the given path to s3 with the given filename + """ + try: + self.s3.meta.client.upload_file(path, settings.s3_bucket, file_name) + except ClientError: + return False + return True + + def download(self, path, file_name): + """ + Downloads file_name in s3 to path + """ + try: + self.s3.meta.client.download_file(settings.s3_bucket, file_name, path) + except ClientError: + return False + return True + + def delete(self, file_path): + """ + Deletes file_path in S3 + """ + try: + resp = self.s3.meta.client.delete_object( + Bucket=settings.s3_bucket, Key=file_path + ) + except ClientError: + return False + return resp.get("DeleteMarker") + + def generate_presigned_download_url(self, file_path: str, expiration: int = 3600): + """ + Generates a presigned Download URL + + file_path :string: Path to the file in the S3 Bucket + expirationg :integer: The duration in seconds that the URL should be valid for + """ + try: + response = self.s3.meta.client.generate_presigned_url( + "get_object", + Params={"Bucket": settings.s3_bucket, "Key": file_path}, + ExpiresIn=expiration, + ) + except ClientError: + return None + return response diff --git a/app/libs/tableau/client.py b/app/libs/tableau/client.py new file mode 100644 index 0000000..aef5168 --- /dev/null +++ b/app/libs/tableau/client.py @@ -0,0 +1,50 @@ +import tableauserverclient as TSC + +from pathlib import Path + +from app.models import Configuration + + +class TableauClient: + def __init__(self, configuration: Configuration): + self.project_name = configuration.project_name + self.token_name = configuration.token_name + self.token_value = Configuration.decrypt_value(configuration.token_value) + self.site_name = configuration.site_name + self.server_address = configuration.server_address + + def publish_hyper(self, hyper_name): + """ + Signs in and publishes an extract directly to Tableau Online/Server + """ + + # Sign in to server + tableau_auth = TSC.PersonalAccessTokenAuth( + token_name=self.token_name, + personal_access_token=self.token_value, + site_id=self.site_name, + ) + server = TSC.Server(self.server_address, use_server_version=True) + + print(f"Signing into {self.site_name} at {self.server_address}") + with server.auth.sign_in(tableau_auth): + # Define publish mode - Overwrite, Append, or CreateNew + publish_mode = TSC.Server.PublishMode.Overwrite + + # Get project_id from project_name + # all_projects, _ = server.projects.get() + for project in TSC.Pager(server.projects): + if project.name == self.project_name: + project_id = project.id + + # Create the datasource object with the project_id + datasource = TSC.DatasourceItem(project_id) + + print(f"Publishing {hyper_name} to {self.project_name}...") + + path_to_database = Path(hyper_name) + # Publish datasource + datasource = server.datasources.publish( + datasource, path_to_database, publish_mode + ) + print("Datasource published. Datasource ID: {0}".format(datasource.id)) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0d4ea01 --- /dev/null +++ b/app/main.py @@ -0,0 +1,111 @@ +import os +import uvicorn +import sentry_sdk +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.templating import Jinja2Templates +from fastapi_cache import caches, close_caches +from fastapi_cache.backends.redis import CACHE_KEY, RedisCacheBackend +from tableauhyperapi import HyperProcess, Telemetry +from sentry_sdk.integrations.asgi import SentryAsgiMiddleware +from starlette.middleware.sessions import SessionMiddleware + +from app.common_tags import HYPER_PROCESS_CACHE_KEY +from app.database import engine +from app.models import Base +from app.settings import settings +from app.utils.onadata_utils import schedule_all_active_forms +from app.routers.file import router as file_router +from app.routers.oauth import router as oauth_router +from app.routers.server import router as server_router +from app.routers.configuration import router as configurations_router +from app.jobs.scheduler import clear_scheduler_queue + +Base.metadata.create_all(bind=engine) + +app = FastAPI( + title=settings.app_name, + description=settings.app_description, + version=settings.app_version, +) + +templates = Jinja2Templates(directory="app/templates") + +# Include middlewares +app.add_middleware( + SessionMiddleware, + secret_key=settings.secret_key, + https_only=settings.enable_secure_sessions, + same_site=settings.session_same_site, +) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_allowed_origins, + allow_credentials=settings.cors_allow_credentials, + allow_methods=settings.cors_allowed_methods, + allow_headers=settings.cors_allowed_headers, + max_age=settings.cors_max_age, +) +if settings.sentry_dsn: + sentry_sdk.init(dsn=settings.sentry_dsn, release=settings.app_version) + app.add_middleware(SentryAsgiMiddleware) + +# Include routes +app.include_router(server_router, tags=["Server Configuration"]) +app.include_router(oauth_router, tags=["OAuth2"]) +app.include_router(file_router, tags=["Hyper File"]) +app.include_router(configurations_router, tags=["Tableau Server Configuration"]) + + +@app.get("/", tags=["Application"]) +def home(request: Request): + return { + "app_name": settings.app_name, + "app_description": settings.app_description, + "app_version": settings.app_version, + "docs_url": str(request.base_url.replace(path=app.docs_url)), + } + + +@app.on_event("startup") +async def on_startup() -> None: + # Ensure media file path exists + if not os.path.isdir(settings.media_path): + os.mkdir(settings.media_path) + + # Connect to redis cache + rc = RedisCacheBackend(settings.redis_url) + caches.set(CACHE_KEY, rc) + + # Check if Hyper Process has started + # Note: Doing this in order to ensure only one + # Hyper process is started. + if not caches.get(HYPER_PROCESS_CACHE_KEY): + caches.set( + HYPER_PROCESS_CACHE_KEY, + HyperProcess(telemetry=Telemetry.SEND_USAGE_DATA_TO_TABLEAU), + ) + + if settings.schedule_all_active: + clear_scheduler_queue() + schedule_all_active_forms(close_db=True) + + +@app.on_event("shutdown") +async def on_shutdown() -> None: + await close_caches() + + # Check if hyper process is running and shut it down + process: HyperProcess = caches.get(HYPER_PROCESS_CACHE_KEY) + if process: + print("Shutting down hyper process") + process.close() + + +if __name__ == "__main__": + uvicorn.run( + "app.main:app", + host=settings.app_host, + port=settings.app_port, + reload=settings.debug, + ) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..95b5099 --- /dev/null +++ b/app/models.py @@ -0,0 +1,231 @@ +import sqlalchemy.types as types +from typing import Optional +from cryptography.fernet import Fernet +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Integer, + String, + UniqueConstraint, + JSON, +) +from sqlalchemy.orm import Session, relationship +from sqlalchemy.sql.schema import ForeignKey + +from app import schemas +from app.common_tags import SYNC_FAILURES_METADATA, JOB_ID_METADATA +from app.database import Base +from app.settings import settings +from app.libs.s3.client import S3Client + + +class ChoiceType(types.TypeDecorator): + """ + ChoiceField Implementation for SQL Alchemy + + Credits: https://stackoverflow.com/a/6264027 + """ + + impl = types.String + + def __init__(self, enum, **kwargs): + self.choices = enum + super(ChoiceType, self).__init__(**kwargs) + + def process_bind_param(self, value, dialect): + for member in dir(self.choices): + if getattr(self.choices, member) == value: + return member + + def process_result_value(self, value, dialect): + return getattr(self.choices, value).value + + +class ModelMixin(object): + @classmethod + def get(cls, db: Session, object_id: int): + return db.query(cls).filter(cls.id == object_id).first() + + @classmethod + def get_all(cls, db: Session, skip: int = 0, limit: int = 100): + return db.query(cls).offset(skip).limit(limit).all() + + @classmethod + def delete(cls, db: Session, object_id: int): + return ( + db.query(cls) + .filter(cls.id == object_id) + .delete(synchronize_session="fetch") + ) + + +class EncryptionMixin(object): + @classmethod + def _get_encryption_key(cls): + return Fernet(settings.secret_key) + + @classmethod + def encrypt_value(cls, raw_value): + key = cls._get_encryption_key() + return key.encrypt(raw_value.encode("utf-8")).decode("utf-8") + + @classmethod + def decrypt_value(cls, encrypted_value): + key = cls._get_encryption_key() + return key.decrypt(encrypted_value.encode("utf-8")).decode("utf-8") + + +class HyperFile(ModelMixin, Base): + __tablename__ = "hyper_file" + __table_args__ = (UniqueConstraint("user", "form_id", name="_user_form_id_uc"),) + + id = Column(Integer, primary_key=True, index=True) + filename = Column(String, unique=False) + user = Column(Integer, ForeignKey("user.id", ondelete="CASCADE")) + form_id = Column(Integer, nullable=False) + last_updated = Column(DateTime) + last_synced = Column(DateTime) + is_active = Column(Boolean, default=True) + file_status = Column( + ChoiceType(schemas.FileStatusEnum), + default=schemas.FileStatusEnum.file_unavailable, + ) + configuration_id = Column( + Integer, ForeignKey("configuration.id", ondelete="SET NULL") + ) + meta_data = Column(JSON, default={SYNC_FAILURES_METADATA: 0, JOB_ID_METADATA: ""}) + configuration = relationship("Configuration") + + def get_file_path(self, db: Session): + user = User.get(db, self.user) + s3_path = f"{user.server}/{user.username}/{self.form_id}_{self.filename}" + return s3_path + + def retrieve_latest_file(self, db: Session): + local_path = f"{settings.media_path}/{self.form_id}_{self.filename}" + s3_path = self.get_file_path(db) + client = S3Client() + client.download(local_path, s3_path) + return local_path + + @classmethod + def get_using_file_create(cls, db: Session, file_create: schemas.FileCreate): + return ( + db.query(cls) + .filter(cls.user == file_create.user, cls.form_id == file_create.form_id) + .first() + ) + + @classmethod + def get_active_files(cls, db: Session): + return db.query(cls).filter(cls.is_active == True).all() # noqa + + @classmethod + def create(cls, db: Session, hyperfile: schemas.FileCreate): + instance = cls(**hyperfile.dict()) + db.add(instance) + db.commit() + db.refresh(instance) + return instance + + @classmethod + def filter(cls, user: schemas.User, form_id: int, db: Session): + return db.query(cls).filter(cls.user == user.id, cls.form_id == form_id).all() + + +class Server(ModelMixin, EncryptionMixin, Base): + __tablename__ = "server" + + id = Column(Integer, primary_key=True) + url = Column(String, unique=True) + client_id = Column(String) + client_secret = Column(String) + + @classmethod + def get_using_url(cls, db: Session, url: str) -> Optional[schemas.Server]: + return db.query(cls).filter(cls.url == url).first() + + @classmethod + def create(cls, db: Session, server: schemas.ServerCreate) -> schemas.Server: + encrypted_secret = cls.encrypt_value(server.client_secret) + server = cls( + url=server.url, + client_id=server.client_id, + client_secret=encrypted_secret, + ) + db.add(server) + db.commit() + db.refresh(server) + return server + + +class User(ModelMixin, EncryptionMixin, Base): + __tablename__ = "user" + __table_args__ = (UniqueConstraint("server", "username", name="_server_user_uc"),) + + id = Column(Integer, primary_key=True, index=True) + username = Column(String) + refresh_token = Column(String) + server = Column(Integer, ForeignKey("server.id", ondelete="CASCADE")) + files = relationship("HyperFile") + + @classmethod + def get_using_username(cls, db: Session, username: str): + return db.query(cls).filter(cls.username == username).first() + + @classmethod + def get_using_server_and_username(cls, db: Session, username: str, server_id: int): + return ( + db.query(cls) + .filter(cls.username == username, cls.server == server_id) + .first() + ) + + @classmethod + def create(cls, db: Session, user: schemas.User): + encrypted_token = cls.encrypt_value(user.refresh_token) + user = cls( + username=user.username, refresh_token=encrypted_token, server=user.server + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +class Configuration(ModelMixin, EncryptionMixin, Base): + """ + Tableau server authentication configurations; Used to publish + Hyper files. + """ + + __tablename__ = "configuration" + __table_args__ = ( + UniqueConstraint( + "server_address", "token_name", "user", name="_server_token_name_uc" + ), + ) + + id = Column(Integer, primary_key=True, index=True) + server_address = Column(String) + site_name = Column(String) + token_name = Column(String) + token_value = Column(String) + project_name = Column(String, default="default") + user = Column(Integer, ForeignKey("user.id", ondelete="CASCADE")) + + @classmethod + def filter_using_user_id(cls, db: Session, user_id: int): + return db.query(cls).filter(cls.user == user_id) + + @classmethod + def create(cls, db: Session, config: schemas.ConfigurationCreate): + encrypted_token = cls.encrypt_value(config.token_value) + data = config.dict() + data.update({"token_value": encrypted_token}) + configuration = cls(**data) + db.add(configuration) + db.commit() + db.refresh(configuration) + return configuration diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/configuration.py b/app/routers/configuration.py new file mode 100644 index 0000000..1f4ba17 --- /dev/null +++ b/app/routers/configuration.py @@ -0,0 +1,142 @@ +# Routes for the Tableau Configuration (/configurations) endpoint +from typing import List + +from fastapi import Depends +from fastapi.exceptions import HTTPException +from fastapi.requests import Request +from fastapi.routing import APIRouter +from sqlalchemy.orm import Session +from psycopg2.errors import UniqueViolation +from sqlalchemy.exc import IntegrityError + +from app import schemas +from app.models import Configuration, User +from app.utils.auth_utils import IsAuthenticatedUser +from app.utils.utils import get_db + + +router = APIRouter() + + +@router.get( + "/api/v1/configurations", + status_code=200, + response_model=List[schemas.ConfigurationListResponse], +) +def list_configurations( + request: Request, + user: User = Depends(IsAuthenticatedUser()), + db: Session = Depends(get_db), +): + """ + Lists out all the Tableau Configurations currently accessible for to the logged in user + """ + resp = [] + configurations = Configuration.filter_using_user_id(db, user.id) + + for config in configurations: + config = schemas.ConfigurationListResponse.from_orm(config) + config.url = f"{request.base_url.scheme}://{request.base_url.netloc}" + config.url += router.url_path_for("get_configuration", config_id=config.id) + resp.append(config) + return resp + + +@router.get( + "/api/v1/configurations/{config_id}", + status_code=200, + response_model=schemas.ConfigurationResponse, +) +def get_configuration( + config_id: int, + user: User = Depends(IsAuthenticatedUser()), + db: Session = Depends(get_db), +): + """ + Retrieve a specific configuration + """ + config = Configuration.get(db, config_id) + + if config and config.user == user.id: + return config + else: + raise HTTPException(status_code=404, detail="Tableau configuration not found.") + + +@router.post( + "/api/v1/configurations", + status_code=201, + response_model=schemas.ConfigurationResponse, +) +def create_configuration( + config_data: schemas.ConfigurationCreateRequest, + user: User = Depends(IsAuthenticatedUser()), + db: Session = Depends(get_db), +): + """ + Create a new Tableau Server Configuration that can be attached + to a hyper file to define where the hyper file should be pushed to. + """ + config_data = schemas.ConfigurationCreate( + user=user.id, + server_address=config_data.server_address, + site_name=config_data.site_name, + token_name=config_data.token_name, + token_value=config_data.token_value, + project_name=config_data.project_name, + ) + try: + config = Configuration.create(db, config_data) + return config + except (UniqueViolation, IntegrityError): + raise HTTPException(status_code=400, detail="Configuration already exists") + + +@router.patch( + "/api/v1/configurations/{config_id}", + status_code=200, + response_model=schemas.ConfigurationResponse, +) +def patch_configuration( + config_id: int, + config_data: schemas.ConfigurationPatchRequest, + user: User = Depends(IsAuthenticatedUser()), + db: Session = Depends(get_db), +): + """ + Partially update a Configuration + """ + config = Configuration.get(db, config_id) + + if config and config.user == user.id: + try: + for key, value in config_data.dict().items(): + if value: + if key == "token_value": + value = Configuration.encrypt_value(value) + setattr(config, key, value) + db.commit() + db.refresh(config) + return config + except (UniqueViolation, IntegrityError): + raise HTTPException(status_code=400, detail="Configuration already exists") + else: + raise HTTPException(404, detail="Tableau Configuration not found.") + + +@router.delete("/api/v1/configurations/{config_id}", status_code=204) +def delete_configuration( + config_id: int, + user: User = Depends(IsAuthenticatedUser()), + db: Session = Depends(get_db), +): + """ + Permanently delete a configuration + """ + config = Configuration.get(db, config_id) + + if config and config.user == user.id: + Configuration.delete(db, config.id) + db.commit() + else: + raise HTTPException(status_code=400) diff --git a/app/routers/file.py b/app/routers/file.py new file mode 100644 index 0000000..ad5ed4e --- /dev/null +++ b/app/routers/file.py @@ -0,0 +1,309 @@ +# Routes for the Hyperfile (/files) endpoint +import os +import shutil +from pathlib import Path +from tempfile import NamedTemporaryFile +from datetime import datetime, timedelta +from typing import List, Optional, Union + +from fastapi import BackgroundTasks, Depends, HTTPException, UploadFile, File, Request +from fastapi.routing import APIRouter +from fastapi.responses import FileResponse, JSONResponse +from fastapi_cache import caches +from redis.client import Redis +from sqlalchemy.orm import Session +from tableauhyperapi.hyperprocess import HyperProcess + +from app import schemas +from app.common_tags import HYPER_PROCESS_CACHE_KEY +from app.libs.s3.client import S3Client +from app.models import HyperFile, Configuration, User +from app.settings import settings +from app.utils.auth_utils import IsAuthenticatedUser +from app.utils.utils import get_db, get_redis_client +from app.utils.hyper_utils import handle_csv_import +from app.utils.onadata_utils import ( + ConnectionRequestError, + DoesNotExist, + UnsupportedForm, + create_or_get_hyperfile, + start_csv_import_to_hyper, + schedule_hyper_file_cron_job, + start_csv_import_to_hyper_job, +) + + +router = APIRouter() + + +def _create_hyper_file_response( + hyper_file: HyperFile, db: Session, request: Request +) -> schemas.FileResponseBody: + from app.main import app + + s3_client = S3Client() + file_path = hyper_file.get_file_path(db) + download_url = s3_client.generate_presigned_download_url( + file_path, expiration=settings.download_url_lifetime + ) + data = schemas.File.from_orm(hyper_file).dict() + if download_url: + expiry_date = datetime.utcnow() + timedelta( + seconds=settings.download_url_lifetime + ) + data.update( + { + "download_url": download_url, + "download_url_valid_till": expiry_date.isoformat(), + } + ) + if hyper_file.configuration_id: + config_url = f"{request.base_url.scheme}://{request.base_url.netloc}" + config_url += app.url_path_for( + "get_configuration", config_id=hyper_file.configuration_id + ) + data.update({"configuration_url": config_url}) + response = schemas.FileResponseBody(**data) + return response + + +@router.post("/api/v1/files", status_code=201, response_model=schemas.FileResponseBody) +def create_hyper_file( + request: Request, + file_request: schemas.FileRequestBody, + background_tasks: BackgroundTasks, + user: User = Depends(IsAuthenticatedUser()), + db: Session = Depends(get_db), + redis_client: Redis = Depends(get_redis_client), +): + """ + Creates a Hyper file object. + + JSON Data Parameters: + - `form_id`: An integer representing the ID of the form whose data should be exported + into a Hyperfile & tracked. + - `sync_immediately`: An optional boolean field that determines whether a forms data should + be synced immediately after creation of a Hyper file object. _Note: Hyper files are updated + periodically on a schedule by default i.e 15 minutes after creation of object or every 24 hours_ + - `configuration_id`: An integer representing the ID of a Configuration(_See docs on /api/v1/configurations route_). + Determines where the hyper file is pushed to after it has been updated with the latest form data. + """ + process: HyperProcess = caches.get(HYPER_PROCESS_CACHE_KEY) + configuration = None + try: + file_data = schemas.FileCreate(form_id=file_request.form_id, user=user.id) + if file_request.configuration_id: + configuration = Configuration.get(db, file_request.configuration_id) + if not configuration or not configuration.user == user.id: + raise HTTPException( + status_code=400, + detail=f"Tableau configuration with ID {file_request.configuration_id} not found", + ) + file_instance, created = create_or_get_hyperfile(db, file_data, process) + except (DoesNotExist, UnsupportedForm) as e: + raise HTTPException(status_code=400, detail=str(e)) + except ConnectionRequestError as e: + raise HTTPException(status_code=502, detail=str(e)) + else: + if not created: + raise HTTPException(status_code=400, detail="File already exists.") + + if configuration: + file_instance.configuration_id = configuration.id + + if file_request.sync_immediately: + background_tasks.add_task( + start_csv_import_to_hyper, file_instance.id, process + ) + background_tasks.add_task( + schedule_hyper_file_cron_job, + start_csv_import_to_hyper_job, + file_instance.id, + ) + file_instance.file_status = schemas.FileStatusEnum.queued.value + db.commit() + return _create_hyper_file_response(file_instance, db, request) + + +@router.get("/api/v1/files", response_model=List[schemas.FileListItem]) +def list_hyper_files( + request: Request, + user: User = Depends(IsAuthenticatedUser()), + form_id: Optional[str] = None, + db: Session = Depends(get_db), +): + """ + This endpoint lists out all the hyper files currently owned by the + logged in user. + + Query Parameters: + - `form_id`: An integer representing an ID of a form on the users authenticated + server. + """ + response = [] + hyperfiles = [] + if form_id: + hyperfiles = HyperFile.filter(user, form_id, db) + else: + hyperfiles = user.files + + for hyperfile in hyperfiles: + url = request.base_url.scheme + "://" + request.base_url.netloc + url += router.url_path_for("get_hyper_file", file_id=hyperfile.id) + response.append( + schemas.FileListItem( + url=url, + id=hyperfile.id, + form_id=hyperfile.form_id, + filename=hyperfile.filename, + file_status=hyperfile.file_status, + ) + ) + return response + + +@router.get("/api/v1/files/{file_id}", response_model=schemas.FileResponseBody) +def get_hyper_file( + file_id: Union[str, int], + request: Request, + user: User = Depends(IsAuthenticatedUser()), + db: Session = Depends(get_db), +): + """ + Retrieves a specific hyper file. _This endpoint supports both `.json` and `.hyper` response_ + + The `.json` response provides the JSON representation of the hyper file object. While the `.hyper` + response provides a FileResponse that contains the latest hyper file download. + """ + response_type = None + file_parts = file_id.split(".") + if len(file_parts) == 2: + file_id, response_type = file_parts + + try: + file_id = int(file_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid file ID") + + hyperfile = HyperFile.get(db, file_id) + + if hyperfile and user.id == hyperfile.user: + if not response_type or response_type == "json": + return _create_hyper_file_response(hyperfile, db, request) + elif response_type == "hyper": + file_path = hyperfile.retrieve_latest_file(db) + if os.path.exists(file_path): + return FileResponse(file_path, filename=hyperfile.filename) + else: + raise HTTPException( + status_code=404, detail="File currently not available" + ) + else: + raise HTTPException(status_code=400, detail="Unsupported content type") + else: + raise HTTPException(status_code=404, detail="File not found") + + +@router.patch( + "/api/v1/files/{file_id}", status_code=200, response_model=schemas.FileResponseBody +) +def patch_hyper_file( + file_id: int, + request: Request, + data: schemas.FilePatchRequestBody, + user: User = Depends(IsAuthenticatedUser()), + db: Session = Depends(get_db), +): + """ + Partially updates a specific hyper file object + """ + hyper_file = HyperFile.get(db, file_id) + + if not hyper_file or hyper_file.user != user.id: + raise HTTPException(status_code=404, detail="File not found") + + configuration = Configuration.get(db, data.configuration_id) + if not configuration or not configuration.user == user.id: + raise HTTPException( + status_code=400, + detail=f"Tableau configuration with ID {data.configuration_id} not found", + ) + hyper_file.configuration_id = configuration.id + db.commit() + db.refresh(hyper_file) + return _create_hyper_file_response(hyper_file, db, request) + + +@router.post("/api/v1/files/csv_import", status_code=200, response_class=FileResponse) +def import_data(id_string: str, csv_file: UploadFile = File(...)): + """ + Experimental Endpoint: Creates and imports `csv_file` data into a hyper file. + """ + process: HyperProcess = caches.get(HYPER_PROCESS_CACHE_KEY) + suffix = Path(csv_file.filename).suffix + csv_file.file.seek(0) + file_path = f"{settings.media_path}/{id_string}.hyper" + with NamedTemporaryFile(delete=False, suffix=suffix) as tmp_upload: + shutil.copyfileobj(csv_file.file, tmp_upload) + tmp_upload.flush() + handle_csv_import( + file_path=file_path, csv_path=Path(tmp_upload.name), process=process + ) + return FileResponse(file_path, filename=f"{id_string}.hyper") + + +@router.delete("/api/v1/files/{file_id}", status_code=204) +def delete_hyper_file( + file_id: int, + user: User = Depends(IsAuthenticatedUser()), + db: Session = Depends(get_db), +): + """ + Permanently delete a Hyper File Object + """ + hyper_file = HyperFile.get(db, file_id) + + if hyper_file and hyper_file.user == user.id: + # Delete file from S3 + s3_client = S3Client() + if s3_client.delete(hyper_file.get_file_path(db)): + # Delete Hyper File object from database + HyperFile.delete(db, file_id) + db.commit() + else: + raise HTTPException(status_code=400) + + +@router.post("/api/v1/files/{file_id}/sync") +def trigger_hyper_file_sync( + request: Request, + file_id: int, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + user: User = Depends(IsAuthenticatedUser()), + redis_client: Redis = Depends(get_redis_client), +): + """ + Trigger Hyper file sync; Starts a process that updates the + hyper files data. + """ + hyper_file = HyperFile.get(db, file_id) + + if not hyper_file: + raise HTTPException(404, "File not found.") + if hyper_file.user == user.id: + status_code = 200 + if hyper_file.file_status not in [ + schemas.FileStatusEnum.queued, + schemas.FileStatusEnum.syncing, + ]: + process: HyperProcess = caches.get(HYPER_PROCESS_CACHE_KEY) + background_tasks.add_task(start_csv_import_to_hyper, hyper_file.id, process) + else: + status_code = 202 + + return JSONResponse( + {"message": "File syncing is currently on-going"}, status_code=status_code + ) + else: + raise HTTPException(401) diff --git a/app/routers/oauth.py b/app/routers/oauth.py new file mode 100644 index 0000000..1db3ffa --- /dev/null +++ b/app/routers/oauth.py @@ -0,0 +1,149 @@ +# Routes for the OAuth (/oauth) endpoint +import json +import uuid +from typing import Optional + +import httpx +import redis +from fastapi import Depends, HTTPException, Request +from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.routing import APIRouter + +from app import schemas +from app.common_tags import ONADATA_TOKEN_ENDPOINT, ONADATA_USER_ENDPOINT +from app.models import User, Server +from app.utils.utils import get_db, get_redis_client +from app.utils.auth_utils import create_session, IsAuthenticatedUser + +router = APIRouter() + + +@router.get("/api/v1/oauth/login", status_code=302) +def start_login_flow( + server_url: str, + redirect_url: Optional[str] = None, + user=Depends(IsAuthenticatedUser(raise_errors=False)), + db=Depends(get_db), + redis: redis.Redis = Depends(get_redis_client), +): + """ + Starts OAuth2 Code Flow; The flow authenticates a user against one of the configured + servers. _For more info on server configurations check the `/api/v1/server` docs_ + + This endpoint redirects the client to the `server_url` for authentication if the server + has a server configuration in the system. Once the user is authorized on the server + the user should be redirected back to `/api/v1/oauth/callback` which will handle + creation of a user session that will allow the user to access the applications Hyper File + resources. + """ + if not user: + server: Optional[schemas.Server] = Server.get_using_url(db, server_url) + if not server: + raise HTTPException(status_code=400, detail="Server not configured") + auth_state = {"server_id": server.id} + if redirect_url: + auth_state["redirect_url"] = redirect_url + + state_key = str(uuid.uuid4()) + redis.setex(state_key, 600, json.dumps(auth_state)) + url = f"{server.url}/o/authorize?client_id={server.client_id}&response_type=code&state={state_key}" + return RedirectResponse( + url=url, + status_code=302, + headers={ + "Cache-Control": "no-cache, no-store, revalidate", + }, + ) + else: + return RedirectResponse(url=redirect_url or "/", status_code=302) + + +@router.get( + "/api/v1/oauth/callback", + status_code=302, + responses={200: {"model": schemas.UserBearerTokenResponse}}, +) +def handle_oauth_callback( + code: str, + state: str, + request: Request, + db=Depends(get_db), + user=Depends(IsAuthenticatedUser(raise_errors=False)), + redis: redis.Redis = Depends(get_redis_client), +): + """ + Handles OAuth2 Code flow callback. This url should be registered + as the "redirect_uri" for your Server OAuth Application(Onadata). + + This endpoint creates a user session for the authorized user and authenticates + the user granting them access to the Hyper File API. + + User sessions last for 2 weeks. After the 2 weeks pass the user needs to re-authorize + with the application to gain access to the Hyper file API + """ + if user: + return RedirectResponse(url="/", status_code=302) + + auth_state = redis.get(state) + if not auth_state: + raise HTTPException( + status_code=401, detail="Authorization state can not be confirmed." + ) + + auth_state = json.loads(auth_state) + redis.delete(state) + server: Optional[schemas.Server] = Server.get( + db, object_id=auth_state.get("server_id") + ) + redirect_url = auth_state.get("redirect_url") + data = { + "grant_type": "authorization_code", + "code": code, + "client_id": server.client_id, + } + url = f"{server.url}{ONADATA_TOKEN_ENDPOINT}" + resp = httpx.post( + url, + data=data, + auth=( + server.client_id, + Server.decrypt_value(server.client_secret), + ), + ) + + if resp.status_code == 200: + resp = resp.json() + access_token = resp.get("access_token") + refresh_token = resp.get("refresh_token") + + user_url = f"{server.url}{ONADATA_USER_ENDPOINT}.json" + headers = {"Authorization": f"Bearer {access_token}"} + resp = httpx.get(user_url, headers=headers) + if resp.status_code == 200: + resp = resp.json() + username = resp.get("username") + user = User.get_using_server_and_username(db, username, server.id) + if not user: + user_data = schemas.User( + username=username, refresh_token=refresh_token, server=server.id + ) + user = User.create(db, user_data) + else: + user.refresh_token = User.encrypt_value(refresh_token) + db.commit() + + request, session_data = create_session(user, redis, request) + if redirect_url: + return RedirectResponse( + redirect_url, + status_code=302, + headers={ + "Cache-Control": "no-cache, no-store, revalidate", + }, + ) + return JSONResponse( + schemas.UserBearerTokenResponse( + bearer_token=session_data.decode("utf-8") + ).dict() + ) + raise HTTPException(status_code=401, detail="Authentication failed.") diff --git a/app/routers/server.py b/app/routers/server.py new file mode 100644 index 0000000..04e9781 --- /dev/null +++ b/app/routers/server.py @@ -0,0 +1,63 @@ +# Routes for the Server (/server) endpoint +from typing import List + +from fastapi import Depends, HTTPException +from fastapi.routing import APIRouter +from starlette.datastructures import URL + +from app import schemas +from app.models import Server +from app.utils.utils import get_db + + +router = APIRouter() + + +@router.post("/api/v1/servers", response_model=schemas.ServerResponse, status_code=201) +def create_server_object(server: schemas.ServerCreate, db=Depends(get_db)): + """ + Create new Server configuration objects. + + Server configuration objects are used to authorize the + Duva Application against an OnaData server; Users authorize + against a server configuration. + + After creation of a server object, users & 3rd party applications + can utilize the OAuth login route with the server url as the `server_url` query param to authorize users and enable the application to pull & sync forms that the user has access to. + """ + url = URL(server.url) + if not url.scheme or not url.netloc: + raise HTTPException(status_code=400, detail=f"Invalid url {server.url}") + server.url = f"{url.scheme}://{url.netloc}" + if Server.get_using_url(db, server.url): + raise HTTPException( + status_code=400, detail=f"Server with url '{server.url}' already exists." + ) + server = Server.create(db, server) + return server + + +@router.get( + "/api/v1/servers/{obj_id}", + response_model=schemas.ServerResponse, +) +def retrieve_server(obj_id: int, db=Depends(get_db)): + """ + Retrieve a specific server configuration + """ + server = Server.get(db=db, object_id=obj_id) + if not server: + raise HTTPException( + status_code=404, + detail=f"Server configuration with ID {obj_id} can not be found.", + ) + return server + + +@router.get("/api/v1/servers", response_model=List[schemas.ServerResponse]) +def list_servers(db=Depends(get_db)): + """ + List all servers configured to work with the application that users can authorize against. + """ + servers = Server.get_all(db) + return servers diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..3484d1d --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,157 @@ +# Schema Definitions +from enum import Enum + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel +from app.common_tags import SYNC_FAILURES_METADATA, JOB_ID_METADATA + + +class FileStatusEnum(str, Enum): + queued = "Sync Queued" + syncing = "Syncing file" + latest_sync_failed = "Latest Sync Failed" + file_available = "File available" + file_unavailable = "File unavailable" + + +class ServerBase(BaseModel): + url: str + + +class ServerResponse(BaseModel): + id: int + url: str + + class Config: + orm_mode = True + + +class ServerCreate(ServerBase): + client_id: str + client_secret: str + + +class Server(ServerCreate): + id: int + + class Config: + orm_mode = True + + +class User(BaseModel): + username: str + refresh_token: str + server: int + + +class FileBase(BaseModel): + form_id: int + + +class FileListItem(BaseModel): + url: str + id: int + form_id: int + filename: str + file_status: FileStatusEnum = FileStatusEnum.file_unavailable.value + + +class FileCreate(FileBase): + user: int + filename: Optional[str] + is_active: bool = True + meta_data: dict = {SYNC_FAILURES_METADATA: 0, JOB_ID_METADATA: ""} + + +class ConfigurationResponse(BaseModel): + id: int + server_address: str + site_name: str + token_name: str + project_name: str + + class Config: + orm_mode = True + + +class ConfigurationListResponse(BaseModel): + url: Optional[str] + id: int + site_name: str + token_name: str + project_name: str + + class Config: + orm_mode = True + + +class ConfigurationCreateRequest(BaseModel): + server_address: str + site_name: str + token_name: str + project_name: str + token_value: str + + +class ConfigurationPatchRequest(BaseModel): + server_address: Optional[str] + site_name: Optional[str] + token_name: Optional[str] + project_name: Optional[str] + + +class ConfigurationCreate(ConfigurationCreateRequest): + user: int + + +class Configuration(ConfigurationCreate): + id: int + + class Config: + orm_mode = True + + +class File(FileCreate): + id: int + file_status: FileStatusEnum = FileStatusEnum.file_unavailable.value + last_updated: Optional[datetime] = None + last_synced: Optional[datetime] = None + configuration: Optional[Configuration] = None + + class Config: + orm_mode = True + + +class FileResponseBody(FileBase): + id: int + filename: str + file_status: FileStatusEnum = FileStatusEnum.file_unavailable.value + last_updated: Optional[datetime] = None + last_synced: Optional[datetime] = None + download_url: Optional[str] + download_url_valid_till: Optional[str] + configuration_url: Optional[str] + + class Config: + orm_mode = True + + +class FilePatchRequestBody(BaseModel): + configuration_id: int + + +class UserBearerTokenResponse(BaseModel): + bearer_token: str + + +class FileRequestBody(FileBase): + sync_immediately: bool = False + configuration_id: Optional[int] + + +class EventResponse(BaseModel): + status: Optional[str] = "" + name: Optional[str] = "" + object_url: Optional[str] = "" diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..3d451de --- /dev/null +++ b/app/settings.py @@ -0,0 +1,49 @@ +from pydantic import BaseSettings + + +class Settings(BaseSettings): + app_name: str = "Duva" + app_description: str = "" + app_version: str = "0.0.1" + app_host: str = "127.0.0.1" + app_port: int = 8000 + database_url: str = "sqlite:///./sqllite_db.db" + debug: bool = True + sentry_dsn: str = "" + session_same_site: str = "none" + # How long force update is locked after an update is completed + force_update_cooldown: int = 10 + # How long download URLs for HyperFiles should last + download_url_lifetime: int = 3600 + # Generate secret key using: + # dd if=/dev/urandom bs=32 count=1 2>/dev/null | openssl base64 + secret_key: str = "xLLwpyLgT0YumXu77iDYX+HDVBX6djFFVbAWPhhHAHY=" + enable_secure_sessions: bool = False + # check_same_thread: False is only needed for SQLite + # https://fastapi.tiangolo.com/tutorial/sql-databases/#note + db_connect_args: dict = {} + media_path: str = "/app/media" + s3_bucket: str = "hypermind-mvp" + s3_region: str = "eu-west-1" + # For more on tokens, head here: + # https://help.tableau.com/current/server/en-us/security_personal_access_tokens.htm + tableau_server_address: str = "" + tableau_site_name: str = "" + tableau_token_name: str = "" + tableau_token_value: str = "" + redis_url: str = "redis://cache" + redis_host: str = "cache" + redis_port: int = 6379 + redis_db: int = 0 + # CORS Configuration + cors_allowed_origins: list = ["http://localhost:3000", "http://localhost:8000"] + cors_allow_credentials: bool = True + cors_allowed_methods: list = ["*"] + cors_allowed_headers: list = ["*"] + cors_max_age: int = -1 + # HyperFile job settings + job_failure_limit: int = 5 + schedule_all_active: bool = False + + +settings = Settings() diff --git a/app/tests/conftest.py b/app/tests/conftest.py new file mode 100644 index 0000000..3e72571 --- /dev/null +++ b/app/tests/conftest.py @@ -0,0 +1,64 @@ +import fakeredis +import pytest + +from app.main import app +from app.tests.test_base import TestingSessionLocal +from app.utils.utils import get_db, get_redis_client + + +TEST_REDIS_SERVER = fakeredis.FakeServer() + + +def override_get_db(): + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + + +def override_get_redis_client(): + redis_client = fakeredis.FakeRedis(server=TEST_REDIS_SERVER) + try: + yield redis_client + finally: + pass + + +app.dependency_overrides[get_db] = override_get_db +app.dependency_overrides[get_redis_client] = override_get_redis_client + + +@pytest.fixture(scope="function") +def create_user_and_login(): + from app import schemas + from app.models import User, Server + from app.utils.auth_utils import create_session + + db = TestingSessionLocal() + redis_client = fakeredis.FakeRedis(server=TEST_REDIS_SERVER) + server = Server.create( + db, + schemas.ServerCreate( + url="http://testserver", + client_id="some_client_id", + client_secret="some_secret_value", + ), + ) + if User.get_using_username(db, "bob"): + db.query(User).filter(User.username == "bob").delete() + db.commit() + + user = User.create( + db, + schemas.User(username="bob", refresh_token="somes3cr3tvalu3", server=server.id), + ) + + _, bearer_token = create_session(user, redis_client) + yield user, bearer_token + + # Clean up created objects + db.query(User).filter(User.id == user.id).delete() + db.query(Server).filter(Server.id == server.id).delete() + db.commit() + db.close() diff --git a/app/tests/routes/test_configuration.py b/app/tests/routes/test_configuration.py new file mode 100644 index 0000000..54e78ab --- /dev/null +++ b/app/tests/routes/test_configuration.py @@ -0,0 +1,101 @@ +from app.models import Configuration +from app import schemas +from app.tests.test_base import TestBase + + +class TestConfiguration(TestBase): + def _create_configuration(self, auth_credentials: dict, config_data: dict = None): + config_data = ( + config_data + or schemas.ConfigurationCreateRequest( + site_name="test", + server_address="http://test", + token_name="test", + token_value="test", + project_name="default", + ).dict() + ) + response = self.client.post( + "/api/v1/configurations", json=config_data, headers=auth_credentials + ) + + # Returns a 400 exception when configuration already exists + resp = self.client.post( + "/api/v1/configurations", json=config_data, headers=auth_credentials + ) + assert resp.status_code == 400 + assert resp.json() == {"detail": "Configuration already exists"} + return response + + def _cleanup_configs(self): + self.db.query(Configuration).delete() + self.db.commit() + + def test_create_retrieve_config(self, create_user_and_login): + _, jwt = create_user_and_login + jwt = jwt.decode("utf-8") + auth_credentials = {"Authorization": f"Bearer {jwt}"} + response = self._create_configuration(auth_credentials) + + assert response.status_code == 201 + config_id = response.json().get("id") + expected_data = schemas.ConfigurationResponse( + site_name="test", + server_address="http://test", + token_name="test", + project_name="default", + id=config_id, + ).dict() + assert response.json() == expected_data + + # Able to retrieve Tableau Configuration + response = self.client.get( + f"/api/v1/configurations/{config_id}", headers=auth_credentials + ) + assert response.status_code == 200 + assert response.json() == expected_data + self._cleanup_configs() + + def test_delete_config(self, create_user_and_login): + _, jwt = create_user_and_login + jwt = jwt.decode("utf-8") + auth_credentials = {"Authorization": f"Bearer {jwt}"} + response = self._create_configuration(auth_credentials) + assert response.status_code == 201 + config_id = response.json().get("id") + current_count = len(Configuration.get_all(self.db)) + + response = self.client.delete( + f"/api/v1/configurations/{config_id}", headers=auth_credentials + ) + assert response.status_code == 204 + assert len(Configuration.get_all(self.db)) == current_count - 1 + + def test_patch_config(self, create_user_and_login): + _, jwt = create_user_and_login + jwt = jwt.decode("utf-8") + auth_credentials = {"Authorization": f"Bearer {jwt}"} + response = self._create_configuration(auth_credentials) + + assert response.status_code == 201 + config_id = response.json().get("id") + data = schemas.ConfigurationPatchRequest( + site_name="test_change", + ).dict() + expected_data = schemas.ConfigurationResponse( + site_name="test_change", + server_address="http://test", + token_name="test", + project_name="default", + id=config_id, + ).dict() + + # Able to patch Tableau Configuration + response = self.client.patch( + f"/api/v1/configurations/{config_id}", + json=data, + headers=auth_credentials, + ) + assert response.status_code == 200 + assert response.json() == expected_data + self._cleanup_configs() diff --git a/app/tests/routes/test_file.py b/app/tests/routes/test_file.py new file mode 100644 index 0000000..781f69a --- /dev/null +++ b/app/tests/routes/test_file.py @@ -0,0 +1,344 @@ +from unittest.mock import patch + +from httpx._models import Response + +from app import schemas +from app.models import HyperFile, Configuration +from app.tests.test_base import TestBase + + +class TestFileRoute(TestBase): + @patch("app.routers.file.S3Client.generate_presigned_download_url") + @patch("app.routers.file.schedule_hyper_file_cron_job") + @patch("app.utils.onadata_utils.httpx.get") + @patch("app.utils.onadata_utils.get_access_token") + def _create_file( + self, + auth_credentials, + mock_access_token, + mock_get, + mock_schedule_form, + mock_presigned_create, + file_data: dict = None, + ): + mock_presigned_create.return_value = "https://testing.s3.amazonaws.com/1/bob/check_fields.hyper?AWSAccessKeyId=key&Signature=sig&Expires=1609838540" + mock_access_token.return_value = "some_access_token" + mock_schedule_form.return_value = True + mock_get.return_value = Response( + json={ + "url": "https://testserver/api/v1/forms/1", + "formid": 1, + "metadata": [], + "owner": "https://testserver/api/v1/users/bob", + "created_by": "https://testserver/api/v1/users/bob", + "public": True, + "public_data": True, + "public_key": "", + "require_auth": False, + "submission_count_for_today": 0, + "tags": [], + "title": "check_fields", + "users": [ + { + "is_org": False, + "metadata": {}, + "first_name": "Bob", + "last_name": "", + "user": "bob", + "role": "owner", + } + ], + "enketo_url": "https://enketo-stage.ona.io/x/Z7k6kqn9", + "enketo_preview_url": "https://enketo-stage.ona.io/preview/3eZVdQ26", + "enketo_single_submit_url": "https://enketo-stage.ona.io/x/Z7k6kqn9", + "num_of_submissions": 3, + "last_submission_time": "2020-11-16T15:38:28.779972+00:00", + "form_versions": [], + "data_views": [], + "description": "", + "downloadable": True, + "allows_sms": False, + "encrypted": False, + "sms_id_string": "check_fields", + "id_string": "check_fields", + "date_created": "2019-11-21T08:00:06.668073-05:00", + "date_modified": "2020-11-16T10:38:28.744440-05:00", + "uuid": "da3eed4893e74723b555f3255c432ae4", + "bamboo_dataset": "", + "instances_with_geopoints": True, + "instances_with_osm": False, + "version": "201911211300", + "has_hxl_support": False, + "last_updated_at": "2020-11-16T10:38:28.744455-05:00", + "hash": "md5:692e501a01879439dcec79399484de4f", + "is_merged_dataset": False, + "project": "https://testserver/api/v1/projects/500", + }, + status_code=200, + ) + + file_data = ( + file_data + or schemas.FileRequestBody( + server_url="http://testserver", form_id=1, immediate_sync=False + ).dict() + ) + response = self.client.post( + "/api/v1/files", json=file_data, headers=auth_credentials + ) + return response + + def _cleanup_files(self): + self.db.query(HyperFile).delete() + self.db.commit() + + def test_file_create(self, create_user_and_login): + _, jwt = create_user_and_login + num_of_files = len(HyperFile.get_all(self.db)) + jwt = jwt.decode("utf-8") + auth_credentials = {"Authorization": f"Bearer {jwt}"} + response = self._create_file(auth_credentials) + + assert response.status_code == 201 + assert len(HyperFile.get_all(self.db)) == num_of_files + 1 + self._cleanup_files() + + @patch("app.routers.file.S3Client.generate_presigned_download_url") + def test_file_update(self, mock_presigned_create, create_user_and_login): + mock_presigned_create.return_value = "https://testing.s3.amazonaws.com/1/bob/check_fields.hyper?AWSAccessKeyId=key&Signature=sig&Expires=1609838540" + user, jwt = create_user_and_login + num_of_files = len(HyperFile.get_all(self.db)) + jwt = jwt.decode("utf-8") + auth_credentials = {"Authorization": f"Bearer {jwt}"} + response = self._create_file(auth_credentials) + + assert response.status_code == 201 + assert len(HyperFile.get_all(self.db)) == num_of_files + 1 + + file_id = response.json().get("id") + # Test fails with 400 if update with non-existant + # configuration + data = {"configuration_id": 10230} + response = self.client.patch( + f"/api/v1/files/{file_id}", json=data, headers=auth_credentials + ) + + assert response.status_code == 400 + assert response.json() == { + "detail": "Tableau configuration with ID 10230 not found" + } + + # Correctly updates tableau configuration + configuration = Configuration.create( + self.db, + schemas.ConfigurationCreate( + user=user.id, + server_address="http://testserver", + site_name="test", + token_name="test", + token_value="test", + project_name="test", + ), + ) + data = {"configuration_id": configuration.id} + response = self.client.patch( + f"/api/v1/files/{file_id}", json=data, headers=auth_credentials + ) + + assert response.status_code == 200 + assert ( + response.json().get("configuration_url") + == f"http://testserver/api/v1/configurations/{configuration.id}" + ) + self._cleanup_files() + + @patch("app.routers.file.S3Client.delete") + def test_file_delete(self, mock_s3_delete, create_user_and_login): + mock_s3_delete.return_value = True + _, jwt = create_user_and_login + num_of_files = len(HyperFile.get_all(self.db)) + jwt = jwt.decode("utf-8") + auth_credentials = {"Authorization": f"Bearer {jwt}"} + response = self._create_file(auth_credentials) + + assert response.status_code == 201 + assert len(HyperFile.get_all(self.db)) == num_of_files + 1 + num_of_files += 1 + file_id = response.json().get("id") + + response = self.client.delete( + f"/api/v1/files/{file_id}", headers=auth_credentials + ) + assert response.status_code == 204 + assert len(HyperFile.get_all(self.db)) == num_of_files - 1 + self._cleanup_files() + + @patch("app.routers.file.S3Client.generate_presigned_download_url") + def test_file_with_config(self, mock_presigned_create, create_user_and_login): + mock_presigned_create.return_value = "https://testing.s3.amazonaws.com/1/bob/check_fields.hyper?AWSAccessKeyId=key&Signature=sig&Expires=1609838540" + user, jwt = create_user_and_login + num_of_files = len(HyperFile.get_all(self.db)) + jwt = jwt.decode("utf-8") + auth_credentials = {"Authorization": f"Bearer {jwt}"} + config = Configuration.create( + self.db, + schemas.ConfigurationCreate( + user=user.id, + server_address="http://test", + site_name="test", + project_name="test", + token_name="test", + token_value="test", + ), + ) + file_data = schemas.FileRequestBody( + server_url="http://testserver", + form_id=1, + immediate_sync=False, + configuration_id=config.id, + ).dict() + response = self._create_file(auth_credentials, file_data=file_data) + response_json = response.json() + response_json.pop("download_url_valid_till") + expected_data = { + "download_url": "https://testing.s3.amazonaws.com/1/bob/check_fields.hyper?AWSAccessKeyId=key&Signature=sig&Expires=1609838540", + "filename": "check_fields.hyper", + "file_status": schemas.FileStatusEnum.queued.value, + "form_id": 1, + "id": 1, + "last_synced": None, + "last_updated": None, + "configuration_url": f"http://testserver/api/v1/configurations/{config.id}", + } + + assert response.status_code == 201 + assert response_json == expected_data + assert len(HyperFile.get_all(self.db)) == num_of_files + 1 + + # Able to change Tableau Server Config + config_2 = Configuration.create( + self.db, + schemas.ConfigurationCreate( + user=user.id, + server_address="http://tes2", + site_name="tes2t", + project_name="test2", + token_name="test2", + token_value="test2", + ), + ) + file_data = schemas.FilePatchRequestBody(configuration_id=config_2.id).dict() + response = self.client.patch( + "/api/v1/files/1", json=file_data, headers=auth_credentials + ) + assert response.status_code == 200 + response_json = response.json() + assert ( + response_json.get("configuration_url") + == f"http://testserver/api/v1/configurations/{config_2.id}" + ) + # Delete Tableau Configurations + self.db.query(Configuration).delete() + self.db.commit() + self._cleanup_files() + + def test_file_list(self, create_user_and_login): + user, jwt = create_user_and_login + jwt = jwt.decode("utf-8") + auth_credentials = {"Authorization": f"Bearer {jwt}"} + self._create_file(auth_credentials) + + response = self.client.get("/api/v1/files", headers=auth_credentials) + assert response.status_code == 200 + assert len(user.files) == 1 + assert len(response.json()) == len(user.files) + hyperfile = user.files[0] + expected_data = schemas.FileListItem( + url=f"http://testserver/api/v1/files/{hyperfile.id}", + id=hyperfile.id, + form_id=hyperfile.form_id, + filename=hyperfile.filename, + ).dict() + expected_data.update({"file_status": schemas.FileStatusEnum.queued.value}) + assert response.json()[0] == expected_data + + # Test filtering + response = self.client.get( + "/api/v1/files?form_id=000", headers=auth_credentials + ) + assert response.status_code == 200 + assert len(response.json()) == 0 + + response = self.client.get("/api/v1/files?form_id=1", headers=auth_credentials) + assert response.status_code == 200 + assert len(response.json()) == len(user.files) + + self._cleanup_files() + + @patch("app.routers.file.start_csv_import_to_hyper") + def test_trigger_hyper_file_sync( + self, mock_start_csv_import, create_user_and_login + ): + _, jwt = create_user_and_login + jwt = jwt.decode("utf-8") + auth_credentials = {"Authorization": f"Bearer {jwt}"} + response = self._create_file(auth_credentials) + + # User is able to trigger a force update + file_id = response.json().get("id") + + with patch("app.utils.utils.redis.Redis"): + response = self.client.post( + f"/api/v1/files/{file_id}/sync", headers=auth_credentials + ) + + assert response.status_code == 202 + expected_json = response.json() + update_count = mock_start_csv_import.call_count + + # Returns a 202 status_code when update is on-going + # and doesn't trigger another update + response = self.client.post( + f"/api/v1/files/{file_id}/sync", headers=auth_credentials + ) + assert response.status_code == 202 + assert update_count == mock_start_csv_import.call_count + assert response.json() == expected_json + self._cleanup_files() + + @patch("app.routers.file.S3Client.generate_presigned_download_url") + def test_file_get(self, mock_presigned_create, create_user_and_login): + mock_presigned_create.return_value = "https://testing.s3.amazonaws.com/1/bob/check_fields.hyper?AWSAccessKeyId=key&Signature=sig&Expires=1609838540" + user, jwt = create_user_and_login + jwt = jwt.decode("utf-8") + auth_credentials = {"Authorization": f"Bearer {jwt}"} + self._create_file(auth_credentials) + + hyperfile = user.files[0] + response = self.client.get( + f"/api/v1/files/{hyperfile.id}", headers=auth_credentials + ) + expected_keys = [ + "form_id", + "id", + "filename", + "file_status", + "last_updated", + "last_synced", + "download_url", + "download_url_valid_till", + "configuration_url", + ] + assert response.status_code == 200 + assert list(response.json().keys()) == expected_keys + assert response.json()["id"] == hyperfile.id + self._cleanup_files() + + def test_file_get_raises_error_on_invalid_id(self, create_user_and_login): + user, jwt = create_user_and_login + jwt = jwt.decode("utf-8") + auth_credentials = {"Authorization": f"Bearer {jwt}"} + + response = self.client.get("/api/v1/files/form_id=1", headers=auth_credentials) + assert response.status_code == 400 + assert response.json() == {"detail": "Invalid file ID"} diff --git a/app/tests/routes/test_oauth.py b/app/tests/routes/test_oauth.py new file mode 100644 index 0000000..da48935 --- /dev/null +++ b/app/tests/routes/test_oauth.py @@ -0,0 +1,134 @@ +from unittest.mock import patch + +from httpx._models import Response + +from app import schemas +from app.models import Server, User +from app.tests.test_base import TestBase + + +class TestOAuthRoute(TestBase): + def setup_class(cls): + super().setup_class() + cls.mock_server = Server.create( + cls.db, + schemas.ServerCreate( + url="http://testserver", + client_id="some_client_id", + client_secret="some_client_secret", + ), + ) + cls.mock_server_2 = Server.create( + cls.db, + schemas.ServerCreate( + url="http://dupli.testserver", + client_id="some_client_id", + client_secret="some_client_secret", + ), + ) + + def teardown_class(cls): + cls.db.query(Server).filter(Server.id == cls.mock_server.id).delete() + cls.db.query(Server).filter(Server.id == cls.mock_server_2.id).delete() + cls.db.commit() + super().teardown_class() + + @patch("app.routers.oauth.uuid.uuid4") + def test_oauth_login_redirects(self, mock_uuid): + """ + Test that the "oauth/login" route redirects + to the correct URL + """ + # Ensure a 400 is raised when a user tries + # to login to a server that isn't configured + response = self.client.get("/api/v1/oauth/login?server_url=http://testserve") + assert response.status_code == 400 + assert response.json() == {"detail": "Server not configured"} + + mock_uuid.return_value = "some_uuid" + response = self.client.get("/api/v1/oauth/login?server_url=http://testserver") + assert ( + response.url + == f"http://testserver/o/authorize?client_id={self.mock_server.client_id}&response_type=code&state=some_uuid" + ) + + @patch("app.routers.oauth.httpx") + @patch("app.routers.oauth.redis.Redis.get") + def test_oauth_callback(self, mock_redis_get, mock_httpx): + """ + Test that the OAuth2 callback URL confirms the + auth state and creates a User object + """ + mock_redis_get.return_value = None + url = "/api/v1/oauth/callback?code=some_code&state=some_uuid" + response = self.client.get(url) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authorization state can not be confirmed." + } + + assert len(User.get_all(self.db)) == 0 + mock_auth_state = f'{{"server_id": {self.mock_server.id}}}' + mock_redis_get.return_value = mock_auth_state + mock_httpx.post.return_value = Response( + json={ + "access_token": "some_access_token", + "token_type": "Bearer", + "expires_in": 36000, + "refresh_token": "some_refresh_token", + "scope": "read write groups", + }, + status_code=200, + ) + mock_httpx.get.return_value = Response( + json={ + "api_token": "some_api_token", + "temp_token": "some_temp_token", + "city": "Nairobi", + "country": "Kenya", + "gravatar": "avatar.png", + "name": "Bob", + "email": "bob@user.com", + "organization": "", + "require_auth": False, + "twitter": "", + "url": "http://testserver/api/v1/profiles/bob", + "user": "http://testserver/api/v1/users/bob", + "username": "bob", + "website": "", + }, + status_code=200, + ) + self.redis_client.set("some_uuid", mock_auth_state) + response = self.client.get(url) + assert response.status_code == 200 + assert len(User.get_all(self.db)) == 1 + assert "bearer_token" in response.json().keys() + + # Create user from different server with same username + self.client.cookies.clear() + mock_auth_state = f'{{"server_id": {self.mock_server_2.id}}}' + mock_redis_get.return_value = mock_auth_state + mock_httpx.get.return_value = Response( + json={ + "api_token": "some_api_token", + "temp_token": "some_temp_token", + "city": "Nairobi", + "country": "Kenya", + "gravatar": "avatar.png", + "name": "Bob", + "email": "bob@user.com", + "organization": "", + "require_auth": False, + "twitter": "", + "url": "http://dupli.testserver/api/v1/profiles/bob", + "user": "http://dupli.testserver/api/v1/users/bob", + "username": "bob", + "website": "", + }, + status_code=200, + ) + self.redis_client.set("some_uuid", mock_auth_state) + response = self.client.get(url) + assert response.status_code == 200 + assert len(User.get_all(self.db)) == 2 diff --git a/app/tests/routes/test_server.py b/app/tests/routes/test_server.py new file mode 100644 index 0000000..ff5b082 --- /dev/null +++ b/app/tests/routes/test_server.py @@ -0,0 +1,55 @@ +from typing import Tuple +from fastapi.responses import Response +from app import schemas +from app.models import Server +from app.tests.test_base import TestBase + + +class TestServerRoute(TestBase): + def _create_server(self, url: str = "http://testserver") -> Tuple[Response, int]: + initial_count = len(Server.get_all(self.db)) + data = schemas.ServerCreate( + url=url, + client_id="some_client_id", + client_secret="some_client_secret", + ).dict() + response = self.client.post("/api/v1/servers", json=data) + return response, initial_count + + def _cleanup_server(self): + self.db.query(Server).filter(Server.url == "http://testserver").delete() + self.db.commit() + + def test_bad_url_rejected(self): + url = "bad_url" + response, _ = self._create_server(url=url) + assert response.status_code == 400 + assert response.json() == {"detail": f"Invalid url {url}"} + + def test_create_server(self): + response, initial_count = self._create_server() + expected_keys = ["id", "url"] + assert response.status_code == 201 + assert expected_keys == list(response.json().keys()) + assert len(Server.get_all(self.db)) == initial_count + 1 + + # Test trying to create a different server with the same URL + # returns a 400 response + response, _ = self._create_server() + assert response.status_code == 400 + assert response.json() == { + "detail": "Server with url 'http://testserver' already exists." + } + self._cleanup_server() + + def test_retrieve_server(self): + """ + Test the retrieve server configuration routes + """ + response, _ = self._create_server() + expected_response = response.json() + server_id = expected_response.get("id") + response = self.client.get(f"/api/v1/servers/{server_id}") + assert response.status_code == 200 + assert response.json() == expected_response + self._cleanup_server() diff --git a/app/tests/test_base.py b/app/tests/test_base.py new file mode 100644 index 0000000..83b68d4 --- /dev/null +++ b/app/tests/test_base.py @@ -0,0 +1,35 @@ +import os + +import fakeredis +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm.session import sessionmaker + +from app.main import app +from app.database import Base + + +SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" + +# Delete existing test database +if os.path.exists("./test.db"): + os.remove("./test.db") + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base.metadata.create_all(bind=engine) + + +class TestBase: + @classmethod + def setup_class(cls): + cls.client = TestClient(app=app) + cls.db = TestingSessionLocal() + cls.redis_client = fakeredis.FakeRedis() + + @classmethod + def teardown_class(cls): + cls.db.close() diff --git a/app/tests/test_main.py b/app/tests/test_main.py new file mode 100644 index 0000000..7aea20d --- /dev/null +++ b/app/tests/test_main.py @@ -0,0 +1,14 @@ +from app.tests.test_base import TestBase +from app.settings import settings + + +class TestMain(TestBase): + def test_home_route(self): + response = self.client.get("/") + assert response.status_code == 200 + assert response.json() == { + "app_name": settings.app_name, + "app_description": settings.app_description, + "app_version": settings.app_version, + "docs_url": "http://testserver/docs", + } diff --git a/app/tests/utils/test_hyper_utils.py b/app/tests/utils/test_hyper_utils.py new file mode 100644 index 0000000..265ebe7 --- /dev/null +++ b/app/tests/utils/test_hyper_utils.py @@ -0,0 +1,117 @@ +""" +TEsts for the hyper_utils module +""" +from unittest.mock import patch, MagicMock +from sqlalchemy.orm.attributes import flag_modified + +from app.common_tags import JOB_ID_METADATA, SYNC_FAILURES_METADATA +from app.models import HyperFile +from app.schemas import FileCreate, FileStatusEnum +from app.settings import settings +from app.tests.test_base import TestBase +from app.utils.hyper_utils import ( + schedule_hyper_file_cron_job, + cancel_hyper_file_job, + handle_hyper_file_job_completion, +) + + +class TestHyperUtils(TestBase): + @patch("app.utils.hyper_utils.schedule_cron_job") + def _schedule_hyper_file_cron_job( + self, mock_schedule_cron_job, user, job_mock: MagicMock = MagicMock + ): + def dummy_func(a: str): + print(a) + + mock_schedule_cron_job.side_effect = job_mock + + hyperfile = HyperFile.create( + self.db, + FileCreate( + user=user.id, filename="test.hyper", is_active=True, form_id="111" + ), + ) + + schedule_hyper_file_cron_job(dummy_func, hyperfile.id, db=self.db) + self.db.refresh(hyperfile) + + assert mock_schedule_cron_job.called is True + return hyperfile + + def test_schedule_hyper_file_cron_job(self, create_user_and_login): + user, _ = create_user_and_login + job_mock = MagicMock + job_mock.id = "some_id" + hyperfile = self._schedule_hyper_file_cron_job(user=user, job_mock=job_mock) + expected_metadata = {JOB_ID_METADATA: job_mock.id, SYNC_FAILURES_METADATA: 0} + # Ensure that the HyperFiles' metadata is updated accordingly + assert hyperfile.meta_data == expected_metadata + # Clean up created hyper file + self.db.query(HyperFile).delete() + self.db.commit() + + @patch("app.utils.hyper_utils.cancel_job") + def test_cancel_hyper_file_job(self, mock_cancel_job, create_user_and_login): + user, _ = create_user_and_login + job_mock = MagicMock + job_mock.id = "some_id" + hyperfile = self._schedule_hyper_file_cron_job(user=user, job_mock=job_mock) + self.db.refresh(hyperfile) + hyperfile.meta_data[SYNC_FAILURES_METADATA] = 4 + flag_modified(hyperfile, "meta_data") + self.db.commit() + self.db.refresh(hyperfile) + + assert hyperfile.meta_data == { + JOB_ID_METADATA: job_mock.id, + SYNC_FAILURES_METADATA: 4, + } + # Ensure that cancelling a hyper file job updates it's metadata + cancel_hyper_file_job(hyperfile.id, job_mock.id, db=self.db) + self.db.refresh(hyperfile) + expected_metadata = {JOB_ID_METADATA: "", SYNC_FAILURES_METADATA: 0} + assert hyperfile.meta_data == expected_metadata + assert mock_cancel_job.called is True + self.db.query(HyperFile).delete() + self.db.commit() + + @patch("app.utils.hyper_utils.cancel_job") + def test_handle_hyper_file_job_completion( + self, mock_cancel_job, create_user_and_login + ): + user, _ = create_user_and_login + job_mock = MagicMock + job_mock.id = "some_id" + hyperfile = self._schedule_hyper_file_cron_job(user=user, job_mock=job_mock) + self.db.refresh(hyperfile) + failure_count = hyperfile.meta_data[SYNC_FAILURES_METADATA] + + # Test that the failure count is updated on job failure + handle_hyper_file_job_completion( + hyperfile.id, + self.db, + job_succeeded=False, + file_status=FileStatusEnum.latest_sync_failed, + ) + self.db.refresh(hyperfile) + assert hyperfile.meta_data[SYNC_FAILURES_METADATA] == failure_count + 1 + assert hyperfile.file_status == FileStatusEnum.latest_sync_failed + assert mock_cancel_job.called is False + + # Test job is cancelled once job failure limit is reached + hyperfile.meta_data[SYNC_FAILURES_METADATA] = settings.job_failure_limit + flag_modified(hyperfile, "meta_data") + self.db.commit() + + handle_hyper_file_job_completion( + hyperfile.id, + self.db, + job_succeeded=False, + file_status=FileStatusEnum.latest_sync_failed, + ) + self.db.refresh(hyperfile) + assert hyperfile.meta_data == {JOB_ID_METADATA: "", SYNC_FAILURES_METADATA: 0} + assert mock_cancel_job.called is True + self.db.query(HyperFile).delete() + self.db.commit() diff --git a/app/tests/utils/test_onadata_utils.py b/app/tests/utils/test_onadata_utils.py new file mode 100644 index 0000000..11a310f --- /dev/null +++ b/app/tests/utils/test_onadata_utils.py @@ -0,0 +1,45 @@ +""" +Tests for the onadata_utils module +""" +from unittest.mock import patch + +from httpx._models import Response + +from app.models import Server, User +from app.tests.test_base import TestBase +from app.utils.onadata_utils import get_access_token + + +class TestOnadataUtils(TestBase): + @patch("app.utils.onadata_utils.httpx.post") + def test_get_access_token(self, mock_httpx_post, create_user_and_login): + """ + Test the get_access_token function correctly retrieves the + access_token and resets the refresh_token + """ + user, jwt = create_user_and_login + mock_httpx_post.return_value = Response( + json={ + "refresh_token": "new_token", + "access_token": "new_access_token", + "expiresIn": "somedate", + }, + status_code=200, + ) + old_refresh_token = user.refresh_token + server = Server.get(self.db, user.server) + ret = get_access_token(user, server, self.db) + + assert ret == "new_access_token" + mock_httpx_post.assert_called_with( + f"{server.url}/o/token/", + data={ + "grant_type": "refresh_token", + "refresh_token": User.decrypt_value(user.refresh_token), + "client_id": server.client_id, + }, + auth=(server.client_id, Server.decrypt_value(server.client_secret)), + ) + user = User.get(self.db, user.id) + assert user.refresh_token != old_refresh_token + assert User.decrypt_value(user.refresh_token) == "new_token" diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/auth_utils.py b/app/utils/auth_utils.py new file mode 100644 index 0000000..f1a1ed7 --- /dev/null +++ b/app/utils/auth_utils.py @@ -0,0 +1,106 @@ +# Authentication/Authorization Utilities +import uuid +from datetime import datetime, timedelta +from typing import Tuple + +import redis +import jwt +from fastapi import Request, Depends +from fastapi.exceptions import HTTPException + +from app import schemas +from app.settings import settings +from app.models import User +from app.utils.utils import get_db, get_redis_client + + +def create_session( + user: schemas.User, redis_client: redis.Redis, request: Request = None +) -> Tuple[Request, str]: + session_key = f"{user.username}-sessions" + session_id = str(uuid.uuid4()) + expiry_time = datetime.now() + timedelta(days=14) + expiry_timestamp = datetime.timestamp(expiry_time) + stored_session = session_id + f":{expiry_timestamp}" + redis_client.sadd(session_key, stored_session) + jwt_data = { + "username": user.username, + "session-id": session_id, + "server_id": user.server, + } + encoded_jwt = jwt.encode(jwt_data, settings.secret_key, algorithm="HS256") + + if request: + request.session["session-data"] = jwt_data + return request, encoded_jwt + + +class IsAuthenticatedUser: + def __init__(self, raise_errors: bool = True) -> None: + self.raise_errors = raise_errors + + def __call__( + self, request: Request, db=Depends(get_db), redis=Depends(get_redis_client) + ): + def _raise_error(exception: Exception): + if self.raise_errors: + if request.session: + request.session.clear() + raise exception + return None + + self.db = db + self.redis_client = redis + + session_data = request.session.get("session-data") + invalid_credentials_error = HTTPException( + status_code=401, detail="Invalid authentication credentials" + ) + if not session_data: + auth = request.headers.get("authorization") + if auth: + auth_type, value = auth.split(" ") + + if auth_type != "Bearer": + return _raise_error(invalid_credentials_error) + + try: + session_data = jwt.decode( + value, settings.secret_key, algorithms=["HS256"] + ) + except jwt.DecodeError: + return _raise_error(invalid_credentials_error) + + if session_data: + session_id = session_data.get("session-id") + username = session_data.get("username") + server_id = session_data.get("server_id") + session_key = f"{username}-sessions" + + if self.is_valid_session(session_id=session_id, session_key=session_key): + user = User.get_using_server_and_username(self.db, username, server_id) + if not user: + return _raise_error(invalid_credentials_error) + return user + + return _raise_error( + HTTPException(status_code=401, detail="Authentication required") + ) + + def is_valid_session(self, session_key: str, session_id: str) -> bool: + sessions = self.redis_client.smembers(session_key) + for session in sessions: + sess_id, expiry = session.decode("utf-8").split(":") + + try: + expiry = int(expiry) + except ValueError: + expiry = float(expiry) + + if expiry and datetime.fromtimestamp(expiry) > datetime.now(): + if sess_id == session_id: + return True + else: + self.redis_client.srem(session_key, session) + + return False diff --git a/app/utils/hyper_utils.py b/app/utils/hyper_utils.py new file mode 100644 index 0000000..ad22b51 --- /dev/null +++ b/app/utils/hyper_utils.py @@ -0,0 +1,251 @@ +""" +File containing utility functions related to a Hyper Database / HyperFile +""" +from typing import List, Callable +from pathlib import Path + +import pandas as pd +from datetime import datetime +from pandas.errors import EmptyDataError +from rq.job import Job +from sqlalchemy.orm.session import Session +from sqlalchemy.orm.attributes import flag_modified +from tableauhyperapi import ( + SqlType, + Connection, + HyperProcess, + TableName, + escape_string_literal, + TableDefinition, + CreateMode, +) + +from app.database import SessionLocal +from app.schemas import FileStatusEnum +from app.settings import settings +from app.common_tags import JOB_ID_METADATA, SYNC_FAILURES_METADATA +from app.jobs.scheduler import schedule_cron_job, cancel_job +from app.libs.s3.client import S3Client +from app.libs.tableau.client import TableauClient +from app.models import HyperFile, Configuration + + +def element_type_to_hyper_sql_type(elem_type: str) -> SqlType: + type_map = { + "integer": SqlType.big_int, + "decimal": SqlType.double, + "text": SqlType.text, + } + return type_map.get(elem_type) + + +def _pandas_type_to_hyper_sql_type(_type: str) -> SqlType: + # Only supports text and numeric fields, more may be added later + type_map = { # noqa + "b": SqlType.text, + "i": SqlType.big_int, + "u": SqlType.text, + "f": SqlType.double, + "c": SqlType.text, + "O": SqlType.text, + "S": SqlType.text, + "a": SqlType.text, + "U": SqlType.text, + } + return type_map.get(_type) + + +def _import_csv_to_hyperfile( + path: str, + csv_path: str, + process: HyperProcess, + table_name: TableName = TableName("Extract", "Extract"), + null_field: str = "NULL", + delimiter: str = ",", +) -> int: + """ + Imports CSV data into a HyperFile + """ + with Connection(endpoint=process.endpoint, database=path) as connection: + command = ( + f"COPY {table_name} from {escape_string_literal(csv_path)} with " + f"(format csv, NULL '{null_field}', delimiter '{delimiter}', header)" + ) + count = connection.execute_command(command=command) + return count + + +def _prep_csv_for_import(csv_path: Path) -> List[TableDefinition.Column]: + """ + Creates a schema definition from an Onadata CSV Export + DISCLAIMER: This function doesn't actually try to derive the columns + type. It returns every column as a string column + """ + columns: List[SqlType] = [] + df = pd.read_csv(csv_path, na_values=["n/a", ""]) + df = df.convert_dtypes() + for name, dtype in df.dtypes.iteritems(): + column = TableDefinition.Column( + name, _pandas_type_to_hyper_sql_type(dtype.kind)() + ) + columns.append(column) + # Save dataframe to CSV as the dataframe is more cleaner + # in most cases. We also don't want the headers to be within + # the CSV as Hyper picks the header as a value + with open(csv_path, "w") as f: + f.truncate(0) + df.to_csv(csv_path, na_rep="NULL", header=True, index=False) + return columns + + +def handle_csv_import_to_hyperfile( + hyperfile: HyperFile, csv_path: str, process: HyperProcess, db: Session +) -> int: + file_path = hyperfile.retrieve_latest_file(db) + s3_destination = hyperfile.get_file_path(db) + configuration = hyperfile.configuration + + return handle_csv_import( + file_path=file_path, + csv_path=csv_path, + process=process, + configuration=configuration, + s3_destination=s3_destination, + ) + + +def handle_csv_import( + file_path: str, + csv_path: Path, + process: HyperProcess, + configuration: Configuration = None, + s3_destination: str = None, +) -> int: + """ + Handles CSV Import to Hyperfile + """ + table_name = TableName("Extract", "Extract") + try: + columns = _prep_csv_for_import(csv_path=csv_path) + except EmptyDataError: + return 0 + else: + with Connection( + endpoint=process.endpoint, + database=file_path, + create_mode=CreateMode.CREATE_AND_REPLACE, + ) as connection: + connection.catalog.create_schema("Extract") + extract_table = TableDefinition(table_name, columns=columns) + connection.catalog.create_table(extract_table) + + import_count = _import_csv_to_hyperfile( + path=file_path, + csv_path=str(csv_path), + table_name=table_name, + process=process, + ) + + # Store hyper file in S3 Storage + s3_client = S3Client() + s3_client.upload(file_path, s3_destination or Path(file_path).name) + + if configuration: + tableau_client = TableauClient(configuration=configuration) + tableau_client.publish_hyper(file_path) + + return import_count + + +def schedule_hyper_file_cron_job( + job_func: Callable, + hyperfile_id: int, + extra_job_args: list = [], + job_id_meta_tag: str = JOB_ID_METADATA, + job_failure_counter_meta_tag: str = SYNC_FAILURES_METADATA, + db: SessionLocal = SessionLocal(), +) -> Job: + """ + Schedules a Job that should run on a cron schedule for a particular + Hyperfile + """ + hf: HyperFile = HyperFile.get(db, hyperfile_id) + metadata = hf.meta_data or {} + + job: Job = schedule_cron_job(job_func, [hyperfile_id] + extra_job_args) + # Set meta tags to help track the started CRON Job + metadata[job_id_meta_tag] = job.id + metadata[job_failure_counter_meta_tag] = 0 + + hf.meta_data = metadata + flag_modified(hf, "meta_data") + db.commit() + return job + + +def cancel_hyper_file_job( + hyperfile_id: int, + job_id: str, + db: SessionLocal = SessionLocal(), + job_name: str = "app.utils.onadata_utils.start_csv_import_to_hyper_job", + job_id_meta_tag: str = JOB_ID_METADATA, + job_failure_counter_meta_tag: str = SYNC_FAILURES_METADATA, +) -> None: + """ + Cancels a scheduler Job related to a Hyper file and resets the job failure + counter and meta tag + """ + hf: HyperFile = HyperFile.get(db, hyperfile_id) + metadata = hf.meta_data or {} + + cancel_job(job_id, [hyperfile_id], job_name) + if metadata.get(job_id_meta_tag): + metadata[job_id_meta_tag] = "" + metadata[job_failure_counter_meta_tag] = 0 + hf.meta_data = metadata + flag_modified(hf, "meta_data") + db.commit() + + +def handle_hyper_file_job_completion( + hyperfile_id: int, + db: SessionLocal = SessionLocal(), + job_succeeded: bool = True, + object_updated: bool = True, + file_status: str = FileStatusEnum.file_available.value, + job_id_meta_tag: str = JOB_ID_METADATA, + job_failure_counter_meta_tag: str = SYNC_FAILURES_METADATA, +): + """ + Handles updating a HyperFile according to the outcome of a running Job; Updates + file status & tracks the jobs current failure counter. + """ + hf: HyperFile = HyperFile.get(db, hyperfile_id) + metadata = hf.meta_data or {} + + if job_succeeded: + if object_updated: + hf.last_updated = datetime.now() + metadata[job_failure_counter_meta_tag] = 0 + else: + failure_count = metadata.get(job_failure_counter_meta_tag) + if isinstance(failure_count, int): + metadata[job_failure_counter_meta_tag] = failure_count + 1 + else: + metadata[job_failure_counter_meta_tag] = failure_count = 0 + + if failure_count >= settings.job_failure_limit and hf.is_active: + cancel_hyper_file_job( + hyperfile_id, + metadata.get(job_id_meta_tag), + db=db, + job_id_meta_tag=job_id_meta_tag, + job_failure_counter_meta_tag=job_failure_counter_meta_tag, + ) + db.refresh(hf) + hf.is_active = False + + hf.meta_data = metadata + hf.file_status = file_status + flag_modified(hf, "meta_data") + db.commit() diff --git a/app/utils/onadata_utils.py b/app/utils/onadata_utils.py new file mode 100644 index 0000000..b55aed1 --- /dev/null +++ b/app/utils/onadata_utils.py @@ -0,0 +1,250 @@ +# Utility functions for Ona Data Aggregate Servers +import time +from pathlib import Path +from tempfile import NamedTemporaryFile +from typing import Optional + +import httpx +import sentry_sdk +from fastapi_cache import caches +from sqlalchemy.orm.session import Session +from tableauhyperapi import HyperProcess, Telemetry + +from app import schemas +from app.common_tags import ( + ONADATA_TOKEN_ENDPOINT, + ONADATA_FORMS_ENDPOINT, + ONADATA_USER_ENDPOINT, + JOB_ID_METADATA, + HYPER_PROCESS_CACHE_KEY, +) +from app.database import SessionLocal +from app.models import HyperFile, Server, User +from app.settings import settings +from app.utils.hyper_utils import ( + handle_csv_import_to_hyperfile, + handle_hyper_file_job_completion, + schedule_hyper_file_cron_job, +) + + +class UnsupportedForm(Exception): + pass + + +class ConnectionRequestError(Exception): + pass + + +class CSVExportFailure(Exception): + pass + + +class DoesNotExist(Exception): + pass + + +def get_access_token(user: User, server: Server, db: SessionLocal) -> Optional[str]: + url = f"{server.url}{ONADATA_TOKEN_ENDPOINT}" + data = { + "grant_type": "refresh_token", + "refresh_token": user.decrypt_value(user.refresh_token), + "client_id": server.client_id, + } + resp = httpx.post( + url, + data=data, + auth=(server.client_id, server.decrypt_value(server.client_secret)), + ) + if resp.status_code == 200: + resp = resp.json() + user = User.get(db, user.id) + user.refresh_token = user.encrypt_value(resp.get("refresh_token")) + db.commit() + return resp.get("access_token") + return None + + +def _get_csv_export( + url: str, headers: dict = None, temp_token: str = None, retries: int = 0 +): + def _write_export_to_temp_file(export_url, headers, retry: int = 0): + print("Writing to temporary CSV Export to temporary file.") + retry = 0 or retry + status = 0 + with NamedTemporaryFile(delete=False, suffix=".csv") as export: + with httpx.stream("GET", export_url, headers=headers) as response: + if response.status_code == 200: + for chunk in response.iter_bytes(): + export.write(chunk) + return export + status = response.status_code + if retry < 3: + print( + f"Retrying export write: Status {status}, Retry {retry}, URL {export_url}" + ) + _write_export_to_temp_file( + export_url=export_url, headers=headers, retry=retry + 1 + ) + + print("Checking on export status.") + resp = httpx.get(url, headers=headers) + + if resp.status_code == 202: + resp = resp.json() + job_status = resp.get("job_status") + if "export_url" in resp and job_status == "SUCCESS": + export_url = resp.get("export_url") + if temp_token: + export_url += f"&temp_token={temp_token}" + return _write_export_to_temp_file(export_url, headers) + elif job_status == "FAILURE": + reason = resp.get("progress") + raise CSVExportFailure(f"CSV Export Failure. Reason: {reason}") + + job_uuid = resp.get("job_uuid") + if job_uuid: + print(f"Waiting for CSV Export to be ready. Job UUID: {job_uuid}") + url += f"&job_uuid={job_uuid}" + + if retries < 3: + time.sleep(30 * (retries + 1)) + return _get_csv_export( + url, headers=headers, temp_token=temp_token, retries=retries + 1 + ) + else: + raise ConnectionRequestError( + f"Failed to retrieve CSV Export. URL: {url}, took too long for CSV Export to be ready" + ) + else: + raise ConnectionRequestError( + f"Failed to retrieve CSV Export. URL: {url}, Status Code: {resp.status_code}" + ) + + +def get_csv_export( + hyperfile: HyperFile, user: schemas.User, server: schemas.Server, db: SessionLocal +) -> str: + """ + Retrieves a CSV Export for an XForm linked to a Hyperfile + """ + bearer_token = get_access_token(user, server, db) + headers = { + "user-agent": f"{settings.app_name}/{settings.app_version}", + "Authorization": f"Bearer {bearer_token}", + } + form_url = f"{server.url}{ONADATA_FORMS_ENDPOINT}/{hyperfile.form_id}" + resp = httpx.get(form_url + ".json", headers=headers) + if resp.status_code == 200: + form_data = resp.json() + public = form_data.get("public") + url = f"{form_url}/export_async.json?format=csv" + temp_token = None + + # Retrieve auth credentials if XForm is private + # Onadatas' Export Endpoint only support TempToken or Basic Authentication + if not public: + resp = httpx.get( + f"{server.url}{ONADATA_USER_ENDPOINT}.json", headers=headers + ) + temp_token = resp.json().get("temp_token") + csv_export = _get_csv_export(url, headers, temp_token) + if csv_export: + return Path(csv_export.name) + + +def start_csv_import_to_hyper( + hyperfile_id: int, process: HyperProcess, schedule_cron: bool = True +): + db = SessionLocal() + hyperfile: HyperFile = HyperFile.get(db, object_id=hyperfile_id) + job_status: str = schemas.FileStatusEnum.file_available.value + err: Exception = None + + if hyperfile: + user = User.get(db, hyperfile.user) + server = Server.get(db, user.server) + + hyperfile.file_status = schemas.FileStatusEnum.syncing.value + db.commit() + db.refresh(hyperfile) + + try: + export = get_csv_export(hyperfile, user, server, db) + + if export: + handle_csv_import_to_hyperfile(hyperfile, export, process, db) + + if schedule_cron and not hyperfile.meta_data.get(JOB_ID_METADATA): + schedule_hyper_file_cron_job( + start_csv_import_to_hyper_job, hyperfile_id + ) + else: + job_status = schemas.FileStatusEnum.file_unavailable.value + except (CSVExportFailure, ConnectionRequestError, Exception) as exc: + err = exc + job_status = schemas.FileStatusEnum.latest_sync_failed.value + + successful_import = job_status == schemas.FileStatusEnum.file_available.value + handle_hyper_file_job_completion( + hyperfile.id, + db, + job_succeeded=successful_import, + object_updated=successful_import, + file_status=job_status, + ) + db.close() + if err: + sentry_sdk.capture_exception(err) + return successful_import + + +def start_csv_import_to_hyper_job(hyperfile_id: int, schedule_cron: bool = False): + if not caches.get(HYPER_PROCESS_CACHE_KEY): + caches.set( + HYPER_PROCESS_CACHE_KEY, + HyperProcess(telemetry=Telemetry.DO_NOT_SEND_USAGE_DATA_TO_TABLEAU), + ) + process: HyperProcess = caches.get(HYPER_PROCESS_CACHE_KEY) + start_csv_import_to_hyper(hyperfile_id, process, schedule_cron=schedule_cron) + + +def create_or_get_hyperfile( + db: Session, file_data: schemas.FileCreate, process: HyperProcess +): + hyperfile = HyperFile.get_using_file_create(db, file_data) + if hyperfile: + return hyperfile, False + + headers = {"user-agent": f"{settings.app_name}/{settings.app_version}"} + user = User.get(db, file_data.user) + server = Server.get(db, user.server) + bearer_token = get_access_token(user, server, db) + headers.update({"Authorization": f"Bearer {bearer_token}"}) + + url = f"{server.url}{ONADATA_FORMS_ENDPOINT}/{file_data.form_id}.json" + resp = httpx.get(url, headers=headers) + + if resp.status_code == 200: + resp = resp.json() + if "public_key" in resp and resp.get("public_key"): + raise UnsupportedForm("Encrypted forms are not supported") + + title = resp.get("title") + file_data.filename = f"{title}.hyper" + return HyperFile.create(db, file_data), True + else: + raise ConnectionRequestError( + f"Currently unable to start connection to form. Aggregate status code: {resp.status_code}" + ) + + +def schedule_all_active_forms(db: Session = SessionLocal(), close_db: bool = False): + """ + Schedule CSV Import Jobs for all active Hyper Files + """ + for hf in HyperFile.get_active_files(db): + schedule_hyper_file_cron_job(start_csv_import_to_hyper_job, hf.id) + + if close_db: + db.close() diff --git a/app/utils/utils.py b/app/utils/utils.py new file mode 100644 index 0000000..728d853 --- /dev/null +++ b/app/utils/utils.py @@ -0,0 +1,23 @@ +# Common/General Utilities +import redis + +from app.database import SessionLocal +from app.settings import settings + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_redis_client(): + redis_client = redis.Redis( + host=settings.redis_host, port=settings.redis_port, db=settings.redis_db + ) + try: + yield redis_client + finally: + pass diff --git a/dev-requirements.in b/dev-requirements.in new file mode 100644 index 0000000..2d94fc4 --- /dev/null +++ b/dev-requirements.in @@ -0,0 +1,9 @@ +ipdb +pip-tools +black +flake8 +pre-commit +pytest +pytest-cov +tox +fakeredis diff --git a/dev-requirements.pip b/dev-requirements.pip new file mode 100644 index 0000000..bac496f --- /dev/null +++ b/dev-requirements.pip @@ -0,0 +1,134 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile --output-file=dev-requirements.pip dev-requirements.in +# +appdirs==1.4.4 + # via + # black + # virtualenv +attrs==20.3.0 + # via pytest +backcall==0.2.0 + # via ipython +black==20.8b1 + # via -r dev-requirements.in +cfgv==3.2.0 + # via pre-commit +click==7.1.2 + # via + # black + # pip-tools +coverage==5.4 + # via pytest-cov +decorator==4.4.2 + # via ipython +distlib==0.3.1 + # via virtualenv +fakeredis==1.4.5 + # via -r dev-requirements.in +filelock==3.0.12 + # via + # tox + # virtualenv +flake8==3.8.4 + # via -r dev-requirements.in +identify==1.5.9 + # via pre-commit +iniconfig==1.1.1 + # via pytest +ipdb==0.13.4 + # via -r dev-requirements.in +ipython-genutils==0.2.0 + # via traitlets +ipython==7.18.1 + # via ipdb +jedi==0.17.2 + # via ipython +mccabe==0.6.1 + # via flake8 +mypy-extensions==0.4.3 + # via black +nodeenv==1.5.0 + # via pre-commit +packaging==20.7 + # via + # pytest + # tox +parso==0.7.1 + # via jedi +pathspec==0.8.1 + # via black +pexpect==4.8.0 + # via ipython +pickleshare==0.7.5 + # via ipython +pip-tools==5.3.1 + # via -r dev-requirements.in +pluggy==0.13.1 + # via + # pytest + # tox +pre-commit==2.8.2 + # via -r dev-requirements.in +prompt-toolkit==3.0.8 + # via ipython +ptyprocess==0.6.0 + # via pexpect +py==1.9.0 + # via + # pytest + # tox +pycodestyle==2.6.0 + # via flake8 +pyflakes==2.2.0 + # via flake8 +pygments==2.7.2 + # via ipython +pyparsing==2.4.7 + # via packaging +pytest-cov==2.11.1 + # via -r dev-requirements.in +pytest==6.1.2 + # via + # -r dev-requirements.in + # pytest-cov +pyyaml==5.3.1 + # via pre-commit +redis==3.5.3 + # via fakeredis +regex==2020.10.28 + # via black +six==1.15.0 + # via + # fakeredis + # pip-tools + # tox + # virtualenv +sortedcontainers==2.3.0 + # via fakeredis +toml==0.10.2 + # via + # black + # pre-commit + # pytest + # tox +tox==3.20.1 + # via -r dev-requirements.in +traitlets==5.0.5 + # via ipython +typed-ast==1.4.1 + # via black +typing-extensions==3.7.4.3 + # via black +virtualenv==20.1.0 + # via + # pre-commit + # tox +wcwidth==0.2.5 + # via prompt-toolkit + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1c3df0b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,76 @@ +version: "3" + +services: + cache: + image: redis:6-alpine + ports: + - 6379:6379 + db: + image: postgres:13 + volumes: + - ../.duva_db:/var/lib/postgresql/data + environment: + - POSTGRES_PASSWORD=duva + - POSTGRES_USER=duva + - POSTGRES_DB=duva + app: + build: + context: . + dockerfile: Dockerfile + image: hypermind:latest + stdin_open: true + tty: true + volumes: + # For local development + - .:/app + - ~/.aws:/root/.aws + ports: + - "8000:80" + depends_on: + - cache + - db + environment: + - REDIS_URL=redis://cache + - MEDIA_PATH=/app/media + - REDIS_URL=redis://cache:6379/1 + - QUEUE_NAME=default + - CRON_SCHEDULE=*/30 * * * * + - DEBUG=True + - DATABASE_URL=postgresql://duva:duva@db/duva + - RUN_MIGRATION=True + scheduler: + build: + context: . + dockerfile: Dockerfile + image: hypermind:latest + command: "bash init_scheduler.sh" + volumes: + # For local development + - .:/app + - ~/.aws:/root/.aws + depends_on: + - cache + - db + environment: + - REDIS_URL=redis://cache:6379/1 + - QUEUE_NAME=default + - CRON_SCHEDULE=*/30 * * * * + - SCHEDULE_ALL=False + - DATABASE_URL=postgresql://duva:duva@db/duva + worker: + build: + context: . + dockerfile: Dockerfile + image: hypermind:latest + command: "rq worker -c app.jobs.settings" + volumes: + # For local development + - .:/app + - ~/.aws:/root/.aws + depends_on: + - cache + - db + environment: + - REDIS_URL=redis://cache:6379/1 + - QUEUE_NAME=default + - DATABASE_URL=postgresql://duva:duva@db/duva diff --git a/docs/flow-diagrams/managed-hyper-database-flow.png b/docs/flow-diagrams/managed-hyper-database-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..ecc4f636f441dc5e23787b1fdb44e06ef34e9669 GIT binary patch literal 60167 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
ZodvZSaarpZFI~RqZNrAP%BN=mN3*B6S@FjW8 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 S~JV9r2b&+<$nPgv4ze6 literal 0 HcmV?d00001 diff --git a/docs/flow-diagrams/one-off-hyper-database-flow.png b/docs/flow-diagrams/one-off-hyper-database-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..1d0f652910999b3d2edde88ae6c9f9700d647173 GIT binary patch literal 39770 zcmeFZcUx1-_dXmz;V2-2(ghR{l-@#bN)r)Kfk5a;=v@d%$3`zkq&ESn2`vz+6s3hO zLV|P@si8;-CA8nhb3Whq@LbpP2UpmWJ!{rlvu4db_sk^fv7Q#~Wwy&85QtX$f%;Pr z=rS4vqCoxw6o8~cCr^Qo3l8^m?}0#73Dn0n7lG%Swhx}_f}mpmUpy+I%mp7TGl7X>Q5Kp~Zn zwyp-%0tFon*9Azm|1Ka6q^*9>@WsUXbaQ;ZUjEXSxBe7GovO6ly;)Eehf@I}(@(YL zJFniLFSfLrr@~a8J~2KsPW?OCcV0(c&Fyr7VrKTqA7OXrj)wD>HVer{aNvOTZigAFe!Cb23E8n6b;bL@&`eO_5mkK z#EWEYrB|V3bA?tFvwF?Jy1`FCmE1tr*Z-V7s*_eJY7qgM&;hYxzQ1`?=eucO#R@`E z0s_iO`GHj}Ex{onpI0hcCH+AlMXK}S!-Bwy(=wUhlI$|k(83l)kO|d!?X6r>!-1cW z*7RgNb72#Cn8Qspd7H-BjL5DT?!-)`utgJOLUI1$w${aw>T$ANA##=FZ zG8g=Klnn|42FX@fs7m3LHg+LzxTQ;<=xyVS>4C1ns453CKP?r|0 zb2>zsX%4d7J=!Xus6IsT4Kx11=pxA4RUI-#7#h9jshN7{7|)sJmULbU14@xjF9S}F zbz`0)D~1*uw)D+RcT z%0~|5Wl&o&@ZHdCP&(IX>af2QnHX;SjiRgjn%Ad_da+rX+*=rdx&VPTL+A(Hv#+^8FXQ429%f3kul zs=sC)`*Re&Gz8;XhTE=yD7ewTM69H4!uK+;@;nJBu5a<@HE90UV8OH6fq4?j{wury z6uxD_bJ&5|=_>v!d+~o|%u0AV$L><{e~r6OLx@MGM}-imYHW?j{%Y=bD;%x`9Z;J+ z*XysdAOBxv>XWhmD&GOD__y-Qdw9BfPiF`tD2$9A>c#xb#P37WQMl=UPWA+xOf8O5 z=)LOc*D>XRCxu^X~CDqMJKXbcLtX72-X=zKSo#oC;Ys_gU=1) zcJWh(Xz30w_W<7eb`8*Ng$X*6=%x;-w@oiE7+O=L2I;5+sx)fgf5X@jjDswpCt<$l z^_#E$uRfKotnO+e->ZvZZ_m4Qnwzk3{;GSblr$PJZ};33N|q2K`zBs(--hNsFP9q{ zJ>_DxFz77@btSpkE%r2`k-NLdd)er?kx6DlSht7SEb}&RR9@WS-ZsN~-I_A#2?Q9q z7@+IEV9ky6E)0=XF)F$MG64Y&{;q&dj@s0|_t{wCL)BPhUw|230E=t$PCTV=e%a;3 z=R1XRPbQwud6 zZt&1M{OpjLt*uFfCOy>7`7;l?%fUfL#L=UrsiIs0T+w5^UZKz|jz@s0`ME9xzmZUsJLXVCG!0hkM$*3|5780XEn9U(&N_z=)f%s4CeLi00f$&qX`ZbnwYW zM6)Yg7p-3NC7kGQF#fJl2U)E#(DdOK)-eOG@@3^gbyM$U{ZBWs3?5;oMl8y%2Z0tNfdS(agR<+B zCVo{(vs#@SUq}D+N?C19^DCjAn<5HUUs%_eRKVQU#>_PaMibqvz%@InOgvnTOXc^u(sQ zbRak*F)@S^zhPH9shPA`+#hlc?s<2qKH9g=KoEhWbyp7eLn6sR^?xO0rNCz&IkI)= zE*fOe=)&CAd(-`*Q4zIRXT5yqwQQ5>&Qh%N1^X<&9eXR4ma1b$1nyG#72oGhsw|*> z(77>p(8-!z{kDvB?S{Mlx*Kt*@p%L;Qv>YGce#>DLX5`+*6MI#hbZp6@M`*y#(e(+ zW7pEgho>8lnl6I8&j%_n&=U`d`V#h~5M9QRY{--E39g4fIW-3rQFo~Rx{~n3Lp7FWk;AUl`AlXgZ2^M+uhUl&x zV>QdCA}vN{61p_z!Eb%*E6a^dC^3mKc8O^>a!Sw=kWo@5#a#ol=S_{HCZalwvu{6M zcm~5nSlK~fxDoxNfsFLBlEuSS8_PTgRJIfGYb=>tgSHDMp)veQ+XcVdBtZ@BVM?(4yky~gz*DJ5L zr!xlu@Oz%{+`Gkw^_&V65V|AP9G%aLjOKRCTBpHiO+b_NCg)cmoYltwE+w#$DgTcw z_=oi04-*C%##S+wkAk)snvxhbr4mc@IWnb~K$+*_@=ehytv zG07FDlP>wO^c)5b1YOT&lz_K8{#g6O?Lt&GBTOff+>Y(%T7&RbQh`Xg`3N7n5aA(p zYtkX;BT6iNT>VR$x(g_k?L1{)ZU6n8OrXatjx%S{J~=Ep5^aQZSYaO;)0jlc9z2J| z!L?pXC`O?MRt&%urO~K@w1N@75~ePEbLKy)L3Zvb-c_0dHo8J0A~ke=qJpt@dUR7u z-(He|_y7!J(byp-S1B-6=%0+2q!=ZGbhO1~#)GL1i?2&w?l8J4w2yG!{d@x|iIel? z3dm)I28&+5D~sDKxc4r~R+ttdW%;134J-|H z|CITUzS-#RyFCB6J-ubf#$?1}DI$ujZTCFc6|od9TDYm}_N~@tC%_GcW^yv94xTiF z8QDd(AZdKfH?Qkvx3EI@yZl!vjX9tMzHzb3{m6+i50S5qcbTRCiDypk;T!unA@^7k zRB zkOnWKSts=gr7km>cO(AC3vckKiZtC_avb7bYfKLF%=oLaJ#9`AK^i^%;WKe!flteC zne&N&a4vX^r8dTprp6%4a${6K7N@tP+YGIE8E_lT*-P5ZSo36nB3sDwE*WJ_o7z}& z;oOQ4TdJX4EX;=Pqy6NEnj1IP<1SKPSty6j;SwrP!5Xlv-kcOUX=rKq&&?f27RoS( za}?m$Q7NG_A|yD}ig0M+?HMeZ8=CNIy*XLLA1E^pR>@u_!R_28cQttin7aMsh6*&2 z`Fc(#GNzyqmiQwAm3h7Uxxy}h!ZCf~kHB^EUl-7(!|) zmh2_XRp!3ZeLaUO;Xv}Ozws9$offgi0-|Jh%>RXGs?`63UIEz+O%D&}jkSiu)4jdT>G4IOLQ_(VGe#Q^ycpuUE4j6+QHrPh+E1(Q zHK&Hn+kpdh%>MOOnAAPk3CYXvdM09%y0wCap*>otZ+p+fPMc9;=V-8KkUcPI?H$LN z3PZ+dOhYoGh{@c4o_bRRm^OX|v$_f&&c*oA2>RvnUz|*Yneq+P#mh;S$#kvfR!`i> zbdOc!CYTC6|IdOXexTY2nX$)aj#V|M=NXZ>Gr!(>nwxxlp~8ViP&)hWgSgH09Z`0dCx%$6~Nr>e?OiEbkr^>V4f z08;;~;Xjc6!WniVr8b>lYbC;O+Y#wxVw+>C>5&DUhCzdmgG8q~q& zCooPAa)Fg)+k}!ZnoSp_!5$szc|MA;Zbb9t% z01+CMVUXd0l9A$I(NElQINeNZna>+|bVc%ADdys7bsdpT3i*s?aq6GcCi!j1m5!!(s%1u-M3SQM= zotPIFa%R^wk`_*F7?bcV^~IAnIi;!3`NF2tB^f^zc|2!>Ui02u&J72${2XK^-#e-K z1=+{FSZpcZC+KqIC#C?@_;<*nzic>T*}W$_I2xn%**<+eI2(7O`kQ{|EmR&b$rDj! zQJHmfQrq^VgH@d>gG%l1Wr`bLlj49du_n$h8p|4!gQT*rLUCNI_(#p+I9gzb;h z&swD#DDkT`b=B_ckqu4XqEuBzJ)%%)8&_}2mt~6rkZ1ONmg~YoVZt8mP4t!6aqTe~ z9%0a(c`f{YQf*A}f`4)nlq=pwd-6qynutQWyRY!j9Cg#Cv!^m4$rDh_o*D0^BZ*{$ zA~o^4)y-vuwPL%skWZM)4IFKzah%YMj>c>~GFswQ?GgzIJvOwi7I^0ZWLLnPwXTcu6H_(27t@3Qa05|BMwdrgOYdH9nD^9+$mUu(UhI?eZ-9Uba|s&~Z<+89!BmB}BFx%VTk!!1$l-cBi~=RH8DZ!deHH{(dG}ONcjw z$hIHr6&CD$pbB&8ibJW4okn1KPyhq;?n~^CUV-aQ4B!S_suD&%fZwWcDaHm@u7NLg zxtDpzYZA9S&!aHRBt!an#R4MRDMXU)rD(ex#|8~8Q>t3+Cn*!((k%RBsdcfi#aN`d z2L(Ep+^_T2Wajs5W`a^|_QzoxJx&GvyBZ0hg|5Y&1eAdL-VRXY;#k{Av8>RH z*3v7}_SqFZD4m8X#T)JDqi0rk3)OICB^maLhF*uaPB7TjX%1*Lep9gkW$jhHY5%>Z z!@-iU^O`z?O7J>qkK81Ko`=wC%s{y3fPbsl7APB@wCY@#N@-h zUp{UY067u{W)(zBb3hxcxYC!=u^T9*XC^)~{Q{8B6Zlz$hSnwip>1TP>4&ke^B8O+ zK@3x>tX!Ak7*`>+9&Z4-O37;b+Z;yB@c0y*0p;LGqXgx%2%=?85me0;_5gKnJV+Gdo1p(bJoV&kRrk~s#z=j2c|X404IdCRz07D@Z9d+uoR50U zYR|{N#EmT1JzZY_yL)+^ih@`=A0UV>Pt@W`fo6{~ymMG;8JqWal>lnKq8G;Uf?qgo zYbD${v^~V?_q^hk>3o-F4Y>nUN&H^3AH83ioAn>` z*T?G-zA#S^DIEa@8{bnn`G!_<8Z(|hds^M=zPeeffmf6&B(DeZ zl|z+;t0hrL0DxVUm-E%}p`!iRfYR(N_+H}hZmdm7v*&8pKLv+f)o%Kmi&aY&!S_Rz z(45iVWc?={OTUgGa1)cEsJpRPlB^O_i{D5tJ(NIq`^o5pb*RL$h(|!e+QN(tFS0`~ zhro+w8d$nP?<;DA&(r&w7fxHcQW+5WBryaMRnIzk3CtHzbl(jioI7W;lWf!+GX|n_SN5^>9Zeb*xWO&InY-Uy- z4XHR@%zwO`Rd$?VGO&4Xdd@O6=L0!>)E_1s*C`6CcS}I!ofw7qu-&QRB&7Eq)LaLd zl!|k^OkZ+4Rf`KJHb!b+{|lNESf8DMUdN*#yux`fzit0dVu6%~C*&QET1p%qtuz^& zTpHwY!Pq)x6Yj%=)Ac6RLL|-!*ch4nuoKh%H+t=+nhk$xPR?!Pw-q+gdRq9=<-OtF z%y>T+nr{NsJFoz}lmc24|E@en$tc4sj6&t%!M<9YamHcyGms-7CfH2f<<~z0w`l)YZN@5u=z z97e>2)V$1=LHdoC)C>VT1k_SNuwbkEX1`=-n8chw=lFXyWV5~QIvWC$c04w_HbJHA zW;hg9Ca0jmB;&37B8$g`uxeajlK0G*cC^&ks;^?`l@}N8{&c9b7|3&Q4nY)+Loo*n z&$<6)pc}_L2~;fo1s7s_Le2Q)Cz07?FD<{*w2DO8MPLQkoT$eyG(9n+%#aBK zB1C8$5b;_Q-}jsL@Mtt;nCh=edZCuxj=VL@uEM^~;FymEufweNX|O&Oc*SDoe;&mD z>O)}aIiS>FR@YIfr7J%~RUZ$GdK}YIR*JpW!ZW*%q~Hcn-W!gZ=Zt#e&^th-HEh6# z&2$a!!3>%CfPajpP_Ot#3dV#2z1$9P>)6r=zXhUdrD5xk>iM+rlonW#f4LUa=CL2V z9FeWB^;b^cM?lUXH{r?M#U_cuPtL!IpOG!;G2lA9Ie`;88E)kUSJhRAm6K}2BW%v6 zfVXV&=s}glIOo%-?DIVUc*M&wIs(sCZQV|dz^tg6;e(y2)_Vlb713amMTdx3vlTt*Dg0DdB0BH&Hx36WtyJ1R za=TRN=QU55*J|PK@6!As?gR*;qrERWH*rAg7PHH!Yu$0rD6PYT7 z7T%as%feW+M$FZr*@63fmjVtxCXrDxLNjjZKV8F!7PE0muFNdAG|I?R{(in9fRGsC zaj_|wZf^V|VPN^yVH87tZPban%$@x-zJnDQv~u|M!zBiVV<5MD*E>dZt@v^(MtQEFY`Z3Y zs6l0qb9O2w^4#^fo;yJ2`M{?8Vp)osWIrD~Zb2EdJ!bFVH9qyNE3v1)gRFRU$0-iA zef-ncZ2dzG=2vgj9A%#B#mRlScchL&J@>w)knl_czaofofIU_McBD;D%c3#o2K^L?F zvhP{QY(5RJr0JXBLir|A62shx-@t6PS-AkcBokX70^&XIs2ggUc$FP@m7rvdXYrtSP4XK0gD8>w;ZJs-rOdn{hpsc4sntRcfeQKnxxsm`P-EdMzvL% z;s~5gemNKDe8v&#c!wYc$1bXYMf~pAF}_OV9XQHa6-SE)l*dfObF?!s3I(d;ou_I= z_4C7Lu0lE5uRj`+AW1y~{kg8D2|0||`cmTjnaRkILl#|_E8a7pUIND(yo}>$&$|UI z9Yhw&^&i3D-9HFaZEfe_V1va9oZV4=@yCFhpjvvFTs}Pge%EZme~D_DJN*e06B( zpj=PvjrPk!(I~pp#VAyKe&9m&uop|)F)a)9h*pq+o~WKAMj4B0h%A|G1cQ@wBF5HH zlM1s1YgN_*ciKm@?fO|D_E7L>DF2e92e%7EcfLhII|B;7^*OWZ#4y{=D>(TWSQ?|{ z34Ge3L;RaBpaT=v@N{jLAdGG1QBsu#R`$+lH`GuVf|x(HjMRx?y21iQ-kc{Cw0D*? z8@9x61f`&~U?-AG8eh9&ZPIXUB0MfLy9<$VJ|z(Q{jWRytSi;#>Ay?=8IVc0gATA5 zyw!W9?I~`stq-3FN{g-iDn2D+4O<`6*tvwjTozXFZiiId4%uwt4L%*-UENiv0=Sm% zV=Fb|Wm5M`HYReX;!%OKviB4%)dUe3+F>W|Aj3Arp4QQOJ1ObdMtcfPh}_3t!z$s^ z+%7Hp(oe2;4p3MSE`8ZJczao_^Dw9!XJ$fH^e3<0v_n$6^bZ@W7VMXs^x~`B=ZD|8 z6k2l3aHW_xsC`uRbE}T0 zedYAGkXdlDn+t3@5LkAbo!wVkSB*pozfe`;kcU~v>t#pML&sd(uoD&Ql{zqCnMN<& z!h~-Ratj>v+&-l0GwhnxTxqMK3W8pvev?*Zz-w&uaMydK`}AkMf}JW@aX!GALoS?^ zq6n7Zax=79^L725V7a8W!Lba{%!1qK)nh@y(G4*MZh~AmnQoMgM{4e3p%tCQO?3IV zCxWP7afzZ)9p9gx_t15U?Te3xl2SQCul5df*&=m$=l7?QL+Mib3VD;l;u!?dI~L4L z`J^Z+p^w&DKOQykM;0O3DWlHgB6*m!%v0Iwo4kWfPd~{kRVM!epv@@MnLO1^KC^VD z#2A|pLw=FN0uE@OYs!?I+HHH+dhCQpJn{+xbCupc5w;@j%4Rswpiq|wNg)N<^p%)y z>k|7_B;+Zv#Wmx0M;$WC6Gnm9CkonpYJ_$Uim9xH(uSPiK>yZvra3pDMr24jejFz| zCe$Vv_Z(1z&4d=&^HyH`4Ne_j0gzl^rGgUxH++U1Ulvm+ef!|_i3|^+oHeDA6~4_Z zYg>;cHO-zJ`oiMkzDRqGfE=XWbL@Po_@y=ZDZ8O~Vsd-6iDgNdlYaGqxza2AYj}WC z`oVMGTj?I*d!wY^Skv^`tF5_-_k3_3$ol8W)!6jO) zB#)_y67ij(7XB-X3!%usiZ9y+a6|RVAA1;M9Ljljv~cG0lc)mHsUF;ZI)GoTOrQ!i zv32O0X%8dDjg`2gF!Et^^iVGMcbvmAW;)Kn?l4-EN2H3$%aaT@Gw_@<&$Z?mypz+#}kFz5xZX-SgVzr8OuCPD+o%BpBDwl`QKEN-- z0~Dy}nB^08XIFs>qc=(eV3}>3I~kwd;RvE>!h8GCTkSahB0e{hd$=NhuK>Cy1H?Fingg_hg!;Rr+69QU0kAZgDj2Cv$8f%1ZJsUcjhhc@SC1(UQ>0V z!K+Fq6WUUB23(czW|pp(kq!?JhP3#|16ZRLiagd@B;!WLxLgWqnp-$A=8(!}hh8JF z4}URrpR2;MSrj|+m^i^Ot1;Ud$x41w$jhI<&C%}TLVqJoOyk1cxsljPqOH-E{3=l- z_3gMievQ{uY7(i3G_rNN)si(z>pg&n_@@_7u}wM{G+}R3=V1ZH!^UC*V6+2ZCx?fr z9>&L4?{-9C*~Y&MIsjPYOV1@ul1)0AM4a=m6|=2#39%>_?PZT58dgmjhFa&{QO>B& zdmz27Z&*1F4uvx`$aaQesCCP%s=C-Bo!$Z8>s>ah;_A zUaO{5jjKms9H7%5i!5(^Iio*Q(S?<1&N8n0;n#&65a6xsJy1^?R^HdXdHeP0!Qt4C z1$e`W$<9&Z_oR%(R*9|~N@=sF%^%l?OxxJK~8Cz%RIo&ZA^D+8Ok<*wM7*9fHP6=c|kHmJ<|7HODIlE}d}R+*|$pSl;E|#w@bmQ?s zCvS`kb5L|x+*`jI#|jN(KKx^P%9z=2B=wwTGKQ}Zb(toG8+o7+{!1p!ZQ^Lk3Rsd< zOzQQ=CR5HtzOKrQLfNwW9~ki-osPCIQ|#<#N!i|3BeEKuZugblX!o&O*s_q+*yO8A z5M~%waO6psH}Fr~%?O6Qo#-pOhhodg#6;Ea?#Js(A^F|g`A$OIsrTN6>$#P9L+iVCUw z;hMwha;c=t6q{NpiNjP|+9+X;0WMGAyczM#A{v$87wNO-?)6^A)cy^ZTzJwz*4cAd zQt%%omC|ckI+J)Jz|cS3dbP|OOXE&Nz&~o2tHTC}Vx*cwO`CG+;os_8eWJt~F6N1w zWw(w>yj)F7{U*lrxd+w+1q@5$KjdV^Vkd-d2aD*p zcXj-0aHy9UKJ|_N!R;Jd-C)-Ij3+S0{U}}d*wW4RP$~dZ(+b3-RVgC zp*HvRAYiv&&7=Bw{IC~FVd??n@cjbWN}bu!7<=N~$zEmgsr`A848KG~w5ATdvzgf~ zTOz-@^1ObWZKpz%Q*nHi;W0s_m#r|m0KxM5PsDEUki&sW$h>f1M+$$xT@Lfxv{^T*Eo3)y1!) zYOmAVPZZUo$3D>SXV;uUMi!qIjRhD!Gr?Op_!cq$P-Ok=u;)22?(iILc~XD;;^!rC zp`ph9SWDW@m7NEzpMmW$S;08e`oK;^f_-G$^a+$_XSRt=;_i;A!NsS<3d@kil1VuY zMb|^&jA?C+{J`RbvZ1sb>FHiSa9wok9rS>)79_ALrp#{-yYw9l{ylpmbZpN_sP3#j zi}y$QsdP-7(CKOT?MldkNSQ`$-=~0^4C<4ZJzH?(#tIj3+Vn?wJc@4U*~l%lDczem zmC?5joS%szXRP`ZLwS6fhV-K^?z(H*jq(~Ck!Q3#r_;c` z&!$d`JqIBkBbG4O%_fl1o}tcX7Zf$nHgmiZfASxZFT2;+Z4#pb8rQ7 z_Onx4xhl@v1bcGo`=y-X!Rf84Uwr+1S8|_R-$r~tp$llS``JY@Xp!5P@^FHDW z)zoZ@k85k4G9bWlbr?MK9TiE8Jgqg(Odvk&6M4J*{|0+&vndeEhP&q+tNXdjJu zbS^Syv^R&8%yjv2U2cQs?cly2k!YBqp(-n<=DPtm%zFfc-L5rw(Gj-$>w~QAJnqf> zTpEAXn*N^?%QkqwbAcI`gXwI!30|7j;oCj1$E8GYRjpa@$E4#=bj4QPj|Fwz`_+)u zNac0Xt-+ZQm(=1cimZq_;keHnnup9$a$mn4TMJQIgF`b=d}HOKyv-gisV{DrB&^b{ zeP3!|6G1ml`YdN=(|%7h)umx?bM9S5s#%sDyO18w$Sh%|DL%4l`LStcP(D?l2N<|D zUa+R-)O2cuebJ^fDq<}2j;kf_(&kFtE^<>^+^BYP+5J)1?;y=3lWDWuCaTbjpv?WM zSN}+x!%oy->kl>Y{vmAtYsYGd|AY>_1x-{kk3%g@W>joH=*hTcT3c<~q>e{BO@eul z@$Zjn+k)i135?Lj5~_?+-M6RCfuRm0E%rt-+0BZ4NqSnRdhF^(zwY3Q9M_LekZbLf zs?aP0j=NS(evSYEKdfy{U9O4m?=5_-fmbtwF|+qIs~lQ>%q=xR2ot>9XYP;LlstYsWNbLX5o=?#t^li{sgEkSREhO-?09tVR4F6 z_nfMA&gZ+hZZ0wJL*u9uIPL@+81Tc)biEj>=0O&lDv@UE=n|gpc)>r6@34>ZGpC(o z(|;TwktIBeqize{p=HVfI2INADYcyvSj8-`)z)SEcN?K>BVKPE0rxY8VJz6lk}TZp zFf*D@W6Z`8aKUQlsTn8uq`a$hig^%r(~Q+muvK95MiBf=>msQ7KCHQ0zbn^w$7L{) zo8g@86RF=Jg9pL}*0i;8s>LD*_^c>;ql{vZui?GzC+TPROV{7}$+_4I2Msav->Lpx zGp0KF{5k#jh?)bP4;CaQ2v{uEXQbtwJs-{DOVzvj{wSOb11vu|UsM!2tusKWkDfGr z7e}uuUsTu(y8F_8UxFxt(q*ibdZ$SwcF*{q87uI`aFS_&r}{UQ4^|O29w7U-*nku{ zzu}-ls2}O}d4E{=jKOr45JpmJl#q7@`Bea)km%bdG1K>F3=mz+;@|bEOmp*xK-q?L z%A!>nn}0L5H=|09d=2J_KZO|(iREqn4WSY&e6Kc&1I8QoFPa+K!yyvV(v?iz}KlQ4gg2C9$L+ysjm)==Er8^J_*qMhPj z0vN8lAKprhFnZG@U%l9JIb+Wi1ldrOE3(pRG3gvY0z6`BfF|H?qGcZPB_ggadkJXI zs`ny!ZHa8CNQy&Q==I^kRk!rL`De(&IdsUK);&F*INel;-(Rc-pgnJkn!f*Zs=9;5fI?;Ul|XrK39j2&-_FYoQ{;@9tlt z+?QpI9FJnjlI}0*;XX`=pQ9dm-;vA;7npE939B0M{YN~?new&Hb{s-`T@KnOB}VS@ zLfOW}cV;_)?A%ZHV9i5x?!$G#15Lu|f44*WRB*Y(?j+SD2PnkyptS$J?8B$|a@DdS zt@VF!Q9{$28N7F6t62k?7uk(A1uRF$F%N2=WSTk*Ca(JyNw^2(YP}+!YBOkfy|-Vo zu#5>tN9E1q)=evvt1tgVtE{ShG3O$HmZPx&Iac15_!X$K{d5v0kVXeZm9O!hcV%=; z`3;t!%)y5s){YAYaQ0;a(Jiv&cx*t?=1#or(Bp2>_?}=BPk5)M zM@y$Vgtb!v&cloLm8ia(3LICKs26L%OIC$|Dh1T>3Oz+ZsPTZ#shH(#mfDfm&}?St z4vKJrkPzKeNYCxUzmF0SYX-(gWZ_vPa6qrE7YKvhh-|XD&x1ii9TwuK^fIk@&XO)n zEXVBve)YE|bmMpg z>FAlshDuY}-B3*eUObwg!nxiTG#(pK^?l3%ey1B%K6d8em)#@ixx_tK2R zjOg493xu!7J3_Ze-~VO?zdHUN+``B@fP)8lmg#Ugr4p#33{=6rj#(7n^CLgATs$ov zB(gB+&qO@-Zbq4Z_xiU9CvcXt{0jKI2~Bl;Q*h3JShV(W`e6_mUANeD3dX-`);%Nb zN1p{azlm+wQ2%EL8Y4I4g~T*upERc*fcvV2c0Y@`6b83)Pa08~WNh!&-*v)w_3JNv z+>{Rt+(erexg#%sq`=dGAW_(Wx!|5Pa12abr>VwV_q6{0kl?;nEpr+Z*W3meRl>v?+4DOknN~ zAQ=xxZav?8?4j9l8>HZ-j6xNd-dDQQ*_*r?6CH^PKn|iB8ny!@KWnJtoBi}*;30z? zMVTu~?uwQVevbDsyUFW!Pt>6xE`YA}QACQcByRMK{@zh&ZW<;oGweBDaN-IBG+|5h z5ySBJMx~JMS>8$KS0SQ|fdEHFvaBzYbj-T05xJYUt~`Da-~rtKpw#{TNnW}*d()~R z+5FMH8pNiR17vH@CUfI=$$Wr8=@o%8qJx$&l9)bTO0Ea&l3SNWqV|szV#KeH9GJGP zzW@$)#1=6LmxN5;`k4Mv;#ETN={CkZW6nb1W1fKQBeJ@4`<|=gBSzKkq?!-!HNMfr|Kvdi;5{^eZGL=XyQ;6m>D^Mlp|v}K zl4$Af@%wV@FPrPboc9^s8vXQoNiU#f!0}wgFd&*RJZR-l8v`WU)tmugl@u=(R{OhT zw#euN2n>9`fF3#^*0@3s9qNA4lXj`&K6wdz7%ih3aP|q6kiuwUH0O@u0--n&L~%IJ zrBDUhP%9=ei#D55Yfu0pwXD&id!aU-ko_SLOq`uN^Pt~==T9ng>MGE+ume9>ehmCIq<-!`u-db<}< z+9Wj)Tf=Z#7vW>{kiP18MPO!>>63?4x6+|VTjhwONf97+D{cD-C zvk{q=eOJOl4Z|ew_=OD^WbEOkHuXpp-H}P}>p%BKIc~S;2Xj!ocnUKj7(^9Z=wK?jO*k=&F%|g=XTJRkthZ4YR5q|^T!G4jF@Wds#R!s4$H7iiftMCj@T=t z+&344g@r!|Z77$h;2k~UcbYQ%+3sFMKEEfrTQp1?=tE=u!H0ej90I;)fWACm9#X)A zw5qd@DlgcETifeeVLB$Nj{{{IOM7D{GnJBQOSv1Qb?xn}AAJ>|v{aJ1OPII2IFM^} zu{SaME~1%jk{6h7!|ub@e6_)l zkIt8=(Lzn%znd-?hFWp<`iuyTm~t#^s>cs#By<*Xsje80ZU5`!M}z&c&UM>;&ZNf} zmMhg`8LsL4#H!@F{HC_z4*0kXicr-d%=5X7%TDz9xQt7#>0`=U{L_ zhE4N#BgfdIncHmeO|5nyNyZ zUZQe%9$l0r-d1+l3X%=OPK@X;xl8HedGP?7fwj)Q4xNV5R#R~ktTQu2RWN*Xg^Hj@ z@z{y6qee*rd1%o2bxr(FS$t`Ivr^hb>J3*or5Kpo#Y5LI(89(xZ1q*$r=!aVi~=_y z%zU-!Zue$EBIDbb;6SoRao7o~M5q;{t}u*k`SYiahp=V}1pL89>^tTWoePNrJ*&&W z@lrlb7)RUD!?TuCh>sG4 z$vpHVt3RW>d zrv;EnUO&?g?0d({rdBw}+Q2{y#jH^Lw%?w?NgJ?K?ERK~wH?i?P2%g5XqrnK@{U5i z_O|{#{DG&!ob5Gbo47juWihLBOEYSY^7P2)HiVz=H|zEDGcV2;4EF1L)-S%w>8(m@ z0m;6_%3q(B-Cl?P40UUb<$}L#9{9TowM~7&bi%FuKKN{;{|`UaoHRGVbGLe1BLc!x zDN@aR;jgUgE^aQFjlry8laVNj?>zkRxx}5#QI!Lh(-Vo1uD9`Ep7ZrxoNbblJjZe; zb(<4lD=Tf;Y|F_3rl#=K?561AP+bjI#oq%|Y(1B|2b#oygX2Z?&FN$K7?J!4}-33bdz z5vI?%{yoU{qNT9cGf^Fq^^JKv|8m>aNL2e#z)`KI4yT1CO%s(JIIkr8gZ@>h@eexk zwv$Md?Ey&~UVLL@UxNzr?1$1Fr1*(Js8)94*Qp&If)(Rn$wT1hDbVrro85$9IZqMG z8RprM*uU4W%eC!;f#d&uIfn-2a6M6wA2&LFacKJ~q3=kd2!n0_u8vR{!*vt78TA`A zjeDJ|LQ16SrW%Mi5}A&9>V>v2pmEaq{g$kfZ&2WVOZo5cUk_>e6o+&;WkuvU_aBS~ zzY(;fS<(xb>VYwwFxTUpu!1n=uzpjoeN5w&D;Y)L?nAS7kMp8s) zg+LpL3?vWyD1fx_vmY-I@cQ!T@(;MY5_@djLa^s?et^C(hMu(zxVarRki@d*2pgyt z60BH%d`0HwR1(^>b_K%7!VWC2>M0P6m4FqTaVQJDN3bkL;93&8OQrN;IaCFtN~I2I zTYN~vE{x_=$2X)o8@+r2tUJPvfFDZ85EOTTCeNK;ZhiyYl?n zM9uh0&(oO3CMVNip7As#{TQFXsk(F%BMm${d!m`7_qke2j}gjX%&$-EF|xw1YQPWl zGR2K%iAth39BW^26Gk4POWPuW4Byra*x)wD0(oP}+`r9k!tfXxQFW*?c9b z+tsx0Ed&AXA^IH`#!2Cix}ettJ7d28XzD>NbjjA$l~7bMLaRrsB?S=h+T@y2(7Cs` z0B_N4uQmIrWjth7w90jGOW(yo4HJ3 zDK9nhP}hI90zs=II{ObG}41f2?NP@g-5@o2qqynf!vl#=zBh)ctPdx}H+?HAo zo1$>82cm+d$U(^1;<*K`&KgRyFeX6zZQ30RG2q&npC; zN!%5X_58Ezs|zQ+(8#FE6UqQ|#E+mpuH-N;e2WN+8RkDrSce@k6`*1|@BMiS&<5yF zNBZsn2j1*E^JICZpAW9$9pBfVp9d!UU9MIiE8tBaMmb;S53vWJaIu>pN z-S06MhtqjbaT;U$<06dnw^ILH6@>Q&m~F-$?$1`A+(BMvqEafBsOTNW`%n}2IYn}G- z{c!zrhu)s}!%bxhWpnk8g9Lj}Olu+8Cgg*FrdvkYOTe6B8rjj!kIi9-ifKD2O8P3m zc91VjReLuCign$Ac&a>OoANAC*R^wNWnY0w`LV;;!qF5Y_?~JOO7Zr#G!x%252M7m ztHYM8EJZ{WZ?l3@+<`|7Asv5r!|<(1jFa?4C`D^KlqmAGAencyJ8oo;OFVY!V(Hu` zk;Qv&if_0a$7qnzD5%$&1B8pBr$j{+-){ZcR3Nj8zNugu4;UU1xpo%pKGhwAFCoSC z5r&ck4U5ao*v%lxA0ml1VOfeIi54Cj^-jCb!+lzX*azy zMq4&QxLtAKycRVYi4$>5J3?FfwK-ZP=&}SPr20pY+@T+ML9+HLL{gQ$4_ZqUi=NoM zkU?M4AWwEb8eBeCB1pX>+`#c+ie>M2J(;?7RK^^|9)VAAf6k8-B-Fee3)2}yhcI*= z1ii+vH7#Bgt(mj1PaWGX+~eMhO!|tlE?k4?;sg^p=cb%}4ObP0_oQ&p`SMcOVL24# z?MqQ?;sZ~EZQeXdh=d?rep|ADlG)??^Oe)vUYA0qo)8aydKqG@O`e_ z0%9Ni&zD|bi66FNEtx`c&ww*&^{&~jqwHgMJ;7X#D@}IH>F2(Zi-4M4&EP@Ta{5gx ziqWClhM>(D`a%2sWnnm_RvOeNpQAUh3qay8lzx;{J#&ZQA^;I7*>xxKO5JfqY(XG` z0{^^}kcYgg;tjwtI_R>Uu1$!_>Pqf)UeB_D9FGCy?P@=>_6>|?D{Jo-dT)0*rJlZ?Zf zpT6oQL=1ys9&Bfda7g|-`1Z|z9yHv~&jhX(i2gr)VNY=1-`&~2)c)x!Z6E2F{4ZV; z=i_2@8UT#U+Z9@;-6e-PE|mB27C00b^bkc((0vrQqByY zmcoc7OU0ijd%zP?`I*3bstsSK55?d-C5gK;bel&F_F2c%HhAlI^OGK#;NW1H>hGbJ zD#BZ)oVUTOZ~(S(*v8aeanG`NTeobrJ5rt8^?pg3baXVRoZdp7ugCXp3^a<27Eb(W z@(O7pqp=i{aYvSuprny49F>OOR2YeGaojD|LM*c zv%UWJ;h9D!$ND+DSGg(+UU7f)3?b;&R_Zzry(aw0c3|N4>8eUUzl}}ZL)2&`01*S` zIt51C??`dsJ^^5wF0P*l9=vWAw;}`yc?49e+un1Qf5@jJm5F-w1VFd!TlR#Up+Acm z_}L_Dy6;DWFeEw*JjM=G%)S7N`X&VYLmdFSD81gA;r3YoU1e6tx_g*FH0d8!xOY{q z6IqHN5=;PJ3O7j8X$Lyn%j*C&inDg{Tcc_t1LWax`lF%d1t?G@-kzhkA*&RXI3)L( z3($Oz84wH8!*l+co%N`np(cWEfxU4M;*t-}qm^0O?9$StZZHUmK}N5EW=Z{9Fnu5o z?I5S=SZhch$RTC?i=C^&v%jPYB4W13{Bokvyiaxj)W*PByk-gILmq z0rVjDRgH16QFVn*g7a;%wYR6zi`w&x*^X%az;|@lG>|zjWZkfZy?Af&^+P5Tj_ee& z%hRVY2uMrW^Hx4Wq6Y6@(SDT&8$el`nK|Awp&!^r_ud2XbhgYHMf_%t0;wz!+_B#i zyN;{q#T)nr?h9e2i>O?+xR8(B{+!Y?(B&fq>(!BpqCBQFQ3(CGfRII;`PTBkcIfyz z$53TRG3)NBXE&nZ`ye{IA}Db%qJq!=+*Ex$_Dvqht5=A-V8L+p5uIA7{(b}feW#P% zVnptof=3}gLtm8091qa~8~t|0w*xr0twf5j0ahMVk_0e}WcOGwToVTp!K|2QR!O@^ zh*aLAFwtECkzl4zYceOinHOi6O`L7k(}W{|BnO%rgxnE{ix|>=8-EVv?)!H%^*Ew) z(I`@gRvf?qX#rv=ghmi>$vR#y{}a2-@I6Z>;XIL61UfRN&l?hsg8JCge8W|(r3=Gy zSxO%rD7|os5u!tvR8%`nGV(b2^wHjmwEkP&_c`5VP;!q%A&RGV`;NsCnw;Hi+|H|d zlCfTtj(sK8yYwdzk>}Hx@sI6U$!fWu?y1m7d(1A=2Vt6VDsd|5Zl>D(cpi(DHX(WU zyLx~Qx(NJ#8SwJj#`@;wX4-$mavN>;cHJH>%NqZJt5$7j&jc|FPl-{YSb^VX&bJFl}S$jLTC@r$C5K!NPU$qx4e`tFZ|RgK0rdmsSJXAgvB2LydUYjkCiV`GUrIeXH)t}bvp zugmx9K)H5-lc&K}US2~O3XJB>Pftp11=Tu8e@oQ-fb;O-0jXAKylhK12CFS7ADZ8!Xd3p*XXjPZPp$rk-&a1NqAlr}S+1a^>^wR8 z&k4VUExhs?qUCCc4Ka z+X^6|l+V`#`&l?pB?iZDY}0E7dba#`&sr+_g4E2yVn<-gLoR)t`UEOu{|w=Ee+EO; zEEiDwKc97v1&C9D21xMNU4G_7$)_L&@Vef=U(ZYUpI;~VGhM8*0mpqv!W%gsO*rlP z3$EHsE zp&oh%e4!~B7kBz|{pYQ1?g*M4CF%sgybXy8tCfQ-^d736(?VFGFW>4Z+Y2Cm^0qJU zzpm>^!U9kiW1Eg=^jVM^?Yext^vryT!vxXX9-h^shDZ-z5X6>LpE57QX^W+^OLsx} z*g9@w|>rk=N=oX!*V~|${W!YI~R;iZ!c{9m8Mi|6?3|`H9-J7oh!@Qjvk)A zp!!A;q7zhyQSe)Iit$;OK)R z*&`5K2C5>!c|VNtdUl+H8m#vz5}PW0mhkmXF!^u0s$;yet*=W|)K(z4>^bK&A;T9x zp(BIM4LErGSp|Pbol@q(%-li+y()JiZrgnyy>M`&tt$>vYnWFpPitV** z2L+=uH|mUsUN%qguY-wOcunL%Lj{hciG0a_p#55Oz{#@_Dwd-%L!+j{&sE+vo+g_Ap3=)Ttflm*%Cq{WU$7k}yq9P|AOO~I3plPm zXFqa}C2iFskvtZ0cG%wrhbLJl5rXU?KYt7#hf5BbC%Q&MP8+Mf(e<@hxK-|PPAgGf zU`-LGNj93TW%jzp_GR*+?mHor11s-&Y$7s96zk}-nsa_gW}%FdV>Ek|cJ3FT}Sz{);;tGrJTGDqi&(u_kjPK$oPsllb(#E2p zGtOKNl*Tyf>taoFuimVa3hR3&GZ3Tfa_XoTf}}~BH^Im{4m%jzCNp<9<1;=bLo0Km z^e+<@IZ^I+WzjHzRoLTElZ_y-=6;PzU;lwH(KGxKFxYXB(XTj2wlS|E@;?1Aa!f(n zzMh-Oa$tFTEBb>-lC4#$G75l;kjkv@}^tvsm(gR4Y(OpP!>9-PBlnpV<~8F4$Sa{QkforgZW~$dG!#`ukQ7omerhad@;=JyZE8| zS#L9uXRKk%CJtv`_URq>6VHs){7YC8k0>F-d412>m8;u?La$STE_dC@-r9jbOE(U6 z(i@wIn%|Y!ug|#07Wz$3oQ$!E*gC6RgjO68q=wc8!|^6M|w|JlA_Z{&ub=h99wZK`3Ry z-llxJAw_jx#~UoXCcM8ro;?0Xn&r5+<7>PRwY^Cqd z4(EC;7X-c`5^79+wZ&NA6t}*PZ&ej;lg@jq6;c(g(gyvO>bY0VdL!OsOAsqfRm&hwjg}~~gr}`MQ&CBN4vQIG zpB$Wsh>WLGx#baCD9rWNWk=o#jI5V*m*0ypj=rFxUf_Z zf>X(4;Dx=fCQK<(qJ|HyKfThWU)w7WhaFm7o%E(WlM9ejK z9=dWxYmjTnkhb-2&TzL1H17H|+~@T1XJ;B2PU@^BG~rx3b-ffTlBD`lWoegIZm}GR z69k7r^gav8GM8(&rFVqi`4q1>IX|jq3M=rDCs?O+RTb855M^uj{OsOgyJ`y4Cb63} zHrB_D3aWJ*@mneN#(1%w|5i7< zDChAN9`&G|$<)?C&eq<(@%j=pvr>Y75aTl&?;)>7ejV-iC_}h%1x?1@B0Izq#=)U~BWvGtw90fmkR-G-S)tBcvC%XZ zz)EJY!?h=U#gXt0!s2Z7&|`I}E&?VpR~PGAy;}b{gD9eG7OG%)W+{g9?CU+aSl^(p zBzfz%9O=M@;pSR-aE0^&F6chCOIoGD{(Wl#`{F zeG&caRn38Ny%6}1v9t_K;ds2X=y4MUY4<3cZrF3B18Eu098ffz`foqGiU&X~5pb8u zDQb@?Vz(X`h{!XC7O!IZeur+W^#hoMeHNi-{0T<=HEXtt@;jxU-mlw9RWvAxCUSs- zt6;k8@`||wJ9|zAS`FTQ?(yR{GmFE@4*u2n5v%~`qbQ{I#Y$Jw!!)b@GEy`yJb+_B z3}4esy-2uX`_=@cJslFgHb*%Q^RQj6>i$M>{_4#~5%IA>D7`3Lf5CaA)>>2=AEkfl z`#2Zr%;$mRjCa)UtDfV#n?Glq7j$V4AD+p}$RE;5etU7u!TFa@mW>^p%1p45xrclR zgNu!ueExv0v-2!&;qYQtLAM#5s7OvGC7iEOa4)@sXSgx8vwtj`BBKae>#a1iT$PE; zBsg70sv%}$|LHvD77kc@Z3$Z&3%Jf(bjj2!NG;)j=|%qYhI)coru3HtZRN}tyI)Ho z5wm5ZFKqvzFL?_&+<%`-X(nG7@JMfgY%9oo@}gwkyZR4(QRH;9OxhBCF`uDreP2L# zZ3xo2;Eb#N3Fx%7dh1qRBt3K>@GonIYZJdTyb9IjyuH!?;@z|P-9i8B`RsO}jc+le zaOUJM<#7vO=l{EHXss{1OKuykQNy2p6A0IR&!4db%9B2N-4RKxg2dH1{i!#dF5mxU z(+zK)sK0+-W?Yl+F11bm|9sw{ow|Cn+MFZ!YsdpdsVw%s?Dq8$kqb{;PDt=ac- zjoYJJoIWFR{+^wL0E+h2@2>E!Js_>mc{oBvu=0F2Y-~}n<-kSyJJm?fPTtNFMM)&Z zqww0Cy!3|&sKlW?7irG7Gn+C~>C;d&&g;uM#bVmw%m-M`u;0}t$F9uJ|0MQ_IGq^F zdbyVW_BcnqEcK*VJDnAXB(&|1+uipna8r+n!@yawC_R2PC)J<-LI?e^BN1(zA*7Yf zsG4C%;v#8OIMD%OUp|ezqFZTLuDjBLYJJIO-*OZ;S&g;Q3q#m#Ot#VPPZQMY?wHrW zU{pzOgc3Qg6UP*av-TDf^Br+4Q^2PTU5d>mmvVt`~>T| zf{~;sooZmH<~0cKt(E6CS@>eGmn2ht$7cSEUjyoJyO|rSQQ+%iL(|H0cb}4VW~+uO ztotq*c%-l#H3v#1$hyJSJm(pioE#jPdgf+q$X>5nZAg-Ech(p3Ve@wgUohy`33fu% z{|J03rBB~&S?;mbv8mSQJ${jrt}L4%#*ks&!Ddk;~1~quW0t6 zyCNRHpxn=WeT&!nmU1SQVNyr;Gc8;iV-ji4yPJ7hE8b-OfufO&|7GR*JmGjoXF)|^eYJcx#;w;!R7H`*c_G12I+DXseCviAUIobp z&-}*Uzt8fFf^Go+ZybFm09)rgJC+6Wb*AxIIF$PrsVRt!Az;Bw<&Za$Nb5ISI87e( zm$kJwN`Jfx4fxfp%@#&IK8PQ7UZlx==&nx|qMPGjAzhbS)1SCL)<2>f)qu0Md*yQb zwX$hq#P%~Nsbf-aP6zeAL>g z$|^eYMXLTDHU39NZYwL{D8qi1;%tfW{00`&7$e2ML*?dPUFee?DNe(urkXV~Tq(#y zpTC91m)^?I04YbY1*D-T_BJGY*w3;upJLaO>KNOv+c$g%ncIO`cO2eCWG}!R(^J&V5)eS02$;MqBF6x*(xSjG(X z-Fh|j@XGIVZcfJ&70ywjy{~)}BTza-e1F!FjV-I+k83>vc5}ag%b_)_eso&7(^r?* z8+&*ud25trE&cdcMeRezJn%v%Cb{1fH%iAna$_DwcPf-;{?t_%E>B1zhnFDVSE`_ctHZ zxzdMs)zvAyUs4?F_q*#e_9TVD>`Bs5 z`u=5#!cDv7p@t6fSURuEAM@$>1V^|zNhL5=qrdOl>H+1R(4v3|vJ<#Ip|xJ?E?V@nb; z6idfs6re}8S4ofO-2&n2#qSHg6K@qA_owyPTaA%U?eBAD^IIqAb%zpb@F%y8>(R7} zZzFNrT6nB8H;UG_9etTE=#IvD!M`jp>KlanJ z3?j;Vt*O7Zoh1@8?mGHkAXwY*8tXuUkt?0@u|#<}E`WUzbS9qK{prD#O^Sg&)98aB z0ct{^Lqo_2B6uQ>q30%DvfXiyB_AHQoWIo^eyBr#$e1M*D67K{ZH06$n@u6wAAdO5 z#EkNG)>9*!JSC+M;GTlw<|y3!)m*>(8}|F{SB z=P`|I7VTx=TUHO@&i{?H^6X1;LUm2^jdwd!jW(UmV|U@uI7XMp-H>H_2P zQ^=i^XZIxxZ>vQvE0TpWO0(;@A#?mlft$-;G!S^@(VtyCTGNLr=epzCpXiEUPNQhm z%1puE@ZaG{F^wW>!f@%|<>B+H`k?$2%m?s6X_!LUj-dBhcn2J1BWPtn(O8?=GEz+O zcMaAYcAFF>L##cyq`K+@?NX|ob_lUac#QRm?Wd%dif^gjrrbq1SbGdn1ormI$xU^{ zVJZey7^j(iOM1&nyP{&uX|$7oaA(Ys{c(48f?lpqF!; zBpU_*d`2wPd^f9SEU<{3h^p&B+#o`{dnk1v^DfBgVyx@O-kf{A=x_K9&9Tv8XWC>C zyS56=I6z&4VFM`|eFfG+*g5&rtA}OCuqQ7MD;M~mbd5fQegozKMHCsV5Wxw4cyQdC z=A1T%<~h}tc3#C5oIzBVkRqHL_Qo?6(I#~=ZdPorP&m*9I+p7FoL__P&gs1^+xqFZ z1wQ$`ln1lAj^5Iq#Bvb@ZN`kAy!74Kdqv>yOo;8c!4z|b(4CyZAdID zgSNvhL0frXvGa%v-6!G@`N)L8N%HW)#4X#Ny3lSC+V`dcO)08=C30hW<>N1>4(Aw4 zaHGmWGcub$yzxCAj#|{+Dm?#5+Nv-Dr+;q4ar>utY-MOC(;WBniuz_r1t#7gmeJ|t z-Az2s8x@G8@)`W}b$DXCGNKV9jF*SaWyL@9K zL7T%e7V0!%6h{~fdVJ?Q$Za1OXF4AqWqjqxNw~jyHywTM^MdJrskY&Qxyk9v0{0z9 z-%Q(1GQtZA6C!Gn*~6^Z?q4*KQh3^ZM*? z>);x^-?sq;!uE-W?YrXfkCxsP)2DDqLJj5{c$n=2D8aSr?^1qYtol>Q_I`Z5bQ^%4 zxoTL=4lhGmI*rXLlTmL*nJ9-hR-g0z_f|stM4Ri5!!qp!u(d%)B<@_xCz?w=st^d% zO`N)|d-;ZxTf<^^Qbq$CT+o)hry}iEp|F(J{%5%2rb|O~JLr%4&lR}!+neem=Qra< zz3^xjHF_C9mCy4B*Kn%#TvtB@HbSc>H)%GbYdEmW0sZl%hPVRuhO8hqrLcHEB2VFII5TyMbgd$QPtcCwG;FEuX$ix(=ic$j zHkjyRlXZGb8Fw`m+aHB{qWj0@lpf^`zX7*;El6!1xHaQl6-GcxI(LQ+tjm0m_>D$M zS4}Y2sKKx#H1FG07U9uCCtC#+Ku7Hh@Pvs{Rs-BMxVn zU#OoW8^An#Vw|+`#j5J$ev%15`vOp9HG7^Sce14L41e^W;p+(kV84}T_ih!}O1~hDz+>d{VE)4q*M&~%UTSU=A_!$D z?YlPi!(0Y!+}A5`C=!o7|OjGwuvHzvZV2$W2S?qjRYY^gQ40uwDD6Mc$N1|W14RLxV&W}?KY-MEcPW+8x1L>x9$CpWE4 zwQd2Yj#j96t=uL>iV)UPf~()`x&EOYQ{m;W+?>dP@=hTb z{f94<8^2tJI_KtrWlLM}*&2a5CvMO#cA!x-qt*~iwYO-RX%5o!%%W?64?T#l2*O~s z(t9I%0}T3WEL3&zgM%hscDg;?UZ%jSYN6`!`3qhA zh+cdC!~O8}yS&hZ6Vmuv>49=8fJYNT{q64Ty(AHpyszpUml806(iLI4(%||P)Y^C- zJ&1kk=vffvy;MU^rs?|Vyj})f!>uB4;- z4f3x5&Dx!x5N&$K6z=Wc%~it*X`9a1!K=}<7=@Bl5MP|w?!qHAZCyLuEDam zU@;%xrTW)yu&fPz)Urae_emGA90pugUi}ym8_qCoR}YFMYMqzfG^U+?D^AKXd`;1# zQGR!AZtARR!ca#2NEgLx2s>*qSHE7MZN2yu;zfhMTUCUeUU*e}5`YtZBg${C4I1>1 zyMH4!^@FgJJzmmf?qp-9ZGd!Ap?t=9NAy<;I9-*$|Gu)-4odmW-&$sR$YVvw#H34pa7ou$|khvNu^wD@LCJi z=MO&)#?&FXFB8V=#^O%GClATx#Q~0~I_U?U?JMI3Kq;zh7>HABj!f`yq-+XnDsFYhl_)hVK96u)t#5Y4xIZC#7!X4M0qN*7cnQFUHT?;J;Y zJM2w9kxaVlEnfe?cCX=NVS^!mnwFS8z*wE@yC{BEuNRhTuJ^E97Ms$Zv<(0cL`V>-T>6H_+iZ>Z0aOIKD%Z|a|9oK&|+;1 zDc|HMXwkoU!)+|h%}w$WwwYp$EOm|3NznbghTQ6`=LG?$D(tm1jk`i!KMHQ4ve|#5 zCog3gI(=vr(C>O&SXq_+%S^EOQm;?uGq$hZn$YSB%)_iy4^JwGsZnN$a`~LZX$mfd zCi&A`z0fJ1H%poZ?R=a0s7dMXc_eISK#}PBffvq8za}e%FM!FhL{1^tzfML*<3iU= z09VMCVWrL0JBZgGv*+^fw){Y{_uQzDpqzbAM*c~(! z7NtZtil}aqsu(gYj^E5i^ruFZpWn3Ws}F5OY%!sjtmY zz1Y|$Y-yWSNpE-d`h8g>$VYBLW#1E7MZE78u-|aR0SqQLn^f8N*{K|fUE_>TD7N?%%idDKHN^t)vX`ifrewyi*>{KMfSKCCxg}XUl-Hs$JaYWirIx{_; z>##KFcb?!5CN_FheU2SDrEk;Tjn){;p7-x}IGh zGw(B8xcxEjLVw1Trb-%f{Gql-M0v(lHJBh1#Se~e+XV6TalPndK^Nrrr{MuuJ&wGu zf&)dlUvsA&x-Y$yh$1oPco7NaitwW#4=aq?aKs8+g8{Mpb_3ydyk3XqE&?+XE?`}p zWo7EcUkddU#Wo`)2pAY_sPDvCddzyI0rx;I;`|W5uiQ)r@FXJ-**68EMiTC?9V0U_26Pb5^eO{pufojKCka4SVY!w~dnJY7hj%b6rdAy2)wu zosoD@vCqW!^75-f=DSA|D5CLI>cD%XMmw%sM{O!TNPUV2Q6jwpb?t4bBI(PGAC2)9 zeDM#t^p-*VH1lWYx(o0bj%Y$lTT+&cw-(j7c1Wl=7m5|YtMyI3(U9tvEoqeJp-q!!nu ziy%8;J-TZ;BT86Gy6inuhBWWT01j`~+FSea!WYGLBsT!{6a0DAH8ZOp%LsJR+hU03 zG3X(59JGhabu67fasNrV!E<14zUu?Z2ReXnLj zUGOSd`fs8FY)?IM+nQ6rYTwGTca!!kB)Oe~T0tsXIVeoSL@Gh=q_nPIuESfg`KDsF z-I$jwSXay#%RT`@n77x*xO>_Y2l(x7vkHNmx{5q!BEGBvtV^LrO7cZ>UE}JCjE-AD zxf{=aMjtir35Q_@yG|@oK5Z^CbM%;DM2veD7<2L?9YKlZQ;iM$_}as<#iu0G(joTR zDxQFiWYS{bN6F7g$zOzv>G53DN#L~T^Y&k?5NBog6X*-~yYJm2zKWdnp>y9QrC!O? zqx`{TYkC3ks7&pEQksD-+8m#oXKteIiAl-7rMNip4Bitvp=WY7ae>4YgZ6vH{`%@2 zB=W{k)-}D>w3+-30R<04Ggkie?@;7YdG$R*{bo)+Tjx)LdYtkX?#EwRn;5i2+ znjlI_`-JL~x}i%xk<|7x%7X*^{_l|@BBent-%eIcy=V5N)p}4wlG>w&2mD7YXzgy{ z_U|5D%P85t@V>4=#QR3h$U6vNv1OJwcb@)9CVFs|z!xx&BT09V(yq6-jaxEw$Rb&R zJJ(lpW+v1R;HkEVHn)&fvoh+Evm$YJuY4j%a6}?F#*8^>%ugnnpG-Y1tau6=wtP|j z20P)UDTasj6YJhvT4mLD-y6&b{1uK zMB*s7*?)Fk3Ri=*ju|7JPHlgE|8m_+Ja{2U0Jb_Nm2RXx1QBVSZhLuNcv)Zd#Cdgu z(l^?=F{((1OV8}A?lP@Pqp?4r-=t2>BSy*dDm2~aJG1A4>N5^^5_7`hM+RwK>@>@7 zNt*W?%Ji?Lee~#QTO!)ladpAvhC3>EqjF8ccjM>E>7=Y`BiNFc!_4xP_fH%WP|yqb8@rGLu?t0)#O_L;p&2@hq#v*cIB*QW_^5?d#P* zAdofCoZhc)kmo2l!Ok6z2M!;n;}*ynEtQ`P7b|gms*==mg3@$Zan)%NZ7Z3Ms+T@P zxYYGPTYT>7h_i2p7&d$t=&I_n{TqM-$nb{#+F6GhMc{|AdbuxoeCG4J@#B`TBYpt8 ziZo~j-*rhROz`%f)#}zPer;4O)o&h!!v@s#wpA$nvMwiWU>EO`Z3m+pw_VU za$VkXadO0w1XBP1TSQ!sBo-;oD6S*#(5UI^N6*s!SL!lWvhfGcZLI2MmEjG=|ML3n zCK0Fm=eUG?Z%s)c(YgEILH}yxUh<(brUTyCC2red*8gK1r0k5onZ$kjSe>tzl;&;G z{n!8QQO0{P1f0>~jNA3GwPF8qHSTs=v9@u=T@Q_tuMg&@Zi1FC@k~yc;oq4rE2xuq zjx~X#q|0|_4jX(i=haVR^>%9AnHal&Od1bW!uNA`D0OMqbANhh9JFg+M5ArL`B{_a zk7?}~v&4bO*|XfLQ@NGA`aCpctu2o%OwY(1NOvC&j2?O7d5L0OKM@qSLLbc~0fLX_ z^B=Y!`cCFovYdb)K~DzvcY{|i{q8Pja3+L!@K4r*s!U)%Md19BXiCj{K_UfCfspiQ zdd+Vzr@)D8Q?Ly4VO{yFKb?6T(}>8r;MM=U#z zd;s9oiC#HeBP8`7PWxteq#A{Ef>(=7hT*a|3 zWnY~Lk$G@;H+$nO3NUj~5AZ0xX^-u~4U~(fb*|4&511}`?Tlw8n1M*lOL}^?eicYQ z=GR-YPdaOOOxQ{CzCFtBPK9b-`0d;_naIjE>#FOd{PR8w3old0WW13#3OjvB-;#v| zEn_Vv&&!YbE)vXhfQ@=HzH7m^u~rdL_(_;lymF5R9)-i`pMU#9{slft4?!7f+Vztp{z9aSmD(O-cc~iGm!;ZX~h#y;D-AG`k z?}LP}8o4B>Y5lF|7ASA7hcRa3vm6!IE6bBC8rO#P(fw`4Qq#-PPM^9sC>z14Qo_u4 z)Aie9#s{+i6k+Wx5B|alORqEKEj4{9C@55|=aAjjuNaxF_ZVGLK#_m1?m4*^T?%1c z8>s*p)<_e(YJ_I=^z_6e1Rt`f6*>ZiQFE?PSE1gHTU(1bx+CTDYY+mmD|q}k^JJA% zI)7cB@PrmZI#%k+i`L|w*2`dh-O-+@Lr|oZPDY2D(ztG{F3vB3tP47zOC2_&Y z4GlzB=R~zQkBV#yxPwBrOKXqeeszSj=)Enio2IRO81MVW2|qE|Zsg3w;}ogWydZ=3 zhjwTcC!oRLdSJPFF}((e>#&#SF$RIrIl86#s75#H(g=w_3Ar8de^_zaE>UQ|~cF$HX_$OHv>z8}Qi!s-8DrDy-pBZ}2}=-gBu z#=s}~F}2A4_rtsw7(-n<;up?dG1q85LA@Y0qHi{P>c#r3Z6Ir@YITLVbG{h10uXsO z-Pp5{CJT(=0_c|ysOoXPfeo1pWfVP%A1!Cc1UB61h)T?iK;TTm?KEb!X#S~C-<*Vd zc~rZ(5&VbJ!TZb?orlG`()LUMT&O$8$SR}{@|1&nKs_SJ7pazO(I`64=#B2~I$3#H z9N_d-*^8zX`^0rRtdZVudIW!e+E5Dp@RV{*P<|kA@4C-nkk@cub7oDW){#{WSx zZj^LP4v~jdu<@D6!NRj$_;L9=d3uf-u}uwp{Cs{>x045`^2>Gr4-!9@YieS<1PwRL zAvjKjA43V)eHb%KmB4Srx+Zg_xV| z9KS0{n6hs5D+BdSn|*R~ZoTR>Dl~sdI7W8YEIcjS+9}DEQ=WF?FfyNf?19AO4TP7R zqSDS9b5kX294r>Ph5(WRucH)}=H(x8F<{sn?q zc@b%q0})&nwQgRM6}rV3%-lGSZAI0Nm?h@7Y9gtoKrR!*nxTCvoHlQ~pFG_~*)0sX z?j9QvXxzvPukSal?2N?SAIZWCkEV?sa1P}}em`M#9!sKd!1Y~#zqE*jicH8HNGMSr z@Gv)2S0TUJKA%9=k}ivF?*@tV-*4alG`z*w@*1 zGQE3hd%X!@7J$6$^s!j5SOIzacUxQXDiJ>M&=FZ$ezMM{Y|)6NZ9T~`L?sR<%yqj_ z%h>x#-h1~R7k7IrgJioJumD?u8}{}!apyRP8tK?!`l|~cLOFI_3>pY&(@nO+uhK@H zg+CI9bEFBBx}l&O(s2$YZbsB@g+Wh4eUMS>o0y^0>kg8|3-1jIxR{$}s_h*skFCAF zWwTMRL_|JdXqj_^R^?8y!!?&_&Mh?_;vShchf-E3J@ZFIqgDK`>?WRptd;1xLnu<( zr+Otq##0VK!kl^c!!J+6b&6Gw<=)l)nl1h3%cwYxUnE-;o&i@^0BNn`Jcp;6UO|+QH|Q<@5RYq}s4ll3W_#!u|qFQ&PY=4CYY~gokU=hNl~M{H3a-RAo* zChVp#yqmHkF}{hKlBq9qrvPV73{Ft6I4~aD_YCYBDOZYbk42+GARG^y?(3i)Id60y zTm=tq1Brc2-hbNjXTlE$b}|FZ%f5@W>aI@y5Hv8+`NJN-I}U{^?I*YKHb3%JZgnjt zJpM9KygJzWI1AkntW${tkU+aBJ@R~WX(Q!IoQC%<5aw|GhuIHJY_1?aqzx1 zL4EUT!?VM=0@&&HTXU0@L}&5Ki5Mh@d86l~amT`w&Po%lHC);lc*~z zB0jA9vV{O`P2-q@CgG018!k}tP$&1vE|r>PgN!=VP~i0e(=}_dR8f{U&2O+}#9I1$$#GV3ofR0**jaowc?AxxEn#7C;eM9zpk=LPqX#Um<95QpF}n8Hbd<%U=px8O84P-=xQHFFGQDM3ICC6%RrSY9iY6 z{HTrl?zpT*p326Bu7`)kfvK$A)_|K52^O3pcTShY3 z>xK3D^#{^^hqfYLG_0q0q#ZrJvfcFxIQLKTcyO6}T(Y>~J?S!*Z8O-M=Pgr?DVi{` zqE6hn+r@Sid5Y_BUfvy}T~?z^nN8$3yD1x1In_rRn#mmTi<`KxXMQ=?l}(!}NgN7! zP*G#B#PYk3ne-Tc*MXC*le1(qgL;6)3`X_RoH;k;foF-?zhmw^u-EhMg(cqxQcPL4 zoXz2=;S`5DQZntl>~X6@=*ZVt2a*>CQ?%xB$9B`=`E2pKtV7N`Lc1s8JNHV0esTErLkwe+NEFqCXaPGf3jL&VJtAPJ0_M7_!?Z%25e_rRKL}^-=RG& z_b3iI)8U*5bk_f8I&18YcKDr~eeI{n~z3+w#{7QdxO`6SoIko$twPj=gI8)ur&x zDrt+a7vB5ipZ%V>Y~F)ut+ik3;!CU(=e-5)lFawc-Mi}K>8#p!+Iu(s+PZT6r*H8I z^S1rZI;|u7B0O*Z?SDDvI&1dluitg;>Gk{D(&QvItZ8TjRTR+v>1zGGX}nE*Vew%z z#6Wos(u>~s?b2$g$iIEL`?K}6eSl|h!FtFM)h|`&0(UKh%>bSf30M8;Qt;}u(v6=s z9Rls!hi>TYFP%PbhO+YOUEg<|=9&y@`apZN*J7)HP0NSciFZ?5Pa-=-Z||1hIgC?7 zr;9EFO|n4OqOaNYWzu5nw656F$iym8fh^lzes&+=e)h%C9Q#W>uTLK+Ci6$o^(*L%FYTw!!EpPyvE?Xs>NymLM=2^gn-zf|A% z=a)we>sRD*-L@lN9DR3p&SDb>7JL@3o}0hrP2VT|)cFci1 z4=jLIFfhmflQaWE!((7LFkm@!hk?NYv|wXY)8G$?cmJ8Qe!et0YBjG47+UftDnm{r-UW|Ulb2} literal 0 HcmV?d00001 diff --git a/docs/flow-diagrams/server-registration-flow.png b/docs/flow-diagrams/server-registration-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..f4c2ad188cbcdb72735fbee4a2c2f5db759557f1 GIT binary patch literal 55037 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 dAe`ZLHa`FR2A+jg^q>Kqs*;vsfr913{{