├── .env ├── .env-template ├── .github ├── FUNDING.yml └── workflows │ ├── lint_python.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── backend ├── .gitignore ├── Dockerfile └── app │ ├── alembic.ini │ ├── alembic │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── .keep │ │ ├── 21f1d47b6386_add_farm_table_and_farmtoken_table.py │ │ ├── 90a5fd6c8be7_create_apikey_table.py │ │ ├── cd672c4e6bda_add_scope_string_to_farm_model.py │ │ ├── d2422b5a6859_add_indexes_to_farm_token.py │ │ └── d4867f3a4c0a_first_revision.py │ ├── app │ ├── __init__.py │ ├── backend_pre_start.py │ ├── core │ │ ├── __init__.py │ │ ├── config.py │ │ ├── jwt.py │ │ └── security.py │ ├── crud │ │ ├── __init__.py │ │ ├── api_key.py │ │ ├── farm.py │ │ ├── farm_token.py │ │ └── user.py │ ├── db │ │ ├── __init__.py │ │ ├── base.py │ │ ├── base_class.py │ │ ├── init_db.py │ │ └── session.py │ ├── email-templates │ │ ├── build │ │ │ ├── admin_alert.html │ │ │ ├── authorize_email.html │ │ │ ├── new_account.html │ │ │ ├── registration_invite.html │ │ │ └── reset_password.html │ │ └── src │ │ │ ├── admin_alert.html │ │ │ ├── authorize_email.html │ │ │ ├── new_account.mjml │ │ │ ├── registration_invite.html │ │ │ └── reset_password.mjml │ ├── initial_data.py │ ├── main.py │ ├── models │ │ ├── __init__.py │ │ ├── api_key.py │ │ ├── farm.py │ │ ├── farm_token.py │ │ └── user.py │ ├── routers │ │ ├── __init__.py │ │ ├── api_v2 │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ └── endpoints │ │ │ │ ├── __init__.py │ │ │ │ ├── api_key.py │ │ │ │ ├── farms.py │ │ │ │ ├── login.py │ │ │ │ ├── relay.py │ │ │ │ ├── resources │ │ │ │ ├── __init__.py │ │ │ │ ├── resources.py │ │ │ │ └── subrequests.py │ │ │ │ ├── users.py │ │ │ │ └── utils.py │ │ └── utils │ │ │ ├── __init__.py │ │ │ ├── db.py │ │ │ ├── farms.py │ │ │ └── security.py │ ├── schemas │ │ ├── __init__.py │ │ ├── api_key.py │ │ ├── api_model.py │ │ ├── farm.py │ │ ├── farm_info.py │ │ ├── farm_token.py │ │ ├── msg.py │ │ ├── token.py │ │ └── user.py │ ├── tests │ │ ├── .gitignore │ │ ├── __init__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ └── api_v2 │ │ │ │ ├── test_api_key.py │ │ │ │ ├── test_farm.py │ │ │ │ ├── test_farm_authorize.py │ │ │ │ ├── test_farmospy_relay.py │ │ │ │ ├── test_login.py │ │ │ │ ├── test_relay.py │ │ │ │ ├── test_resources.py │ │ │ │ └── test_users.py │ │ ├── conftest.py │ │ ├── crud │ │ │ ├── __init__.py │ │ │ ├── test_api_token.py │ │ │ ├── test_farm.py │ │ │ ├── test_farm_token.py │ │ │ └── test_user.py │ │ └── utils │ │ │ ├── .gitignore │ │ │ ├── __init__.py │ │ │ ├── farm.py │ │ │ ├── user.py │ │ │ └── utils.py │ ├── tests_pre_start.py │ └── utils.py │ ├── poetry.lock │ ├── prestart.sh │ ├── pyproject.toml │ ├── scripts │ └── lint.sh │ ├── single-test-start.sh │ └── tests-start.sh ├── docker-compose.deploy-template.yml ├── docker-compose.deploy.build.yml ├── docker-compose.deploy.images.yml ├── docker-compose.deploy.yml ├── docker-compose.dev.yml ├── docker-compose.shared.yml ├── docker-compose.test.yml ├── docs ├── api.md ├── configuration.md ├── deployment.md ├── development.md └── using-farmos-aggregator.md ├── frontend ├── .dockerignore ├── .env ├── .gitignore ├── Dockerfile ├── README.md ├── babel.config.js ├── build-env.sh ├── env-template.js ├── nginx-backend-not-found.conf ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── img │ │ └── icons │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon-120x120.png │ │ │ ├── apple-touch-icon-152x152.png │ │ │ ├── apple-touch-icon-180x180.png │ │ │ ├── apple-touch-icon-60x60.png │ │ │ ├── apple-touch-icon-76x76.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── msapplication-icon-144x144.png │ │ │ ├── mstile-150x150.png │ │ │ └── safari-pinned-tab.svg │ ├── index.html │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.vue │ ├── api.ts │ ├── assets │ │ └── logo.png │ ├── component-hooks.ts │ ├── components │ │ ├── FarmAuthorizationForm.vue │ │ ├── FarmAuthorizationRegistrationDialog.vue │ │ ├── FarmAuthorizationStatus.vue │ │ ├── FarmRequestRegistrationDialog.vue │ │ ├── FarmTagsChips.vue │ │ ├── NotificationsManager.vue │ │ ├── RouterComponent.vue │ │ └── UploadButton.vue │ ├── env.ts │ ├── interfaces │ │ └── index.ts │ ├── main.ts │ ├── plugins │ │ ├── vee-validate.ts │ │ └── vuetify.ts │ ├── registerServiceWorker.ts │ ├── router.ts │ ├── shims-tsx.d.ts │ ├── shims-vue.d.ts │ ├── store │ │ ├── admin │ │ │ ├── actions.ts │ │ │ ├── getters.ts │ │ │ ├── index.ts │ │ │ ├── mutations.ts │ │ │ └── state.ts │ │ ├── farm │ │ │ ├── actions.ts │ │ │ ├── getters.ts │ │ │ ├── index.ts │ │ │ ├── mutations.ts │ │ │ └── state.ts │ │ ├── index.ts │ │ ├── main │ │ │ ├── actions.ts │ │ │ ├── getters.ts │ │ │ ├── index.ts │ │ │ ├── mutations.ts │ │ │ └── state.ts │ │ └── state.ts │ ├── utils.ts │ └── views │ │ ├── AuthorizeFarm.vue │ │ ├── Login.vue │ │ ├── PasswordRecovery.vue │ │ ├── ResetPassword.vue │ │ └── main │ │ ├── Dashboard.vue │ │ ├── Main.vue │ │ ├── Start.vue │ │ ├── admin │ │ ├── Admin.vue │ │ ├── AdminUsers.vue │ │ ├── ApiKeys.vue │ │ ├── CreateUser.vue │ │ └── EditUser.vue │ │ ├── farm │ │ ├── AddFarm.vue │ │ ├── AuthorizeFarm.vue │ │ ├── EditFarm.vue │ │ ├── Farm.vue │ │ └── Farms.vue │ │ └── profile │ │ ├── UserProfile.vue │ │ ├── UserProfileEdit.vue │ │ └── UserProfileEditPassword.vue ├── tests │ └── unit │ │ └── upload-button.spec.ts ├── tsconfig.json ├── tslint.json └── vue.config.js ├── img ├── aggregator_logo.png ├── api │ ├── farm_create.png │ ├── farm_info.png │ ├── farm_log_create.png │ ├── farmos_records_api.png │ └── farms_api.png └── ui │ ├── add_farm.png │ ├── aggregator_dashboard.png │ ├── cron_last_accessed.png │ ├── manage_farms.png │ ├── manage_users.png │ ├── re-authorize.png │ ├── register_step2.png │ ├── request_authorization.png │ └── request_registration.png ├── nginx.deploy.template ├── nginx.template └── scripts ├── build-push.sh ├── build.sh ├── deploy.sh ├── test-local.sh └── test.sh /.env: -------------------------------------------------------------------------------- 1 | COMPOSE_PATH_SEPARATOR=: 2 | COMPOSE_FILE=docker-compose.shared.yml:docker-compose.dev.yml 3 | 4 | # Docker image names for local development. 5 | DOCKER_IMAGE_BACKEND=backend 6 | DOCKER_IMAGE_FRONTEND=frontend 7 | 8 | DOMAIN=localhost 9 | 10 | # Configuration for postgres DB. 11 | POSTGRES_SERVER=db 12 | POSTGRES_USER=postgres 13 | POSTGRES_PASSWORD=postgres 14 | POSTGRES_DB=app 15 | 16 | # Configuration for backend. 17 | BACKEND_CORS_ORIGINS=["http://localhost","http://localhost:8080","https://localhost","https://localhost:8080"] 18 | SECRET_KEY=1f034e80442f37b6cfe1e9b442ef431b73fc4b727bf94bd93ed963adb2dec58a 19 | FIRST_SUPERUSER=admin@example.com 20 | FIRST_SUPERUSER_PASSWORD=admin 21 | SMTP_TLS=True 22 | SMTP_PORT=587 23 | SMTP_HOST= 24 | SMTP_USER= 25 | SMTP_PASSWORD= 26 | EMAILS_FROM_EMAIL=info@example.com 27 | USERS_OPEN_REGISTRATION=False 28 | 29 | # Configure Email alerts to admins 30 | AGGREGATOR_ALERT_NEW_FARMS=true 31 | AGGREGATOR_ALERT_ALL_ERRORS=true 32 | AGGREGATOR_ALERT_PING_FARMS_ERRORS=true 33 | 34 | # General Aggregator Configuration 35 | AGGREGATOR_NAME=farmOS-aggregator 36 | FARM_ACTIVE_AFTER_REGISTRATION=true 37 | AGGREGATOR_OAUTH_INSECURE_TRANSPORT=true 38 | AGGREGATOR_OPEN_FARM_REGISTRATION=true 39 | AGGREGATOR_INVITE_FARM_REGISTRATION=true 40 | AGGREGATOR_OAUTH_CLIENT_ID=farm 41 | AGGREGATOR_OAUTH_CLIENT_SECRET= 42 | AGGREGATOR_OAUTH_SCOPES='[{"name":"farm_manager","label":"farmOS Manager","description":"Manager level access."}]' 43 | AGGREGATOR_OAUTH_DEFAULT_SCOPES=[] 44 | AGGREGATOR_OAUTH_REQUIRED_SCOPES=[] 45 | -------------------------------------------------------------------------------- /.env-template: -------------------------------------------------------------------------------- 1 | # The domain the aggregator will be deployed to (do not include https here) 2 | DOMAIN=localhost 3 | 4 | # Configuration for postgres DB. 5 | POSTGRES_SERVER=db 6 | POSTGRES_USER=postgres 7 | POSTGRES_PASSWORD=3b7c15496ef20f1a858eecfe17e6d9147f70a13d4864d4d0b07323c72a845474 8 | POSTGRES_DB=app 9 | 10 | # Configuration for backend. 11 | BACKEND_CORS_ORIGINS=["https://localhost"] 12 | SECRET_KEY=1f034e80442f37b6cfe1e9b442ef431b73fc4b727bf94bd93ed963adb2dec58a 13 | FIRST_SUPERUSER=admin@example.com 14 | FIRST_SUPERUSER_PASSWORD=admin 15 | SMTP_TLS=True 16 | SMTP_PORT=587 17 | SMTP_HOST= 18 | SMTP_USER= 19 | SMTP_PASSWORD= 20 | EMAILS_FROM_EMAIL=info@example.com 21 | USERS_OPEN_REGISTRATION=False 22 | 23 | # Configure Email alerts to admins 24 | AGGREGATOR_ALERT_NEW_FARMS=false 25 | AGGREGATOR_ALERT_ALL_ERRORS=false 26 | AGGREGATOR_ALERT_PING_FARMS_ERRORS=True 27 | 28 | # General Aggregator Configuration 29 | AGGREGATOR_NAME=farmOS-aggregator 30 | FARM_ACTIVE_AFTER_REGISTRATION=true 31 | AGGREGATOR_OAUTH_INSECURE_TRANSPORT=false 32 | AGGREGATOR_OPEN_FARM_REGISTRATION=true 33 | AGGREGATOR_INVITE_FARM_REGISTRATION=true 34 | AGGREGATOR_OAUTH_CLIENT_ID=farmos_api_client 35 | AGGREGATOR_OAUTH_CLIENT_SECRET= 36 | AGGREGATOR_OAUTH_SCOPES=[{"name":"farm_info","label":"farmOS Info","description":"Allow access to basic farm info."},{"name":"farm_metrics","label":"farmOS Metrics","description":"Allow access to basic farm metrics."}] 37 | AGGREGATOR_OAUTH_DEFAULT_SCOPES=["farm_info"] 38 | AGGREGATOR_OAUTH_REQUIRED_SCOPES=[] 39 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://farmOS.org/donate 2 | open_collective: farmos 3 | 4 | -------------------------------------------------------------------------------- /.github/workflows/lint_python.yml: -------------------------------------------------------------------------------- 1 | name: lint_python 2 | on: [pull_request, push] 3 | jobs: 4 | lint_python: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: actions/setup-python@v2 9 | - run: pip install bandit black codespell flake8 isort mypy safety 10 | - run: bandit --recursive --skip B101 . || true # B101 is assert statements 11 | - run: black --check backend/app 12 | - run: codespell backend/app 13 | - run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 14 | - run: cd backend/app && isort --check-only --profile black . 15 | - run: mypy --ignore-missing-imports . || true 16 | - run: safety check 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests on push and nightly 2 | on: 3 | push: 4 | branches: 5 | - '*' 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | build: 11 | name: Run tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@master 16 | - name: Run test.sh 17 | run: sh ./scripts/test.sh 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .mypy_cache 3 | docker-stack.yml 4 | .env 5 | env-* 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Changed 11 | - Update to python 3.10 [#123](https://github.com/farmOS/farmOS-aggregator/issues/123) 12 | - Update dependencies [#123](https://github.com/farmOS/farmOS-aggregator/issues/123) 13 | 14 | ### Fixed 15 | - Update poetry installer [#121](https://github.com/farmOS/farmOS-aggregator/issues/121) 16 | - Only run codespell on backend. 17 | 18 | ## v2.0.0-beta.2 2022-03-04 19 | 20 | ### Added 21 | - Add code linting 22 | 23 | ### Changed 24 | - Use farmOS.py 1.0.0-beta.3 25 | - Change relay endpoint to expect a single farm_url in the path 26 | 27 | ### Removed 28 | - Remove api_v1 endpoints 29 | 30 | ## v2.0.0-beta.1 2021-03-21 31 | 32 | First release with support for farmOS 2.0! 33 | 34 | This introduces a second set of api endpoints at /api/v2/ for making requests to 2.x servers. Instead of separate endpoints for logs, assets, areas and terms, there is a singe /api/v2/resources/{entity_type}/{bundle} endpoint that requires the JSONAPI resource type be specified. 35 | 36 | Requests can still be made to servers via the 1.x endpoints. The endpoint version must match the server version. **NOTE: This was removed in the next release 2.0.0-beta.2** 37 | 38 | ## v0.9.7 2020-08-12 39 | 40 | ### Changed 41 | - Update farmOS.py to v0.2.0 42 | - Manually trigger a refresh before tokens expire. [#91)(https://github.com/farmOS/farmOS-aggregator/issues/91) 43 | 44 | ## v0.9.6 2020-08-12 45 | 46 | ### Added 47 | - Search by farm URL [#93)(https://github.com/farmOS/farmOS-aggregator/issues/93) 48 | 49 | ### Changed 50 | - Run ping farms as background task [#87](https://github.com/farmOS/farmOS-aggregator/issues/87) 51 | - Allow SQLAlchemy pool_size and max_overflow to be configurable [#89](https://github.com/farmOS/farmOS-aggregator/issues/89) 52 | - Only return farm.info with requests to farms/{farm_id} [#90](https://github.com/farmOS/farmOS-aggregator/issues/90) 53 | - Sort by URL ascending by default [#92)(https://github.com/farmOS/farmOS-aggregator/issues/92) 54 | - Show "All" rows per page by default [#94)(https://github.com/farmOS/farmOS-aggregator/issues/94) 55 | 56 | ### Fixed 57 | - Implement a lock to limit refresh token race condition #91 58 | 59 | ## v0.9.5 2020-04-23 60 | 61 | ### Added 62 | - Add test coverage for endpoints with configurable public access [#68](https://github.com/farmOS/farmOS-aggregator/issues/68) 63 | 64 | ### Changed 65 | - Simplify tests to run in the same backend container 66 | - Refactor backend to use poetry for managing dependencies 67 | - Update backend dependencies (FastAPI == v0.54.1) 68 | - Refactor tests to use FastAPI TestClient 69 | - Refactor endpoints to read settings with get_settings Dependency 70 | 71 | ### Fixed 72 | - Fix deprecation warning for Pydantic skip_defaults 73 | 74 | ## v0.9.4 2020-04-20 75 | 76 | ### Added 77 | - Display notes on farm edit screen. 78 | 79 | ### Changed 80 | - Allow list of id query params for requests to DELETE farm records. 81 | 82 | ### Fixed 83 | - Save empty fields when updating farm profiles. Fixes [#81](https://github.com/farmOS/farmOS-aggregator/issues/81) 84 | 85 | ## v0.9.3 2020-03-30 86 | 87 | ### Changed 88 | - Reconnect to farmOS server and update farm.info after successful Authorization. 89 | - Add https:// prefix to farm.url text fields. 90 | - Improve Authorization error messages. 91 | - Display success message after Authorization Flow. 92 | - Display raw API Key within Admin UI. 93 | 94 | ### Fixed 95 | - Build farm.url URLs to include a scheme before making requests. 96 | - Ensure OAuth scope is always saved as a string. 97 | - Fix error for GET requests to /api-keys/ 98 | 99 | ## v0.9.2 2020-03-26 100 | 101 | ### Added 102 | - Adds API Keys to provide scoped access to the Aggregator API. 103 | 104 | ### Changed 105 | - Updates the farm.scope attribute to only be modified when authorizing with a farmOS server. (displays as readonly in the admin UI) 106 | - Update the default oauth_client_id to match the development OAuth client provided by the farm_api_development module (Added in farmOS/farmOS#207) 107 | - Update npm packages. 108 | 109 | ### Fixed 110 | - Fix Authorize Now button in admin UI. 111 | 112 | ## v0.9.1 2020-03-10 113 | 114 | ### Added 115 | - Adds ability to send authorization and registration emails #32 116 | - Adds ability to send Administrators alerts via email #29 117 | - Display success dialog after registering a new farm #72 118 | - Adds stats to the admin Dashboard. 119 | - Add documentation. 120 | 121 | ### Changed 122 | 123 | - Simplify NGINX Configuration with template file. 124 | - Simplify Admin menu items. 125 | 126 | ### Fixed 127 | - Bug fixes in frontend UI regarding reloading and redirecting. 128 | 129 | ## v0.9.0 2020-02-14 130 | 131 | ### Added 132 | 133 | - Add variables to docker-compose.test.yml to fix automatic tests. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # farmOS-aggregator 2 | 3 | [![Licence](https://img.shields.io/badge/Licence-GPL%203.0-blue.svg)](https://opensource.org/licenses/GPL-3.0/) 4 | [![Release](https://img.shields.io/github/release/farmOS/farmOS-aggregator.svg?style=flat)](https://github.com/farmOS/farmOS-aggregator/releases) 5 | [![pipeline status](https://gitlab.com/paul.weidner/farmOS-aggregator/badges/master/pipeline.svg)](https://gitlab.com/farmOS/farmOS-aggregator/commits/master) 6 | [![Docker](https://img.shields.io/docker/pulls/farmos/aggregator.svg)](https://hub.docker.com/r/farmos/aggregator/) 7 | [![Last commit](https://img.shields.io/github/last-commit/farmOS/farmOS-aggregator.svg?style=flat)](https://github.com/farmOS/farmOS-aggregator/commits) 8 | [![Chat](https://img.shields.io/matrix/farmOS:matrix.org.svg)](https://riot.im/app/#/room/#farmOS:matrix.org) 9 | 10 |

11 | 12 |

13 | 14 | farmOS-aggregator is a microservice application for interacting with multiple [farmOS](https://farmOS.org) 15 | instances. The application provides a GUI for registering farmOS instances with the Aggregator 16 | and a REST API for interacting with farmOS instances. Depending on how an Aggregator is configured, 17 | farmOS admins will authorize access to only a subset of their farm's data. 18 | 19 | farmOS-aggregator is built with: 20 | * [FastAPI](https://github.com/tiangolo/fastapi) for the REST API 21 | * [farmOS.py](https://github.com/farmOS/farmOS.py) client library for querying farmOS instances 22 | 23 | For more information on farmOS, visit [farmOS.org](https://farmOS.org). 24 | 25 | ## Documentation 26 | 27 | - _**Using** the farmOS-aggregator_: [docs/using-farmos-aggregator.md](docs/using-farmos-aggregator.md) 28 | - _**Configuring** a farmOS-aggregator_: [docs/configuration.md](docs/configuration.md) 29 | - _**Deploying** a farmOS-aggregator_: [docs/deployment.md](docs/deployment.md) 30 | - _**API Documentation**_: [docs/api.md](docs/api.md) 31 | - _**Development**_: [docs/development.md](docs/development.md) 32 | 33 | ## FAQ 34 | 35 | #### _What are the motivations for creating farmOS-Aggregator?_ 36 | 37 | #### _Does this make all of my farmOS data available to the public?_ 38 | No. 39 | 40 | #### _I have awesome farm data stored in my farmOS server! Are there any Aggregators that I can share my data with?_ 41 | 42 | ## MAINTAINERS 43 | 44 | * Paul Weidner (paul121) - https://github.com/paul121 45 | 46 | This project has been sponsored by: 47 | 48 | * [Farmier](https://farmier.com) 49 | * [Pennsylvania Association for Sustainable Agriculture](https://pasafarming.org) 50 | * [Our Sci](http://our-sci.net) 51 | * [Bionutrient Food Association](https://bionutrient.org) 52 | * [Foundation for Food and Agriculture Research](https://foundationfar.org/) 53 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | app.egg-info 3 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM tiangolo/uvicorn-gunicorn-fastapi:python3.10 2 | 3 | WORKDIR /app/ 4 | 5 | # Install Poetry 6 | RUN curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python && \ 7 | cd /usr/local/bin && \ 8 | ln -s /opt/poetry/bin/poetry && \ 9 | poetry config virtualenvs.create false 10 | 11 | # Copy poetry.lock* in case it doesn't exist in the repo 12 | COPY ./app/pyproject.toml ./app/poetry.lock* /app/ 13 | 14 | # Allow installing dev dependencies to run tests 15 | ARG INSTALL_DEV=false 16 | RUN bash -c "if [ $INSTALL_DEV == 'true' ] ; then poetry install --no-root ; else poetry install --no-root --no-dev ; fi" 17 | 18 | COPY ./app /app 19 | ENV PYTHONPATH=/app 20 | 21 | EXPOSE 80 22 | -------------------------------------------------------------------------------- /backend/app/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | #truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to alembic/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | # Logging configuration 39 | [loggers] 40 | keys = root,sqlalchemy,alembic 41 | 42 | [handlers] 43 | keys = console 44 | 45 | [formatters] 46 | keys = generic 47 | 48 | [logger_root] 49 | level = WARN 50 | handlers = console 51 | qualname = 52 | 53 | [logger_sqlalchemy] 54 | level = WARN 55 | handlers = 56 | qualname = sqlalchemy.engine 57 | 58 | [logger_alembic] 59 | level = INFO 60 | handlers = 61 | qualname = alembic 62 | 63 | [handler_console] 64 | class = StreamHandler 65 | args = (sys.stderr,) 66 | level = NOTSET 67 | formatter = generic 68 | 69 | [formatter_generic] 70 | format = %(levelname)-5.5s [%(name)s] %(message)s 71 | datefmt = %H:%M:%S 72 | -------------------------------------------------------------------------------- /backend/app/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /backend/app/alembic/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import os 4 | from logging.config import fileConfig 5 | 6 | from sqlalchemy import engine_from_config, pool 7 | 8 | from alembic import context 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | fileConfig(config.config_file_name) 17 | 18 | # add your model's MetaData object here 19 | # for 'autogenerate' support 20 | # from myapp import mymodel 21 | # target_metadata = mymodel.Base.metadata 22 | # target_metadata = None 23 | 24 | from app.db.base import Base # noqa 25 | 26 | target_metadata = Base.metadata 27 | 28 | # other values from the config, defined by the needs of env.py, 29 | # can be acquired: 30 | # my_important_option = config.get_main_option("my_important_option") 31 | # ... etc. 32 | 33 | 34 | def get_url(): 35 | user = os.getenv("POSTGRES_USER", "postgres") 36 | password = os.getenv("POSTGRES_PASSWORD", "") 37 | server = os.getenv("POSTGRES_SERVER", "db") 38 | db = os.getenv("POSTGRES_DB", "app") 39 | return f"postgresql://{user}:{password}@{server}/{db}" 40 | 41 | 42 | def run_migrations_offline(): 43 | """Run migrations in 'offline' mode. 44 | 45 | This configures the context with just a URL 46 | and not an Engine, though an Engine is acceptable 47 | here as well. By skipping the Engine creation 48 | we don't even need a DBAPI to be available. 49 | 50 | Calls to context.execute() here emit the given string to the 51 | script output. 52 | 53 | """ 54 | url = get_url() 55 | context.configure( 56 | url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True 57 | ) 58 | 59 | with context.begin_transaction(): 60 | context.run_migrations() 61 | 62 | 63 | def run_migrations_online(): 64 | """Run migrations in 'online' mode. 65 | 66 | In this scenario we need to create an Engine 67 | and associate a connection with the context. 68 | 69 | """ 70 | configuration = config.get_section(config.config_ini_section) 71 | configuration["sqlalchemy.url"] = get_url() 72 | connectable = engine_from_config( 73 | configuration, 74 | prefix="sqlalchemy.", 75 | poolclass=pool.NullPool, 76 | ) 77 | 78 | with connectable.connect() as connection: 79 | context.configure( 80 | connection=connection, target_metadata=target_metadata, compare_type=True 81 | ) 82 | 83 | with context.begin_transaction(): 84 | context.run_migrations() 85 | 86 | 87 | if context.is_offline_mode(): 88 | run_migrations_offline() 89 | else: 90 | run_migrations_online() 91 | -------------------------------------------------------------------------------- /backend/app/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /backend/app/alembic/versions/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/alembic/versions/.keep -------------------------------------------------------------------------------- /backend/app/alembic/versions/21f1d47b6386_add_farm_table_and_farmtoken_table.py: -------------------------------------------------------------------------------- 1 | """Add farm table and farmtoken table 2 | 3 | Revision ID: 21f1d47b6386 4 | Revises: d4867f3a4c0a 5 | Create Date: 2020-01-29 00:53:26.627087 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from sqlalchemy.dialects import postgresql 10 | 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "21f1d47b6386" 15 | down_revision = "d4867f3a4c0a" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "farm", 24 | sa.Column("id", sa.Integer(), nullable=False), 25 | sa.Column( 26 | "time_created", 27 | sa.DateTime(timezone=True), 28 | server_default=sa.text("now()"), 29 | nullable=True, 30 | ), 31 | sa.Column("time_updated", sa.DateTime(timezone=True), nullable=True), 32 | sa.Column("last_accessed", sa.DateTime(timezone=True), nullable=True), 33 | sa.Column("farm_name", sa.String(), nullable=True), 34 | sa.Column("url", sa.String(), nullable=True), 35 | sa.Column("notes", sa.String(), nullable=True), 36 | sa.Column("tags", sa.String(), nullable=True), 37 | sa.Column("active", sa.Boolean(), nullable=True), 38 | sa.Column("info", postgresql.JSONB(astext_type=sa.Text()), nullable=True), 39 | sa.Column("is_authorized", sa.Boolean(), nullable=True), 40 | sa.Column("auth_error", sa.String(), nullable=True), 41 | sa.PrimaryKeyConstraint("id"), 42 | ) 43 | op.create_index(op.f("ix_farm_farm_name"), "farm", ["farm_name"], unique=False) 44 | op.create_index(op.f("ix_farm_id"), "farm", ["id"], unique=False) 45 | op.create_index(op.f("ix_farm_url"), "farm", ["url"], unique=True) 46 | op.create_table( 47 | "farmtoken", 48 | sa.Column("id", sa.Integer(), nullable=False), 49 | sa.Column("access_token", sa.String(), nullable=True), 50 | sa.Column("expires_in", sa.String(), nullable=True), 51 | sa.Column("refresh_token", sa.String(), nullable=True), 52 | sa.Column("expires_at", sa.String(), nullable=True), 53 | sa.Column("farm_id", sa.Integer(), nullable=True), 54 | sa.ForeignKeyConstraint( 55 | ["farm_id"], 56 | ["farm.id"], 57 | ), 58 | sa.PrimaryKeyConstraint("id"), 59 | sa.UniqueConstraint("farm_id"), 60 | ) 61 | # ### end Alembic commands ### 62 | 63 | 64 | def downgrade(): 65 | # ### commands auto generated by Alembic - please adjust! ### 66 | op.drop_table("farmtoken") 67 | op.drop_index(op.f("ix_farm_url"), table_name="farm") 68 | op.drop_index(op.f("ix_farm_id"), table_name="farm") 69 | op.drop_index(op.f("ix_farm_farm_name"), table_name="farm") 70 | op.drop_table("farm") 71 | # ### end Alembic commands ### 72 | -------------------------------------------------------------------------------- /backend/app/alembic/versions/90a5fd6c8be7_create_apikey_table.py: -------------------------------------------------------------------------------- 1 | """Create apikey table. 2 | 3 | Revision ID: 90a5fd6c8be7 4 | Revises: cd672c4e6bda 5 | Create Date: 2020-03-10 21:59:46.206990 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from sqlalchemy.dialects import postgresql 10 | 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "90a5fd6c8be7" 15 | down_revision = "cd672c4e6bda" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "apikey", 24 | sa.Column("id", sa.Integer(), nullable=False), 25 | sa.Column( 26 | "time_created", 27 | sa.DateTime(timezone=True), 28 | server_default=sa.text("now()"), 29 | nullable=True, 30 | ), 31 | sa.Column("key", sa.LargeBinary(), nullable=True), 32 | sa.Column("enabled", sa.Boolean(), nullable=True), 33 | sa.Column("name", sa.String(), nullable=True), 34 | sa.Column("notes", sa.String(), nullable=True), 35 | sa.Column("farm_id", postgresql.ARRAY(sa.Integer()), nullable=True), 36 | sa.Column("all_farms", sa.Boolean(), nullable=True), 37 | sa.Column("scopes", postgresql.ARRAY(sa.String()), nullable=True), 38 | sa.PrimaryKeyConstraint("id"), 39 | ) 40 | # ### end Alembic commands ### 41 | 42 | 43 | def downgrade(): 44 | # ### commands auto generated by Alembic - please adjust! ### 45 | op.drop_table("apikey") 46 | # ### end Alembic commands ### 47 | -------------------------------------------------------------------------------- /backend/app/alembic/versions/cd672c4e6bda_add_scope_string_to_farm_model.py: -------------------------------------------------------------------------------- 1 | """Add scope string to Farm model 2 | 3 | Revision ID: cd672c4e6bda 4 | Revises: 21f1d47b6386 5 | Create Date: 2020-02-04 03:00:30.105475 6 | 7 | """ 8 | import sqlalchemy as sa 9 | 10 | from alembic import op 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "cd672c4e6bda" 14 | down_revision = "21f1d47b6386" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column("farm", sa.Column("scope", sa.String(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column("farm", "scope") 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /backend/app/alembic/versions/d2422b5a6859_add_indexes_to_farm_token.py: -------------------------------------------------------------------------------- 1 | """Add indexes to farm_token 2 | 3 | Revision ID: d2422b5a6859 4 | Revises: 90a5fd6c8be7 5 | Create Date: 2020-05-21 15:46:17.852847 6 | 7 | """ 8 | import sqlalchemy as sa 9 | 10 | from alembic import op 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "d2422b5a6859" 14 | down_revision = "90a5fd6c8be7" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_index(op.f("ix_farmtoken_farm_id"), "farmtoken", ["farm_id"], unique=True) 22 | op.create_index(op.f("ix_farmtoken_id"), "farmtoken", ["id"], unique=False) 23 | op.drop_constraint("farmtoken_farm_id_key", "farmtoken", type_="unique") 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.create_unique_constraint("farmtoken_farm_id_key", "farmtoken", ["farm_id"]) 30 | op.drop_index(op.f("ix_farmtoken_id"), table_name="farmtoken") 31 | op.drop_index(op.f("ix_farmtoken_farm_id"), table_name="farmtoken") 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /backend/app/alembic/versions/d4867f3a4c0a_first_revision.py: -------------------------------------------------------------------------------- 1 | """First revision 2 | 3 | Revision ID: d4867f3a4c0a 4 | Revises: 5 | Create Date: 2019-04-17 13:53:32.978401 6 | 7 | """ 8 | import sqlalchemy as sa 9 | 10 | from alembic import op 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "d4867f3a4c0a" 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "user", 23 | sa.Column("id", sa.Integer(), nullable=False), 24 | sa.Column("full_name", sa.String(), nullable=True), 25 | sa.Column("email", sa.String(), nullable=True), 26 | sa.Column("hashed_password", sa.String(), nullable=True), 27 | sa.Column("is_active", sa.Boolean(), nullable=True), 28 | sa.Column("is_superuser", sa.Boolean(), nullable=True), 29 | sa.PrimaryKeyConstraint("id"), 30 | ) 31 | op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True) 32 | op.create_index(op.f("ix_user_full_name"), "user", ["full_name"], unique=False) 33 | op.create_index(op.f("ix_user_id"), "user", ["id"], unique=False) 34 | # ### end Alembic commands ### 35 | 36 | 37 | def downgrade(): 38 | # ### commands auto generated by Alembic - please adjust! ### 39 | op.drop_index(op.f("ix_user_id"), table_name="user") 40 | op.drop_index(op.f("ix_user_full_name"), table_name="user") 41 | op.drop_index(op.f("ix_user_email"), table_name="user") 42 | op.drop_table("user") 43 | # ### end Alembic commands ### 44 | -------------------------------------------------------------------------------- /backend/app/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/app/__init__.py -------------------------------------------------------------------------------- /backend/app/app/backend_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 4 | 5 | from app.db.session import SessionLocal 6 | 7 | logging.basicConfig(level=logging.INFO) 8 | logger = logging.getLogger(__name__) 9 | 10 | max_tries = 60 * 5 # 5 minutes 11 | wait_seconds = 1 12 | 13 | 14 | @retry( 15 | stop=stop_after_attempt(max_tries), 16 | wait=wait_fixed(wait_seconds), 17 | before=before_log(logger, logging.INFO), 18 | after=after_log(logger, logging.WARN), 19 | ) 20 | def init(): 21 | try: 22 | db = SessionLocal() 23 | # Try to create session to check if DB is awake 24 | db.execute("SELECT 1") 25 | except Exception as e: 26 | logger.error(e) 27 | raise e 28 | 29 | 30 | def main(): 31 | logger.info("Initializing service") 32 | init() 33 | logger.info("Service finished initializing") 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /backend/app/app/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/app/core/__init__.py -------------------------------------------------------------------------------- /backend/app/app/core/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import secrets 3 | from typing import List 4 | 5 | from pydantic import ( 6 | AnyHttpUrl, 7 | BaseSettings, 8 | EmailStr, 9 | HttpUrl, 10 | Json, 11 | PostgresDsn, 12 | validator, 13 | ) 14 | 15 | 16 | class Settings(BaseSettings): 17 | API_PREFIX: str = "/api" 18 | 19 | API_V2_PREFIX: str = None 20 | 21 | @validator("API_V2_PREFIX", pre=True, always=True) 22 | def build_api_v2_prefix(cls, v, values): 23 | base = values.get("API_PREFIX") 24 | prefix = "/v2" 25 | if isinstance(v, str): 26 | prefix = v 27 | return base + prefix 28 | 29 | SECRET_KEY: str = secrets.token_urlsafe(32) 30 | 31 | ACCESS_TOKEN_EXPIRE_MINUTES: int = ( 32 | 60 * 24 * 8 33 | ) # 60 minutes * 24 hours * 8 days = 8 days 34 | 35 | SERVER_NAME: str 36 | SERVER_HOST: AnyHttpUrl 37 | # BACKEND_CORS_ORIGINS is a JSON-formatted list of origins. 38 | # e.g: '["http://localhost", "http://localhost:4200"]' 39 | 40 | BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] 41 | 42 | @validator("BACKEND_CORS_ORIGINS", pre=True) 43 | def assemble_cors_origins(cls, v): 44 | if isinstance(v, str): 45 | return [i.strip() for i in v.split(",")] 46 | return v 47 | 48 | AGGREGATOR_NAME: str 49 | 50 | POSTGRES_SERVER: str 51 | POSTGRES_USER: str 52 | POSTGRES_PASSWORD: str 53 | POSTGRES_DB: str 54 | SQLALCHEMY_POOL_SIZE: int = 10 55 | SQLALCHEMY_MAX_OVERFLOW: int = 15 56 | SQLALCHEMY_DATABASE_URI: PostgresDsn = None 57 | 58 | @validator("SQLALCHEMY_DATABASE_URI", pre=True) 59 | def assemble_db_connection(cls, v, values): 60 | if isinstance(v, str): 61 | return v 62 | return PostgresDsn.build( 63 | scheme="postgresql", 64 | user=values.get("POSTGRES_USER"), 65 | password=values.get("POSTGRES_PASSWORD"), 66 | host=values.get("POSTGRES_SERVER"), 67 | path=f"/{values.get('POSTGRES_DB') or ''}", 68 | ) 69 | 70 | SMTP_TLS: bool = True 71 | SMTP_PORT: int = None 72 | SMTP_HOST: str = None 73 | SMTP_USER: str = None 74 | SMTP_PASSWORD: str = None 75 | EMAILS_FROM_EMAIL: EmailStr = None 76 | EMAILS_FROM_NAME: str = None 77 | 78 | @validator("EMAILS_FROM_NAME") 79 | def get_project_name(cls, v, values): 80 | if not v: 81 | return values["AGGREGATOR_NAME"] 82 | return v 83 | 84 | EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 85 | EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build" 86 | EMAILS_ENABLED: bool = False 87 | 88 | @validator("EMAILS_ENABLED", pre=True) 89 | def get_emails_enabled(cls, v, values): 90 | return bool( 91 | values.get("SMTP_HOST") 92 | and values.get("SMTP_PORT") 93 | and values.get("EMAILS_FROM_EMAIL") 94 | ) 95 | 96 | EMAIL_TEST_USER: EmailStr = "test@example.com" 97 | EMAIL_TESTING: bool = False 98 | 99 | FIRST_SUPERUSER: EmailStr 100 | FIRST_SUPERUSER_PASSWORD: str 101 | 102 | USERS_OPEN_REGISTRATION: bool = False 103 | 104 | TEST_FARM_NAME: str = "farmOS-test-instance" 105 | TEST_FARM_URL: AnyHttpUrl = None 106 | TEST_FARM_USERNAME: str = None 107 | TEST_FARM_PASSWORD: str = None 108 | 109 | AGGREGATOR_OPEN_FARM_REGISTRATION: bool = False 110 | AGGREGATOR_INVITE_FARM_REGISTRATION: bool = False 111 | FARM_ACTIVE_AFTER_REGISTRATION: bool = False 112 | AGGREGATOR_ALERT_NEW_FARMS: bool = False 113 | AGGREGATOR_ALERT_ALL_ERRORS: bool = False 114 | AGGREGATOR_ALERT_PING_FARMS_ERRORS: bool = True 115 | 116 | AGGREGATOR_OAUTH_CLIENT_ID: str 117 | AGGREGATOR_OAUTH_CLIENT_SECRET: str = None 118 | AGGREGATOR_OAUTH_INSECURE_TRANSPORT: bool = False 119 | 120 | class Config: 121 | case_sensitive = True 122 | 123 | 124 | settings = Settings() 125 | -------------------------------------------------------------------------------- /backend/app/app/core/jwt.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import List 3 | 4 | import jwt 5 | 6 | from app.core.config import settings 7 | 8 | ALGORITHM = "HS256" 9 | 10 | 11 | def create_access_token(*, data: dict, expires_delta: timedelta = None): 12 | to_encode = data.copy() 13 | if expires_delta: 14 | expire = datetime.utcnow() + expires_delta 15 | else: 16 | expire = datetime.utcnow() + timedelta(minutes=15) 17 | to_encode.update({"exp": expire}) 18 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) 19 | return encoded_jwt 20 | 21 | 22 | def create_api_key(farm_id: List[int], scopes: List[str], all_farms=False): 23 | now = datetime.utcnow() 24 | encoded_jwt = jwt.encode( 25 | { 26 | "nbf": now.timestamp(), 27 | "farm_id": farm_id, 28 | "all_farms": all_farms, 29 | "scopes": scopes, 30 | }, 31 | settings.SECRET_KEY, 32 | algorithm=ALGORITHM, 33 | ) 34 | return encoded_jwt 35 | 36 | 37 | def create_farm_api_token(farm_id: List[int], scopes: List[str]): 38 | delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS) 39 | now = datetime.utcnow() 40 | expires = now + delta 41 | encoded_jwt = jwt.encode( 42 | { 43 | "exp": expires.timestamp(), 44 | "nbf": now.timestamp(), 45 | "farm_id": farm_id, 46 | "scopes": scopes, 47 | }, 48 | settings.SECRET_KEY, 49 | algorithm=ALGORITHM, 50 | ) 51 | return encoded_jwt 52 | -------------------------------------------------------------------------------- /backend/app/app/core/security.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | 3 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 4 | 5 | 6 | def verify_password(plain_password: str, hashed_password: str): 7 | return pwd_context.verify(plain_password, hashed_password) 8 | 9 | 10 | def get_password_hash(password: str): 11 | return pwd_context.hash(password) 12 | -------------------------------------------------------------------------------- /backend/app/app/crud/__init__.py: -------------------------------------------------------------------------------- 1 | from . import api_key, farm, farm_token, user 2 | -------------------------------------------------------------------------------- /backend/app/app/crud/api_key.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi.encoders import jsonable_encoder 4 | from sqlalchemy.orm import Session 5 | 6 | from app.core.jwt import create_api_key 7 | from app.models.api_key import ApiKey 8 | from app.schemas.api_key import ApiKeyCreate, ApiKeyUpdate 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def get_by_id(db: Session, key_id: int): 14 | return db.query(ApiKey).filter(ApiKey.id == key_id).first() 15 | 16 | 17 | def get_by_key(db: Session, key: bytes): 18 | return db.query(ApiKey).filter(ApiKey.key == key).first() 19 | 20 | 21 | def get_multi(db: Session): 22 | return db.query(ApiKey).all() 23 | 24 | 25 | def create(db: Session, api_key_in: ApiKeyCreate): 26 | # Generate the JWT token that will be saved as the 'key'. 27 | key = create_api_key( 28 | farm_id=api_key_in.farm_id, 29 | all_farms=api_key_in.all_farms, 30 | scopes=api_key_in.scopes, 31 | ) 32 | db_item = ApiKey(key=key, **api_key_in.dict()) 33 | db.add(db_item) 34 | db.commit() 35 | 36 | logger.debug("Created API Key: " + key.decode()) 37 | db.refresh(db_item) 38 | return db_item 39 | 40 | 41 | def update(db: Session, *, api_key: ApiKey, api_key_in: ApiKeyUpdate): 42 | api_key_data = jsonable_encoder(api_key) 43 | update_data = api_key_in.dict(exclude_unset=True) 44 | for field in api_key_data: 45 | if field in update_data: 46 | setattr(api_key, field, update_data[field]) 47 | db.add(api_key) 48 | db.commit() 49 | db.refresh(api_key) 50 | return api_key 51 | 52 | 53 | def delete(db: Session, *, key_id: int): 54 | key = get_by_id(db=db, key_id=key_id) 55 | db.delete(key) 56 | db.commit() 57 | -------------------------------------------------------------------------------- /backend/app/app/crud/farm_token.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from fastapi.encoders import jsonable_encoder 4 | from sqlalchemy.orm import Session 5 | 6 | from app.models.farm_token import FarmToken 7 | from app.schemas.farm_token import FarmTokenBase, FarmTokenCreate 8 | 9 | 10 | def get_farm_token(db: Session, farm_id: int): 11 | return db.query(FarmToken).filter(FarmToken.farm_id == farm_id).first() 12 | 13 | 14 | def create_farm_token(db: Session, token: FarmTokenCreate): 15 | db_item = FarmToken(**token.dict()) 16 | db.add(db_item) 17 | db.commit() 18 | db.refresh(db_item) 19 | return db_item 20 | 21 | 22 | def update_farm_token(db: Session, token: FarmToken, token_in: FarmTokenBase): 23 | token_data = jsonable_encoder(token) 24 | update_data = token_in.dict(exclude_unset=True) 25 | for field in token_data: 26 | if field in update_data: 27 | setattr(token, field, update_data[field]) 28 | db.add(token) 29 | db.commit() 30 | db.refresh(token) 31 | return token 32 | -------------------------------------------------------------------------------- /backend/app/app/crud/user.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from fastapi.encoders import jsonable_encoder 4 | from sqlalchemy.orm import Session 5 | 6 | from app.core.security import get_password_hash, verify_password 7 | from app.models.user import User 8 | from app.schemas.user import UserCreate, UserUpdate 9 | 10 | 11 | def get(db: Session, *, user_id: int) -> Optional[User]: 12 | return db.query(User).filter(User.id == user_id).first() 13 | 14 | 15 | def get_by_email(db: Session, *, email: str) -> Optional[User]: 16 | return db.query(User).filter(User.email == email).first() 17 | 18 | 19 | def authenticate(db: Session, *, email: str, password: str) -> Optional[User]: 20 | user = get_by_email(db, email=email) 21 | if not user: 22 | return None 23 | if not verify_password(password, user.hashed_password): 24 | return None 25 | return user 26 | 27 | 28 | def is_active(user) -> bool: 29 | return user.is_active 30 | 31 | 32 | def is_superuser(user) -> bool: 33 | return user.is_superuser 34 | 35 | 36 | def get_multi(db: Session, *, skip=0, limit=100) -> List[Optional[User]]: 37 | return db.query(User).offset(skip).limit(limit).all() 38 | 39 | 40 | def create(db: Session, *, user_in: UserCreate) -> User: 41 | user = User( 42 | email=user_in.email, 43 | hashed_password=get_password_hash(user_in.password), 44 | full_name=user_in.full_name, 45 | is_superuser=user_in.is_superuser, 46 | ) 47 | db.add(user) 48 | db.commit() 49 | db.refresh(user) 50 | return user 51 | 52 | 53 | def update(db: Session, *, user: User, user_in: UserUpdate) -> User: 54 | user_data = jsonable_encoder(user) 55 | update_data = user_in.dict(skip_defaults=True) 56 | for field in user_data: 57 | if field in update_data: 58 | setattr(user, field, update_data[field]) 59 | if user_in.password: 60 | passwordhash = get_password_hash(user_in.password) 61 | user.hashed_password = passwordhash 62 | db.add(user) 63 | db.commit() 64 | db.refresh(user) 65 | return user 66 | -------------------------------------------------------------------------------- /backend/app/app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/app/db/__init__.py -------------------------------------------------------------------------------- /backend/app/app/db/base.py: -------------------------------------------------------------------------------- 1 | # Import all the schemas, so that Base has them before being 2 | # imported by Alembic 3 | from app.db.base_class import Base # noqa 4 | from app.models.api_key import ApiKey 5 | from app.models.farm import Farm 6 | from app.models.farm_token import FarmToken 7 | from app.models.user import User # noqa 8 | -------------------------------------------------------------------------------- /backend/app/app/db/base_class.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.declarative import declarative_base, declared_attr 2 | 3 | 4 | class CustomBase(object): 5 | # Generate __tablename__ automatically 6 | @declared_attr 7 | def __tablename__(cls): 8 | return cls.__name__.lower() 9 | 10 | 11 | Base = declarative_base(cls=CustomBase) 12 | -------------------------------------------------------------------------------- /backend/app/app/db/init_db.py: -------------------------------------------------------------------------------- 1 | from app import crud 2 | from app.core.config import settings 3 | 4 | # make sure all SQL Alchemy schemas are imported before initializing DB 5 | # otherwise, SQL Alchemy might fail to initialize properly relationships 6 | # for more details: https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/28 7 | from app.db import base 8 | from app.schemas.user import UserCreate 9 | 10 | 11 | def init_db(db): 12 | # Tables should be created with Alembic migrations 13 | # But if you don't want to use migrations, create 14 | # the tables un-commenting the next line 15 | # Base.metadata.create_all(bind=engine) 16 | 17 | user = crud.user.get_by_email(db, email=settings.FIRST_SUPERUSER) 18 | if not user: 19 | user_in = UserCreate( 20 | email=settings.FIRST_SUPERUSER, 21 | password=settings.FIRST_SUPERUSER_PASSWORD, 22 | is_superuser=True, 23 | ) 24 | user = crud.user.create(db, user_in=user_in) 25 | -------------------------------------------------------------------------------- /backend/app/app/db/session.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import scoped_session, sessionmaker 3 | 4 | from app.core.config import settings 5 | 6 | engine = create_engine( 7 | settings.SQLALCHEMY_DATABASE_URI, 8 | pool_size=settings.SQLALCHEMY_POOL_SIZE, 9 | max_overflow=settings.SQLALCHEMY_MAX_OVERFLOW, 10 | pool_pre_ping=True, 11 | echo_pool=True, 12 | ) 13 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 14 | -------------------------------------------------------------------------------- /backend/app/app/email-templates/src/admin_alert.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ aggregator_name }} 7 | 8 | 9 | 10 | {{ message }} 11 | 12 | You are receiving this message because you are an administrator of the {{ aggregator_name }}. This email was sent for either an error or alert of an event. 13 | 14 | Go to the Dashboard 15 | 16 | 17 | 18 | {{ aggregator_name }} is an instance of the farmOS Aggregator that allows communities and organizations to aggregate farm records from multiple farmOS servers. 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /backend/app/app/email-templates/src/authorize_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ aggregator_name }} 7 | 8 | 9 | 10 | Please authorize your farmOS server with the {{ aggregator_name }}. 11 | 12 | Authorize Now 13 | 14 | 15 | 16 | {{ aggregator_name }} is an instance of the farmOS Aggregator that allows communities and organizations to aggregate farm records from multiple farmOS servers. 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /backend/app/app/email-templates/src/new_account.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} - New Account 7 | You have a new account: 8 | Username: {{ username }} 9 | Password: {{ password }} 10 | Go to Dashboard 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /backend/app/app/email-templates/src/registration_invite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ aggregator_name }} 7 | 8 | 9 | 10 | This is an invitation to register your farmOS server with the {{ aggregator_name }}! 11 | 12 | Register Now 13 | 14 | 15 | 16 | {{ aggregator_name }} is an instance of the farmOS Aggregator that allows communities and organizations to aggregate farm records from multiple farmOS servers. 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /backend/app/app/email-templates/src/reset_password.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} - Password Recovery 7 | We received a request to recover the password for user {{ username }} 8 | with email {{ email }} 9 | Reset your password by clicking the button below: 10 | Reset Password 11 | Or open the following link: 12 | {{ link }} 13 | 14 | The reset password link / button will expire in {{ valid_hours }} hours. 15 | If you didn't request a password recovery you can disregard this email. 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /backend/app/app/initial_data.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.db.init_db import init_db 4 | from app.db.session import SessionLocal 5 | 6 | logging.basicConfig(level=logging.INFO) 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def init(): 11 | db = SessionLocal() 12 | init_db(db) 13 | 14 | 15 | def main(): 16 | logger.info("Creating initial data") 17 | init() 18 | logger.info("Initial data created") 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /backend/app/app/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import FastAPI, Response 4 | from starlette.middleware.cors import CORSMiddleware 5 | from starlette.requests import Request 6 | 7 | from app.core.config import settings 8 | from app.routers import api_v2 9 | 10 | # Configure logging. Change INFO to DEBUG for development logging. 11 | logging.basicConfig(level=logging.INFO) 12 | 13 | app = FastAPI( 14 | title=settings.AGGREGATOR_NAME, 15 | description="farmOS Aggregator Backend", 16 | version="v0.9.5", 17 | openapi_url=f"{settings.API_PREFIX}/openapi.json", 18 | ) 19 | 20 | # Set all CORS enabled origins 21 | if settings.BACKEND_CORS_ORIGINS: 22 | app.add_middleware( 23 | CORSMiddleware, 24 | allow_origins=settings.BACKEND_CORS_ORIGINS, 25 | allow_credentials=True, 26 | allow_methods=["*"], 27 | allow_headers=["*"], 28 | ), 29 | 30 | app.include_router(api_v2.router, prefix=settings.API_V2_PREFIX) 31 | -------------------------------------------------------------------------------- /backend/app/app/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/app/models/__init__.py -------------------------------------------------------------------------------- /backend/app/app/models/api_key.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Boolean, Column, DateTime, Integer, LargeBinary, String 2 | from sqlalchemy.dialects.postgresql import ARRAY 3 | from sqlalchemy.sql import func 4 | 5 | from app.db.base_class import Base 6 | 7 | 8 | class ApiKey(Base): 9 | __tablename__ = "apikey" 10 | 11 | id = Column(Integer, primary_key=True) 12 | time_created = Column(DateTime(timezone=True), server_default=func.now()) 13 | key = Column(LargeBinary) 14 | enabled = Column(Boolean, default=False) 15 | name = Column(String) 16 | notes = Column(String) 17 | farm_id = Column(ARRAY(Integer)) 18 | all_farms = Column(Boolean, default=False) 19 | scopes = Column(ARRAY(String)) 20 | -------------------------------------------------------------------------------- /backend/app/app/models/farm.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Boolean, Column, DateTime, Integer, String 2 | from sqlalchemy.dialects.postgresql import JSONB 3 | from sqlalchemy.orm import deferred, relationship 4 | from sqlalchemy.sql import func 5 | 6 | from app.db.base_class import Base 7 | from app.models.farm_token import FarmToken 8 | 9 | 10 | class Farm(Base): 11 | __tablename__ = "farm" 12 | 13 | id = Column(Integer, primary_key=True, index=True) 14 | time_created = Column(DateTime(timezone=True), server_default=func.now()) 15 | time_updated = Column(DateTime(timezone=True), onupdate=func.now()) 16 | last_accessed = Column(DateTime(timezone=True)) 17 | farm_name = Column(String, index=True) 18 | url = Column(String, index=True, unique=True) 19 | notes = Column(String, nullable=True) 20 | tags = Column(String, nullable=True) 21 | 22 | # Save a space separated list of OAuth Scopes 23 | scope = Column(String, nullable=True) 24 | 25 | # active attribute allows admins to disable farmOS profiles 26 | active = Column(Boolean, default=False) 27 | 28 | # Store farm info in a JSONB column 29 | info = deferred(Column(JSONB, nullable=True)) 30 | 31 | is_authorized = Column(Boolean, default=False) 32 | token = relationship( 33 | "FarmToken", uselist=False, back_populates="farm", lazy="joined" 34 | ) 35 | auth_error = Column(String, nullable=True) 36 | -------------------------------------------------------------------------------- /backend/app/app/models/farm_token.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Boolean, Column, ForeignKey, Integer, String 2 | from sqlalchemy.orm import relationship 3 | 4 | from app.db.base_class import Base 5 | 6 | 7 | class FarmToken(Base): 8 | __tablename__ = "farmtoken" 9 | 10 | id = Column(Integer, primary_key=True, index=True) 11 | access_token = Column(String) 12 | expires_in = Column(String) 13 | refresh_token = Column(String) 14 | expires_at = Column(String) 15 | 16 | farm_id = Column(Integer, ForeignKey("farm.id"), unique=True, index=True) 17 | farm = relationship("Farm", uselist=False, back_populates="token") 18 | -------------------------------------------------------------------------------- /backend/app/app/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Boolean, Column, Integer, String 2 | from sqlalchemy.orm import relationship 3 | 4 | from app.db.base_class import Base 5 | 6 | 7 | class User(Base): 8 | id = Column(Integer, primary_key=True, index=True) 9 | full_name = Column(String, index=True) 10 | email = Column(String, unique=True, index=True) 11 | hashed_password = Column(String) 12 | is_active = Column(Boolean(), default=True) 13 | is_superuser = Column(Boolean(), default=False) 14 | -------------------------------------------------------------------------------- /backend/app/app/routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/app/routers/__init__.py -------------------------------------------------------------------------------- /backend/app/app/routers/api_v2/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import router 2 | -------------------------------------------------------------------------------- /backend/app/app/routers/api_v2/api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import APIRouter, Security 4 | 5 | from app.routers.api_v2.endpoints import api_key, farms, login, relay, users, utils 6 | from app.routers.api_v2.endpoints.resources import resources, subrequests 7 | from app.routers.utils.security import get_current_active_superuser, get_farm_access 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | router = APIRouter() 12 | router.include_router(login.router, tags=["login"]) 13 | router.include_router(users.router, prefix="/users", tags=["users"]) 14 | router.include_router(utils.router, prefix="/utils", tags=["utils"]) 15 | 16 | # Include /api-keys endpoint, require superuser to access. 17 | router.include_router( 18 | api_key.router, 19 | prefix="/api-keys", 20 | tags=["api keys"], 21 | dependencies=[Security(get_current_active_superuser)], 22 | ) 23 | 24 | # Include /farms endpoints. 25 | router.include_router( 26 | farms.router, 27 | prefix="/farms", 28 | tags=["farms"], 29 | ) 30 | 31 | # Include /farms/relay endpoints. 32 | router.include_router( 33 | relay.router, 34 | prefix="/farms/relay", 35 | tags=["Relay"], 36 | dependencies=[Security(get_farm_access, scopes=["farm:read"])], 37 | ) 38 | 39 | # Include /farms/resources endpoints. 40 | router.include_router( 41 | resources.router, 42 | prefix="/farms/resources", 43 | tags=["Resources"], 44 | dependencies=[Security(get_farm_access, scopes=["farm:read"])], 45 | ) 46 | 47 | # Include /farms/resources/subrequests endpoint. 48 | router.include_router( 49 | subrequests.router, 50 | prefix="/farms/resources/subrequests", 51 | tags=["Resources"], 52 | dependencies=[Security(get_farm_access, scopes=["farm:read"])], 53 | ) 54 | -------------------------------------------------------------------------------- /backend/app/app/routers/api_v2/endpoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/app/routers/api_v2/endpoints/__init__.py -------------------------------------------------------------------------------- /backend/app/app/routers/api_v2/endpoints/api_key.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import timedelta 3 | from typing import List 4 | 5 | from fastapi import APIRouter, Body, Depends, HTTPException 6 | from sqlalchemy.orm import Session 7 | 8 | from app import crud 9 | from app.routers.utils.db import get_db 10 | from app.schemas.api_key import ApiKey, ApiKeyCreate, ApiKeyUpdate 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | router = APIRouter() 15 | 16 | 17 | @router.get("/", response_model=List[ApiKey]) 18 | def get_api_keys(db: Session = Depends(get_db)): 19 | """ 20 | Return all api keys. 21 | """ 22 | return crud.api_key.get_multi(db) 23 | 24 | 25 | @router.post("/", response_model=ApiKey) 26 | def create_api_key( 27 | *, 28 | db: Session = Depends(get_db), 29 | key_in: ApiKeyCreate, 30 | ): 31 | """ 32 | Create a new API Key. 33 | """ 34 | api_key = crud.api_key.create(db, api_key_in=key_in) 35 | 36 | return api_key 37 | 38 | 39 | @router.put("/{key_id}", response_model=ApiKey) 40 | def update_api_key( 41 | *, 42 | db: Session = Depends(get_db), 43 | key_id: int, 44 | key_in: ApiKeyUpdate, 45 | ): 46 | """ 47 | Update an existing API Key. 48 | 49 | Only the name, notes, and enabled status can be updated. 50 | """ 51 | key = crud.api_key.get_by_id(db, key_id=key_id) 52 | if not key: 53 | raise HTTPException( 54 | status_code=404, 55 | detail="API Key not found.", 56 | ) 57 | 58 | key = crud.api_key.update(db, api_key=key, api_key_in=key_in) 59 | return key 60 | 61 | 62 | @router.delete("/{key_id}") 63 | def delete_api_key(*, db: Session = Depends(get_db), key_id: int): 64 | """ 65 | Delete an API Key. 66 | """ 67 | key = crud.api_key.delete(db, key_id=key_id) 68 | return key 69 | -------------------------------------------------------------------------------- /backend/app/app/routers/api_v2/endpoints/farms.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import APIRouter, Body, Depends, HTTPException, Query, Security 4 | from pydantic.typing import Optional 5 | from sqlalchemy.orm import Session 6 | 7 | from app import crud 8 | from app.routers.utils.db import get_db 9 | from app.routers.utils.farms import ( 10 | ClientError, 11 | admin_alert_email, 12 | get_farm_by_id, 13 | get_farm_client, 14 | get_farms_url_or_list, 15 | ) 16 | from app.routers.utils.security import get_farm_access, get_farm_access_allow_public 17 | from app.schemas.farm import AllFarmInfo, Farm, FarmCreate, FarmUpdate 18 | from app.utils import get_settings 19 | 20 | router = APIRouter() 21 | 22 | # /farms/ endpoints for farmOS instances 23 | 24 | 25 | @router.get( 26 | "/", 27 | response_model=List[Farm], 28 | dependencies=[Security(get_farm_access, scopes=["farm:read"])], 29 | ) 30 | def read_farms( 31 | farms: List[Farm] = Depends(get_farms_url_or_list), 32 | ): 33 | """ 34 | Retrieve farms 35 | """ 36 | return farms 37 | 38 | 39 | # /farms/info/ endpoint for accessing farmOS info 40 | @router.get( 41 | "/info", 42 | dependencies=[Security(get_farm_access, scopes=["farm:read", "farm.info"])], 43 | tags=["farm info"], 44 | ) 45 | def get_all_farm_info( 46 | db: Session = Depends(get_db), 47 | farm_list: List[Farm] = Depends(get_farms_url_or_list), 48 | use_cached: Optional[bool] = True, 49 | ): 50 | data = {} 51 | for farm in farm_list: 52 | data[farm.id] = {} 53 | 54 | if use_cached: 55 | data[farm.id] = farm.info 56 | else: 57 | 58 | try: 59 | farm_client = get_farm_client(db=db, farm=farm) 60 | except ClientError as e: 61 | data[farm.id] = str(e) 62 | 63 | try: 64 | response = farm_client.info() 65 | # Set the info depending on v1 or v2. 66 | # v2 provides info under the meta.farm key. 67 | if "meta" in response: 68 | info = response["meta"]["farm"] 69 | else: 70 | info = response 71 | data[farm.id]["info"] = info 72 | 73 | crud.farm.update_info(db, farm=farm, info=info) 74 | except: 75 | continue 76 | 77 | return data 78 | 79 | 80 | @router.get( 81 | "/{farm_id}", 82 | response_model=AllFarmInfo, 83 | dependencies=[Security(get_farm_access, scopes=["farm:read"])], 84 | ) 85 | def read_farm_by_id(farm: Farm = Depends(get_farm_by_id)): 86 | """ 87 | Get a specific farm by id 88 | """ 89 | return farm 90 | 91 | 92 | @router.post( 93 | "/", 94 | response_model=Farm, 95 | dependencies=[Security(get_farm_access_allow_public, scopes=["farm:create"])], 96 | ) 97 | async def create_farm( 98 | *, 99 | db: Session = Depends(get_db), 100 | settings=Depends(get_settings), 101 | farm_in: FarmCreate, 102 | ): 103 | """ 104 | Create new farm 105 | """ 106 | existing_farm = crud.farm.get_by_url(db, farm_url=farm_in.url) 107 | if existing_farm: 108 | raise HTTPException( 109 | status_code=409, 110 | detail="A farm with this URL already exists.", 111 | ) 112 | 113 | if settings.AGGREGATOR_ALERT_NEW_FARMS: 114 | admin_alert_email( 115 | db=db, 116 | message="New farm created: " + farm_in.farm_name + " - " + farm_in.url, 117 | ) 118 | 119 | farm = crud.farm.create(db, farm_in=farm_in) 120 | 121 | return farm 122 | 123 | 124 | @router.put( 125 | "/{farm_id}", 126 | response_model=Farm, 127 | dependencies=[Security(get_farm_access, scopes=["farm:update"])], 128 | ) 129 | async def update_farm( 130 | *, 131 | db: Session = Depends(get_db), 132 | farm: Farm = Depends(get_farm_by_id), 133 | farm_in: FarmUpdate, 134 | ): 135 | """ 136 | Update farm 137 | """ 138 | if farm_in.url is not None: 139 | existing_farm = crud.farm.get_by_url(db, farm_url=farm_in.url) 140 | if existing_farm: 141 | raise HTTPException( 142 | status_code=409, 143 | detail="A farm with this URL already exists.", 144 | ) 145 | 146 | farm = crud.farm.update(db, farm=farm, farm_in=farm_in) 147 | return farm 148 | 149 | 150 | @router.delete( 151 | "/{farm_id}", 152 | response_model=Farm, 153 | dependencies=[Security(get_farm_access, scopes=["farm:delete"])], 154 | ) 155 | async def delete_farm( 156 | farm_id: int, db: Session = Depends(get_db), farm: Farm = Depends(get_farm_by_id) 157 | ): 158 | """ 159 | Delete farm 160 | """ 161 | farm = crud.farm.delete(db, farm_id=farm_id) 162 | return farm 163 | -------------------------------------------------------------------------------- /backend/app/app/routers/api_v2/endpoints/login.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import timedelta 3 | 4 | from fastapi import APIRouter, Body, Depends, HTTPException 5 | from fastapi.security import OAuth2PasswordRequestForm 6 | from sqlalchemy.orm import Session 7 | 8 | from app import crud 9 | from app.core.jwt import create_access_token 10 | from app.core.security import get_password_hash 11 | from app.models.user import User as DBUser 12 | from app.routers.utils.db import get_db 13 | from app.routers.utils.security import get_current_user 14 | from app.schemas.msg import Msg 15 | from app.schemas.token import Token 16 | from app.schemas.user import User 17 | from app.utils import ( 18 | generate_password_reset_token, 19 | get_settings, 20 | send_reset_password_email, 21 | verify_password_reset_token, 22 | ) 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | router = APIRouter() 27 | 28 | 29 | @router.post("/login/access-token", response_model=Token, tags=["login"]) 30 | def login_access_token( 31 | db: Session = Depends(get_db), 32 | settings=Depends(get_settings), 33 | form_data: OAuth2PasswordRequestForm = Depends(), 34 | ): 35 | """ 36 | OAuth2 compatible token login, get an access token for future requests 37 | """ 38 | user = crud.user.authenticate( 39 | db, email=form_data.username, password=form_data.password 40 | ) 41 | if not user: 42 | raise HTTPException(status_code=400, detail="Incorrect email or password") 43 | elif not crud.user.is_active(user): 44 | raise HTTPException(status_code=400, detail="Inactive user") 45 | access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) 46 | logger.debug(f"New user login with scopes: {form_data.scopes}") 47 | return { 48 | "access_token": create_access_token( 49 | data={"sub": user.id, "scopes": form_data.scopes}, 50 | expires_delta=access_token_expires, 51 | ), 52 | "token_type": "bearer", 53 | } 54 | 55 | 56 | @router.post("/login/test-token", tags=["login"], response_model=User) 57 | def test_token(current_user: DBUser = Depends(get_current_user)): 58 | """ 59 | Test access token 60 | """ 61 | return current_user 62 | 63 | 64 | @router.post("/password-recovery/{email}", tags=["login"], response_model=Msg) 65 | def recover_password(email: str, db: Session = Depends(get_db)): 66 | """ 67 | Password Recovery 68 | """ 69 | user = crud.user.get_by_email(db, email=email) 70 | 71 | if not user: 72 | raise HTTPException( 73 | status_code=404, 74 | detail="The user with this username does not exist in the system.", 75 | ) 76 | password_reset_token = generate_password_reset_token(email=email) 77 | send_reset_password_email( 78 | email_to=user.email, email=email, token=password_reset_token 79 | ) 80 | return {"msg": "Password recovery email sent"} 81 | 82 | 83 | @router.post("/reset-password/", tags=["login"], response_model=Msg) 84 | def reset_password( 85 | token: str = Body(...), new_password: str = Body(...), db: Session = Depends(get_db) 86 | ): 87 | """ 88 | Reset password 89 | """ 90 | email = verify_password_reset_token(token) 91 | if not email: 92 | raise HTTPException(status_code=400, detail="Invalid token") 93 | user = crud.user.get_by_email(db, email=email) 94 | if not user: 95 | raise HTTPException( 96 | status_code=404, 97 | detail="The user with this username does not exist in the system.", 98 | ) 99 | elif not crud.user.is_active(user): 100 | raise HTTPException(status_code=400, detail="Inactive user") 101 | hashed_password = get_password_hash(new_password) 102 | user.hashed_password = hashed_password 103 | db.add(user) 104 | db.commit() 105 | return {"msg": "Password updated successfully"} 106 | -------------------------------------------------------------------------------- /backend/app/app/routers/api_v2/endpoints/relay.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Body, Depends, HTTPException 2 | from pydantic.typing import Any, Optional 3 | from requests import HTTPError 4 | from sqlalchemy.orm import Session 5 | from starlette.requests import Request 6 | from starlette.responses import Response 7 | 8 | from app.routers.utils.db import get_db 9 | from app.routers.utils.farms import ClientError, get_farm_by_url, get_farm_client 10 | from app.schemas.farm import Farm 11 | 12 | router = APIRouter() 13 | 14 | 15 | # /farms/relay endpoint. 16 | 17 | 18 | # TODO: This does not seem to work if the farm_url includes a http scheme. 19 | # Specifying farm_url:path works, but then the second path:path argument does not accept a path. 20 | # It seems that there can only be one path argument per route. 21 | @router.api_route("/{farm_url}/{path:path}", methods=["GET", "POST", "PATCH", "DELETE"]) 22 | def relay( 23 | request: Request, 24 | response: Response, 25 | path: str, 26 | request_payload: Optional[Any] = Body(default=None), 27 | farm: Farm = Depends(get_farm_by_url), 28 | db: Session = Depends(get_db), 29 | ): 30 | # Get a farmOS client. 31 | try: 32 | farm_client = get_farm_client(db=db, farm=farm) 33 | except ClientError as e: 34 | raise HTTPException( 35 | status_code=500, 36 | detail="Client error", 37 | ) 38 | 39 | try: 40 | # Pass on a payload if provided. 41 | payload = None 42 | 43 | # If json was provided, pass the payload along under the json key. 44 | headers = {**request.headers} 45 | if "content-type" in headers and headers["content-type"] in [ 46 | "application/json", 47 | "application/vnd.api+json", 48 | ]: 49 | payload = {"json": request_payload} 50 | elif request_payload: 51 | payload = request_payload 52 | 53 | # Relay the request. 54 | query_params = {**request.query_params} 55 | server_response = farm_client.session.http_request( 56 | path=path, method=request.method, options=payload, params=query_params 57 | ) 58 | 59 | # Return the response. 60 | res_headers = server_response.headers 61 | response.status_code = server_response.status_code 62 | # Return json if specified. 63 | if "content-type" in res_headers and res_headers["content-type"] in [ 64 | "application/json", 65 | "application/vnd.api+json", 66 | ]: 67 | return server_response.json() 68 | # For DELETE requests, return a new response object with no content. 69 | elif response.status_code == 204: 70 | return Response(status_code=204) 71 | # Else return the response content. 72 | else: 73 | return server_response.content 74 | 75 | except HTTPError as e: 76 | raise HTTPException( 77 | status_code=e.response.status_code, 78 | detail=str(e), 79 | ) 80 | -------------------------------------------------------------------------------- /backend/app/app/routers/api_v2/endpoints/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/app/routers/api_v2/endpoints/resources/__init__.py -------------------------------------------------------------------------------- /backend/app/app/routers/api_v2/endpoints/resources/subrequests.py: -------------------------------------------------------------------------------- 1 | from farmOS.subrequests import Format, Subrequest 2 | from fastapi import APIRouter, Depends 3 | from pydantic.typing import List 4 | from sqlalchemy.orm import Session 5 | 6 | from app.routers.utils.db import get_db 7 | from app.routers.utils.farms import ( 8 | ClientError, 9 | get_active_farms_url_or_list, 10 | get_farm_client, 11 | ) 12 | from app.schemas.farm import Farm 13 | 14 | router = APIRouter() 15 | 16 | # /farms/resources/subrequests endpoint. 17 | 18 | 19 | @router.post("") 20 | def send_subrequests( 21 | blueprint: List[Subrequest], 22 | farm_list: List[Farm] = Depends(get_active_farms_url_or_list), 23 | db: Session = Depends(get_db), 24 | ): 25 | data = {} 26 | for farm in farm_list: 27 | data[farm.id] = [] 28 | 29 | # Get a farmOS client. 30 | try: 31 | farm_client = get_farm_client(db=db, farm=farm) 32 | except ClientError as e: 33 | data[farm.id] = str(e) 34 | continue 35 | 36 | # Make the request. 37 | try: 38 | data[farm.id] = farm_client.subrequests.send(blueprint, Format.json) 39 | except Exception as e: 40 | data[farm.id] = str(e) 41 | continue 42 | 43 | return data 44 | -------------------------------------------------------------------------------- /backend/app/app/routers/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/app/routers/utils/__init__.py -------------------------------------------------------------------------------- /backend/app/app/routers/utils/db.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.db.session import SessionLocal 4 | 5 | 6 | # A special FastAPI dependency used to get a SQLAlchemy session 7 | # and ensure it is closed after sending an HTTP response. 8 | # https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/ 9 | def get_db(): 10 | db = SessionLocal() 11 | try: 12 | logging.debug("Creating DB Session.") 13 | yield db 14 | finally: 15 | logging.debug("Closing DB Session.") 16 | db.close() 17 | -------------------------------------------------------------------------------- /backend/app/app/schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/app/schemas/__init__.py -------------------------------------------------------------------------------- /backend/app/app/schemas/api_key.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional 3 | 4 | from app.schemas.api_model import APIModel 5 | 6 | 7 | # API Key Models 8 | class ApiKeyBase(APIModel): 9 | enabled: Optional[bool] = False 10 | name: Optional[str] = None 11 | notes: Optional[str] = None 12 | 13 | 14 | class ApiKeyInDB(ApiKeyBase): 15 | id: int 16 | time_created: Optional[datetime] = None 17 | key: bytes 18 | farm_id: Optional[List[int]] = [] 19 | all_farms: Optional[bool] = False 20 | scopes: Optional[List[str]] = [] 21 | 22 | 23 | class ApiKeyCreate(ApiKeyBase): 24 | # Only allow these fields to be supplied when creating 25 | # an API Key. They cannot be modified later. 26 | farm_id: Optional[List[int]] = [] 27 | all_farms: Optional[bool] = False 28 | scopes: Optional[List[str]] = [] 29 | 30 | 31 | class ApiKeyUpdate(ApiKeyBase): 32 | pass 33 | 34 | 35 | class ApiKey(ApiKeyInDB): 36 | pass 37 | -------------------------------------------------------------------------------- /backend/app/app/schemas/api_model.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseConfig, BaseModel 2 | 3 | 4 | # Shared properties 5 | class APIModel(BaseModel): 6 | class Config(BaseConfig): 7 | orm_mode = True 8 | allow_population_by_field_name = True 9 | -------------------------------------------------------------------------------- /backend/app/app/schemas/farm.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from app.schemas.api_model import APIModel 5 | from app.schemas.farm_info import FarmInfo 6 | from app.schemas.farm_token import FarmToken, FarmTokenBase 7 | 8 | 9 | # Shared properties 10 | class FarmBase(APIModel): 11 | farm_name: Optional[str] = None 12 | url: Optional[str] = None 13 | notes: Optional[str] = None 14 | tags: Optional[str] = None 15 | active: Optional[bool] = None 16 | token: Optional[FarmToken] = None 17 | 18 | 19 | class FarmBaseInDB(FarmBase): 20 | id: int = None 21 | time_created: Optional[datetime] = None 22 | time_updated: Optional[datetime] = None 23 | 24 | 25 | # Properties to receive via API on creation 26 | class FarmCreate(FarmBase): 27 | farm_name: str 28 | url: str 29 | scope: Optional[str] = None 30 | token: Optional[FarmTokenBase] = None 31 | 32 | 33 | # Properties to receive via API on update 34 | class FarmUpdate(FarmBase): 35 | token: Optional[FarmTokenBase] = None 36 | 37 | 38 | # Additional properties to return via API 39 | class Farm(FarmBaseInDB): 40 | last_accessed: Optional[datetime] = None 41 | is_authorized: Optional[bool] = None 42 | scope: Optional[str] = None 43 | auth_error: Optional[str] = None 44 | 45 | 46 | # Class that returns farm.json info. 47 | class AllFarmInfo(Farm): 48 | info: Optional[FarmInfo] = None 49 | 50 | 51 | # Additional properties stored in DB 52 | class FarmInDB(FarmBaseInDB): 53 | pass 54 | -------------------------------------------------------------------------------- /backend/app/app/schemas/farm_info.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | # farmOS Server Info to cache 7 | class FarmInfo(BaseModel): 8 | class Config: 9 | allow_population_by_field_name = True 10 | 11 | name: str 12 | url: str 13 | version: str = Field(alias="api_version") 14 | user: Optional[dict] 15 | system_of_measurement: Optional[str] 16 | resources: Optional[dict] 17 | -------------------------------------------------------------------------------- /backend/app/app/schemas/farm_token.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from app.schemas.api_model import APIModel 4 | 5 | 6 | # Farm Token Models 7 | class FarmTokenBase(APIModel): 8 | access_token: Optional[str] = None 9 | expires_in: Optional[str] = None 10 | refresh_token: Optional[str] = None 11 | expires_at: Optional[float] = None 12 | 13 | 14 | class FarmTokenCreate(FarmTokenBase): 15 | farm_id: int 16 | pass 17 | 18 | 19 | class FarmToken(FarmTokenBase): 20 | id: int 21 | 22 | 23 | class FarmTokenUpdate(FarmToken): 24 | pass 25 | 26 | 27 | class FarmAuthorizationParams(APIModel): 28 | grant_type: str 29 | code: str 30 | state: str 31 | client_id: str 32 | client_secret: Optional[str] 33 | redirect_uri: Optional[str] 34 | scope: str 35 | -------------------------------------------------------------------------------- /backend/app/app/schemas/msg.py: -------------------------------------------------------------------------------- 1 | from app.schemas.api_model import APIModel 2 | 3 | 4 | class Msg(APIModel): 5 | msg: str 6 | -------------------------------------------------------------------------------- /backend/app/app/schemas/token.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from app.schemas.api_model import APIModel 4 | 5 | 6 | class Token(APIModel): 7 | access_token: str 8 | token_type: str 9 | 10 | 11 | class TokenData(APIModel): 12 | user_id: int = None 13 | scopes: List[str] = [] 14 | farm_id: List[int] = [] 15 | all_farms: bool = False 16 | 17 | 18 | class FarmAccess(APIModel): 19 | user_id: int = None 20 | scopes: List[str] = [] 21 | farm_id_list: List[int] = [] 22 | all_farms: bool = False 23 | 24 | def can_access_farm(self, farm_id): 25 | if self.all_farms: 26 | return True 27 | else: 28 | return farm_id in self.farm_id_list 29 | -------------------------------------------------------------------------------- /backend/app/app/schemas/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from app.schemas.api_model import APIModel 4 | 5 | 6 | # Shared properties 7 | class UserBase(APIModel): 8 | email: Optional[str] = None 9 | is_active: Optional[bool] = True 10 | is_superuser: Optional[bool] = False 11 | full_name: Optional[str] = None 12 | 13 | 14 | class UserBaseInDB(UserBase): 15 | id: int = None 16 | 17 | 18 | # Properties to receive via API on creation 19 | class UserCreate(UserBaseInDB): 20 | email: str 21 | password: str 22 | 23 | 24 | # Properties to receive via API on update 25 | class UserUpdate(UserBaseInDB): 26 | password: Optional[str] = None 27 | 28 | 29 | # Additional properties to return via API 30 | class User(UserBaseInDB): 31 | pass 32 | 33 | 34 | # Additional properties stored in DB 35 | class UserInDB(UserBaseInDB): 36 | hashed_password: str 37 | -------------------------------------------------------------------------------- /backend/app/app/tests/.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | -------------------------------------------------------------------------------- /backend/app/app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/app/tests/__init__.py -------------------------------------------------------------------------------- /backend/app/app/tests/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/app/tests/api/__init__.py -------------------------------------------------------------------------------- /backend/app/app/tests/api/api_v2/test_farm_authorize.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import parse_qs, urlparse 2 | 3 | import pytest 4 | from fastapi.testclient import TestClient 5 | 6 | from app.core.config import settings 7 | from app.routers.utils.security import _validate_token 8 | from app.schemas.farm_token import FarmAuthorizationParams 9 | from app.tests.utils.utils import get_scope_token_headers, random_lower_string 10 | 11 | 12 | @pytest.fixture 13 | def farm_authorize_headers(client: TestClient): 14 | return get_scope_token_headers(client=client, scopes="farm:authorize") 15 | 16 | 17 | def test_authorize_farm(client: TestClient, test_farm, farm_authorize_headers): 18 | data = FarmAuthorizationParams( 19 | grant_type="authorization_code", 20 | code=random_lower_string(), 21 | state=random_lower_string(), 22 | client_id="farmos_api_client", 23 | scope="user_access", 24 | ) 25 | 26 | r = client.post( 27 | f"{settings.API_V2_PREFIX}/utils/authorize-farm/{test_farm.id}", 28 | headers=farm_authorize_headers, 29 | json=data.dict(), 30 | ) 31 | # This request should return 400, and no token will be created. 32 | # This is because we cannot write an integration test for the OAuth Auth code flow at this time. 33 | assert r.status_code == 400 34 | 35 | """ 36 | # Values to test if we could write an integration test. 37 | token = r.json() 38 | 39 | db_token = crud.farm_token.get_by_id(db, farm_id=test_farm.id) 40 | 41 | assert db_token.farm_id == token.farm_id == test_farm.id 42 | assert db_token.access_token == token.access_token 43 | assert db_token.expires_in == token.expires_in 44 | assert db_token.refresh_token == token.refresh_token 45 | assert db_token.expires_at == token.expires_at 46 | """ 47 | 48 | 49 | def test_farm_authorize_oauth_scope(client: TestClient, test_farm): 50 | r = client.post(f"{settings.API_V2_PREFIX}/utils/authorize-farm/{test_farm.id}") 51 | assert r.status_code == 401 52 | 53 | 54 | def test_get_farm_auth_link(client: TestClient, test_farm, superuser_token_headers): 55 | r = client.post( 56 | f"{settings.API_V2_PREFIX}/utils/farm-auth-link/{test_farm.id}", 57 | headers=superuser_token_headers, 58 | ) 59 | assert 200 <= r.status_code < 300 60 | assert r.json() is not None 61 | 62 | # Check the returned farm authorization link 63 | link = urlparse(r.json()) 64 | assert link.scheme is not None 65 | 66 | # Cannot assert netloc == server_host because it is defined in an environment variable 67 | # that is not set in the backend-tests container 68 | # assert link.netloc == server_host 69 | assert link.netloc != "" 70 | 71 | # Check that the path includes the correct farm ID 72 | assert link.path == f"/authorize-farm/" 73 | 74 | # Check that an api_token query param is included 75 | assert link.query is not None 76 | params = parse_qs(link.query) 77 | assert "farm_id" in params 78 | assert int(params["farm_id"][0]) == test_farm.id 79 | assert "api_token" in params 80 | token = params["api_token"][0] 81 | 82 | # Validate the api_token 83 | token_data = _validate_token(token) 84 | assert token_data is not None 85 | assert token_data.farm_id == [test_farm.id] 86 | assert token_data.scopes is not None 87 | assert len(token_data.scopes) > 0 88 | 89 | # Test that the api_token has access to read /api/v1/farms/{id} 90 | r = client.get( 91 | f"{settings.API_V2_PREFIX}/farms/{test_farm.id}", 92 | headers={"api-token": token}, 93 | ) 94 | assert 200 <= r.status_code < 300 95 | farm_info = r.json() 96 | assert farm_info["id"] == test_farm.id 97 | assert farm_info["farm_name"] == test_farm.farm_name 98 | 99 | # Test that the returned link has access to the utils/authorize-farm endpoint 100 | data = FarmAuthorizationParams( 101 | grant_type="authorization_code", 102 | code=random_lower_string(), 103 | state=random_lower_string(), 104 | client_id="farmos_api_client", 105 | scope="user_access", 106 | ) 107 | 108 | r = client.post( 109 | f"{settings.API_V2_PREFIX}/utils/authorize-farm/{test_farm.id}", 110 | headers={"api-token": token}, 111 | json=data.dict(), 112 | ) 113 | # This request should return 400, but no token will be created. 114 | # This is because we cannot write an integration test for the OAuth Auth code flow at this time. 115 | assert r.status_code == 400 116 | -------------------------------------------------------------------------------- /backend/app/app/tests/api/api_v2/test_login.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from app.core.config import settings 4 | 5 | 6 | def test_get_access_token(client: TestClient): 7 | login_data = { 8 | "username": settings.FIRST_SUPERUSER, 9 | "password": settings.FIRST_SUPERUSER_PASSWORD, 10 | } 11 | r = client.post(f"{settings.API_V2_PREFIX}/login/access-token", data=login_data) 12 | tokens = r.json() 13 | assert r.status_code == 200 14 | assert "access_token" in tokens 15 | assert tokens["access_token"] 16 | 17 | 18 | def test_use_access_token(client: TestClient, superuser_token_headers): 19 | r = client.post( 20 | f"{settings.API_V2_PREFIX}/login/test-token", 21 | headers=superuser_token_headers, 22 | ) 23 | result = r.json() 24 | assert r.status_code == 200 25 | assert "email" in result 26 | -------------------------------------------------------------------------------- /backend/app/app/tests/api/api_v2/test_relay.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from app.core.config import settings 5 | from app.tests.utils.utils import farmOS_testing_server, get_scope_token_headers 6 | 7 | 8 | @pytest.fixture 9 | def farm_logs_headers(client: TestClient): 10 | headers = get_scope_token_headers(client=client, scopes="farm:read") 11 | headers["Content-Type"] = "application/vnd.api+json" 12 | return headers 13 | 14 | 15 | @farmOS_testing_server 16 | def test_relay_crud_activity_logs( 17 | client: TestClient, test_farm, test_log, farm_logs_headers 18 | ): 19 | # Create a log. 20 | response = client.post( 21 | f"{settings.API_V2_PREFIX}/farms/relay/{test_farm.url}/api/log/activity", 22 | headers=farm_logs_headers, 23 | json={"data": test_log}, 24 | ) 25 | # Check response 26 | assert 200 <= response.status_code < 300 27 | content = response.json() 28 | assert "data" in content 29 | 30 | # Check log was created 31 | test_farm_log = content["data"] 32 | assert "id" in test_farm_log 33 | created_log_id = test_farm_log["id"] 34 | test_log["id"] = created_log_id 35 | 36 | # Test a GET with the log ID. 37 | response = client.get( 38 | f"{settings.API_V2_PREFIX}/farms/relay/{test_farm.url}/api/log/activity/{test_log['id']}", 39 | headers=farm_logs_headers, 40 | json=test_log, 41 | ) 42 | 43 | # Check response 44 | assert 200 <= response.status_code < 300 45 | content = response.json() 46 | assert "data" in content 47 | 48 | # Check attributes 49 | created_log = content["data"] 50 | assert created_log["type"] == f"log--{test_log['type']}" 51 | assert created_log["attributes"]["name"] == test_log["attributes"]["name"] 52 | 53 | # Change log attributes 54 | test_log["attributes"]["name"] = "Updated name from farmOS-aggregator" 55 | test_log["attributes"]["status"] = "pending" 56 | test_log = test_log 57 | response = client.patch( 58 | f"{settings.API_V2_PREFIX}/farms/relay/{test_farm.url}/api/log/activity/{test_log['id']}", 59 | headers=farm_logs_headers, 60 | json={"data": test_log}, 61 | ) 62 | 63 | # Check response 64 | assert 200 <= response.status_code < 300 65 | content = response.json() 66 | assert "data" in content 67 | 68 | # Check attributes 69 | updated_log = content["data"] 70 | assert updated_log["attributes"]["name"] == test_log["attributes"]["name"] 71 | 72 | response = client.delete( 73 | f"{settings.API_V2_PREFIX}/farms/relay/{test_farm.url}/api/log/activity/{test_log['id']}", 74 | headers=farm_logs_headers, 75 | ) 76 | # Check response 77 | assert response.status_code == 204 78 | 79 | 80 | @farmOS_testing_server 81 | def test_farm_resources_permission(client: TestClient): 82 | r = client.get(f"{settings.API_V2_PREFIX}/farms/resources/logs/activity") 83 | assert r.status_code == 401 84 | -------------------------------------------------------------------------------- /backend/app/app/tests/api/api_v2/test_resources.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from app.core.config import settings 5 | from app.tests.utils.utils import farmOS_testing_server, get_scope_token_headers 6 | 7 | 8 | @pytest.fixture 9 | def farm_logs_headers(client: TestClient): 10 | return get_scope_token_headers(client=client, scopes="farm:read") 11 | 12 | 13 | @farmOS_testing_server 14 | def test_crud_activity_logs(client: TestClient, test_farm, test_log, farm_logs_headers): 15 | # Create a log. 16 | response = client.post( 17 | f"{settings.API_V2_PREFIX}/farms/resources/log/activity?farm_id={test_farm.id}", 18 | headers=farm_logs_headers, 19 | json=test_log, 20 | ) 21 | # Check response 22 | assert 200 <= response.status_code < 300 23 | content = response.json() 24 | assert str(test_farm.id) in content 25 | assert "data" in content[str(test_farm.id)] 26 | 27 | # Check log was created 28 | test_farm_log = content[str(test_farm.id)]["data"] 29 | assert "id" in test_farm_log 30 | created_log_id = test_farm_log["id"] 31 | test_log["id"] = created_log_id 32 | 33 | # Test a GET with the log ID. 34 | response = client.get( 35 | f"{settings.API_V2_PREFIX}/farms/resources/log/activity/{test_log['id']}?farm_id={test_farm.id}", 36 | headers=farm_logs_headers, 37 | json=test_log, 38 | ) 39 | 40 | # Check response 41 | assert 200 <= response.status_code < 300 42 | content = response.json() 43 | assert str(test_farm.id) in content 44 | assert "data" in content[str(test_farm.id)] 45 | 46 | # Check attributes 47 | created_log = content[str(test_farm.id)]["data"] 48 | assert created_log["type"] == f"log--{test_log['type']}" 49 | assert created_log["attributes"]["name"] == test_log["attributes"]["name"] 50 | 51 | # Change log attributes 52 | test_log["attributes"]["name"] = "Updated name from farmOS-aggregator" 53 | test_log["attributes"]["status"] = "pending" 54 | test_log = test_log 55 | response = client.put( 56 | f"{settings.API_V2_PREFIX}/farms/resources/log/activity?farm_id={test_farm.id}", 57 | headers=farm_logs_headers, 58 | json=test_log, 59 | ) 60 | 61 | # Check response 62 | assert 200 <= response.status_code < 300 63 | content = response.json() 64 | assert str(test_farm.id) in content 65 | assert "data" in content[str(test_farm.id)] 66 | 67 | # Check attributes 68 | updated_log = content[str(test_farm.id)]["data"] 69 | assert updated_log["attributes"]["name"] == test_log["attributes"]["name"] 70 | 71 | response = client.delete( 72 | f"{settings.API_V2_PREFIX}/farms/resources/log/activity?farm_id={test_farm.id}&id={test_log['id']}", 73 | headers=farm_logs_headers, 74 | ) 75 | # Check response 76 | assert 200 <= response.status_code < 300 77 | content = response.json() 78 | assert str(test_farm.id) in content 79 | assert len(content[str(test_farm.id)]) == 1 80 | assert test_log["id"] in content[str(test_farm.id)][0] 81 | 82 | 83 | @farmOS_testing_server 84 | def test_farm_resources_permission(client: TestClient): 85 | r = client.get(f"{settings.API_V2_PREFIX}/farms/resources/logs/activity") 86 | assert r.status_code == 401 87 | -------------------------------------------------------------------------------- /backend/app/app/tests/api/api_v2/test_users.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | from sqlalchemy.orm import Session 3 | 4 | from app import crud 5 | from app.core.config import settings 6 | from app.schemas.user import UserCreate 7 | from app.tests.utils.user import user_authentication_headers 8 | from app.tests.utils.utils import random_lower_string 9 | 10 | 11 | def test_get_users_superuser_me(client: TestClient, superuser_token_headers): 12 | r = client.get( 13 | f"{settings.API_V2_PREFIX}/users/me", headers=superuser_token_headers 14 | ) 15 | current_user = r.json() 16 | assert current_user 17 | assert current_user["is_active"] is True 18 | assert current_user["is_superuser"] 19 | assert current_user["email"] == settings.FIRST_SUPERUSER 20 | 21 | 22 | def test_create_user_new_email( 23 | client: TestClient, db: Session, superuser_token_headers 24 | ): 25 | username = random_lower_string() 26 | password = random_lower_string() 27 | data = {"email": username, "password": password} 28 | r = client.post( 29 | f"{settings.API_V2_PREFIX}/users/", 30 | headers=superuser_token_headers, 31 | json=data, 32 | ) 33 | assert 200 <= r.status_code < 300 34 | created_user = r.json() 35 | user = crud.user.get_by_email(db, email=username) 36 | assert user.email == created_user["email"] 37 | 38 | 39 | def test_get_existing_user(client: TestClient, db: Session, superuser_token_headers): 40 | username = random_lower_string() 41 | password = random_lower_string() 42 | user_in = UserCreate(email=username, password=password) 43 | user = crud.user.create(db, user_in=user_in) 44 | user_id = user.id 45 | r = client.get( 46 | f"{settings.API_V2_PREFIX}/users/{user_id}", 47 | headers=superuser_token_headers, 48 | ) 49 | assert 200 <= r.status_code < 300 50 | api_user = r.json() 51 | user = crud.user.get_by_email(db, email=username) 52 | assert user.email == api_user["email"] 53 | 54 | 55 | def test_create_user_existing_username( 56 | client: TestClient, db: Session, superuser_token_headers 57 | ): 58 | username = random_lower_string() 59 | # username = email 60 | password = random_lower_string() 61 | user_in = UserCreate(email=username, password=password) 62 | user = crud.user.create(db, user_in=user_in) 63 | data = {"email": username, "password": password} 64 | r = client.post( 65 | f"{settings.API_V2_PREFIX}/users/", 66 | headers=superuser_token_headers, 67 | json=data, 68 | ) 69 | created_user = r.json() 70 | assert r.status_code == 400 71 | assert "_id" not in created_user 72 | 73 | 74 | def test_create_user_by_normal_user(client: TestClient, db: Session): 75 | username = random_lower_string() 76 | password = random_lower_string() 77 | user_in = UserCreate(email=username, password=password) 78 | user = crud.user.create(db, user_in=user_in) 79 | user_token_headers = user_authentication_headers(client, username, password) 80 | data = {"email": username, "password": password} 81 | r = client.post( 82 | f"{settings.API_V2_PREFIX}/users/", headers=user_token_headers, json=data 83 | ) 84 | assert r.status_code == 400 85 | 86 | 87 | def test_retrieve_users(client: TestClient, db: Session, superuser_token_headers): 88 | username = random_lower_string() 89 | password = random_lower_string() 90 | user_in = UserCreate(email=username, password=password) 91 | user = crud.user.create(db, user_in=user_in) 92 | 93 | username2 = random_lower_string() 94 | password2 = random_lower_string() 95 | user_in2 = UserCreate(email=username2, password=password2) 96 | user2 = crud.user.create(db, user_in=user_in2) 97 | 98 | r = client.get(f"{settings.API_V2_PREFIX}/users/", headers=superuser_token_headers) 99 | all_users = r.json() 100 | 101 | assert len(all_users) > 1 102 | for user in all_users: 103 | assert "email" in user 104 | -------------------------------------------------------------------------------- /backend/app/app/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | import pytest 4 | from fastapi.testclient import TestClient 5 | 6 | from app.db.session import SessionLocal 7 | from app.main import app 8 | from app.tests.utils.farm import delete_test_farm_instance, get_test_farm_instance 9 | from app.tests.utils.utils import ( 10 | get_all_scopes_token_headers, 11 | get_superuser_token_headers, 12 | ) 13 | 14 | 15 | @pytest.fixture(scope="session") 16 | def db() -> Generator: 17 | yield SessionLocal() 18 | 19 | 20 | @pytest.fixture(scope="module") 21 | def client() -> Generator: 22 | with TestClient(app) as c: 23 | yield c 24 | 25 | 26 | @pytest.fixture(scope="module") 27 | def superuser_token_headers(client: TestClient): 28 | return get_superuser_token_headers(client=client) 29 | 30 | 31 | @pytest.fixture(scope="module") 32 | def all_scopes_token_headers(): 33 | return get_all_scopes_token_headers(client=client) 34 | 35 | 36 | @pytest.fixture(scope="package") 37 | def test_farm(): 38 | db = SessionLocal() 39 | farm = get_test_farm_instance(db) 40 | yield farm 41 | 42 | # Delete the test farm from the DB for cleanup. 43 | delete_test_farm_instance(db, farm.id) 44 | 45 | 46 | @pytest.fixture(scope="module") 47 | def test_log(): 48 | data = { 49 | "type": "activity", 50 | "attributes": { 51 | "name": "Test Log from farmOS-aggregator", 52 | "status": "done", 53 | }, 54 | } 55 | 56 | return data 57 | -------------------------------------------------------------------------------- /backend/app/app/tests/crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/app/tests/crud/__init__.py -------------------------------------------------------------------------------- /backend/app/app/tests/crud/test_api_token.py: -------------------------------------------------------------------------------- 1 | from app.core.jwt import create_farm_api_token 2 | from app.routers.utils.security import _validate_token 3 | 4 | 5 | def test_create_api_token(): 6 | farm_id_list = [1, 2, 3] 7 | scopes = ["scope1", "scope2"] 8 | 9 | token = create_farm_api_token(farm_id_list, scopes) 10 | assert token is not None 11 | 12 | token_data = _validate_token(token) 13 | assert token_data is not None 14 | 15 | # api_tokens are not associated with a user. 16 | assert token_data.user_id is None 17 | 18 | # Check that farm_id and scopes match. 19 | assert token_data.farm_id == farm_id_list 20 | assert token_data.scopes == scopes 21 | -------------------------------------------------------------------------------- /backend/app/app/tests/crud/test_farm_token.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | 3 | from app.crud import farm_token 4 | from app.schemas.farm_token import FarmTokenCreate, FarmTokenUpdate 5 | from app.tests.utils.utils import random_lower_string 6 | 7 | 8 | def test_create_farm_token(db: Session, test_farm): 9 | token = FarmTokenCreate( 10 | farm_id=test_farm.id, 11 | access_token=random_lower_string(), 12 | expires_in=random_lower_string(), 13 | refresh_token=random_lower_string(), 14 | expires_at=1581363344.0651991, 15 | ) 16 | 17 | # Check for existing token 18 | old_token = farm_token.get_farm_token(db, test_farm.id) 19 | if old_token is None: 20 | farm_token.create_farm_token(db, token=token) 21 | else: 22 | farm_token.update_farm_token(db, token=old_token, token_in=token) 23 | 24 | db_token = farm_token.get_farm_token(db, farm_id=test_farm.id) 25 | 26 | assert db_token.farm_id == token.farm_id == test_farm.id 27 | assert db_token.access_token == token.access_token 28 | assert db_token.expires_in == token.expires_in 29 | assert db_token.refresh_token == token.refresh_token 30 | assert float(db_token.expires_at) == token.expires_at 31 | 32 | 33 | def test_update_farm_token(db: Session, test_farm): 34 | db_token = farm_token.get_farm_token(db, farm_id=test_farm.id) 35 | assert db_token is not None 36 | assert db_token.farm_id == test_farm.id 37 | 38 | token_changes = FarmTokenUpdate( 39 | id=db_token.id, 40 | farm_id=db_token.farm_id, 41 | access_token=None, 42 | expires_in=None, 43 | refresh_token=None, 44 | expires_at=None, 45 | ) 46 | new_token = farm_token.update_farm_token(db, token=db_token, token_in=token_changes) 47 | assert new_token.id == db_token.id 48 | assert new_token.farm_id == db_token.farm_id == test_farm.id 49 | 50 | # Check that the farm_token was reset. 51 | assert new_token.access_token is None 52 | assert new_token.expires_in is None 53 | assert new_token.refresh_token is None 54 | assert new_token.expires_at is None 55 | -------------------------------------------------------------------------------- /backend/app/app/tests/crud/test_user.py: -------------------------------------------------------------------------------- 1 | from fastapi.encoders import jsonable_encoder 2 | from sqlalchemy.orm import Session 3 | 4 | from app import crud 5 | from app.schemas.user import UserCreate 6 | from app.tests.utils.utils import random_lower_string 7 | 8 | 9 | def test_create_user(db: Session): 10 | email = random_lower_string() 11 | password = random_lower_string() 12 | user_in = UserCreate(email=email, password=password) 13 | user = crud.user.create(db, user_in=user_in) 14 | assert user.email == email 15 | assert hasattr(user, "hashed_password") 16 | 17 | 18 | def test_authenticate_user(db: Session): 19 | email = random_lower_string() 20 | password = random_lower_string() 21 | user_in = UserCreate(email=email, password=password) 22 | user = crud.user.create(db, user_in=user_in) 23 | authenticated_user = crud.user.authenticate(db, email=email, password=password) 24 | assert authenticated_user 25 | assert user.email == authenticated_user.email 26 | 27 | 28 | def test_not_authenticate_user(db: Session): 29 | email = random_lower_string() 30 | password = random_lower_string() 31 | user = crud.user.authenticate(db, email=email, password=password) 32 | assert user is None 33 | 34 | 35 | def test_check_if_user_is_active(db: Session): 36 | email = random_lower_string() 37 | password = random_lower_string() 38 | user_in = UserCreate(email=email, password=password) 39 | user = crud.user.create(db, user_in=user_in) 40 | is_active = crud.user.is_active(user) 41 | assert is_active is True 42 | 43 | 44 | def test_check_if_user_is_active_inactive(db: Session): 45 | email = random_lower_string() 46 | password = random_lower_string() 47 | user_in = UserCreate(email=email, password=password, disabled=True) 48 | print(user_in) 49 | user = crud.user.create(db, user_in=user_in) 50 | print(user) 51 | is_active = crud.user.is_active(user) 52 | print(is_active) 53 | assert is_active 54 | 55 | 56 | def test_check_if_user_is_superuser(db: Session): 57 | email = random_lower_string() 58 | password = random_lower_string() 59 | user_in = UserCreate(email=email, password=password, is_superuser=True) 60 | user = crud.user.create(db, user_in=user_in) 61 | is_superuser = crud.user.is_superuser(user) 62 | assert is_superuser is True 63 | 64 | 65 | def test_check_if_user_is_superuser_normal_user(db: Session): 66 | username = random_lower_string() 67 | password = random_lower_string() 68 | user_in = UserCreate(email=username, password=password) 69 | user = crud.user.create(db, user_in=user_in) 70 | is_superuser = crud.user.is_superuser(user) 71 | assert is_superuser is False 72 | 73 | 74 | def test_get_user(db: Session): 75 | password = random_lower_string() 76 | username = random_lower_string() 77 | user_in = UserCreate(email=username, password=password, is_superuser=True) 78 | user = crud.user.create(db, user_in=user_in) 79 | user_2 = crud.user.get(db, user_id=user.id) 80 | assert user.email == user_2.email 81 | assert jsonable_encoder(user) == jsonable_encoder(user_2) 82 | -------------------------------------------------------------------------------- /backend/app/app/tests/utils/.gitignore: -------------------------------------------------------------------------------- 1 | test_farm_credentials.py 2 | -------------------------------------------------------------------------------- /backend/app/app/tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/backend/app/app/tests/utils/__init__.py -------------------------------------------------------------------------------- /backend/app/app/tests/utils/farm.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from farmOS import farmOS 4 | from sqlalchemy.orm import Session 5 | 6 | from app import crud 7 | from app.core.config import settings 8 | from app.schemas.farm import FarmCreate 9 | 10 | 11 | def get_test_farm_instance(db: Session): 12 | """Populates database with a farmOS testing farm 13 | This creates a farm object in the database with valid credentials 14 | for the farmOS testing instance. 15 | 16 | Returns: the test_farm object 17 | """ 18 | # Allow requests over HTTP. 19 | os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" 20 | 21 | # Get oauth tokens from farmOS Client 22 | farm_client = farmOS( 23 | hostname=settings.TEST_FARM_URL, 24 | client_id="farm", 25 | scope="farm_manager", 26 | ) 27 | token = farm_client.authorize( 28 | username=settings.TEST_FARM_USERNAME, password=settings.TEST_FARM_PASSWORD 29 | ) 30 | assert token is not None 31 | assert "access_token" in token and "refresh_token" in token 32 | 33 | # Remove existing farm from DB if it has the testing URL 34 | old_farm = crud.farm.get_by_url(db, farm_url=settings.TEST_FARM_URL.host) 35 | if old_farm is not None: 36 | crud.farm.delete(db, farm_id=old_farm.id) 37 | 38 | # Create test farm 39 | if settings.TEST_FARM_URL is not None: 40 | farm_in = FarmCreate( 41 | farm_name=settings.TEST_FARM_NAME, 42 | url=settings.TEST_FARM_URL.host, 43 | scope="farm_manager", 44 | active=True, 45 | token=token, 46 | ) 47 | else: 48 | farm_in = FarmCreate( 49 | farm_name=settings.TEST_FARM_NAME, 50 | url="http://localhost", 51 | scope="farm_manager", 52 | active=True, 53 | ) 54 | 55 | test_farm = crud.farm.create(db, farm_in=farm_in) 56 | return test_farm 57 | 58 | 59 | def delete_test_farm_instance(db: Session, farm_id): 60 | """Removes the testing farm from the database""" 61 | crud.farm.delete(db, farm_id=farm_id) 62 | -------------------------------------------------------------------------------- /backend/app/app/tests/utils/user.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | from sqlalchemy.orm import Session 3 | 4 | from app import crud 5 | from app.core.config import settings 6 | from app.schemas.user import UserCreate 7 | from app.tests.utils.utils import random_lower_string 8 | 9 | 10 | def user_authentication_headers(client: TestClient, email, password): 11 | data = {"username": email, "password": password} 12 | 13 | r = client.post(f"{settings.API_V2_PREFIX}/login/access-token", data=data) 14 | response = r.json() 15 | auth_token = response["access_token"] 16 | headers = {"Authorization": f"Bearer {auth_token}"} 17 | return headers 18 | 19 | 20 | def create_random_user(db: Session): 21 | email = random_lower_string() 22 | password = random_lower_string() 23 | user_in = UserCreate(username=email, email=email, password=password) 24 | user = crud.user.create(db=db, user_in=user_in) 25 | return user 26 | -------------------------------------------------------------------------------- /backend/app/app/tests/utils/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | import pytest 5 | from fastapi.testclient import TestClient 6 | 7 | from app.core.config import settings 8 | from app.schemas.api_key import ApiKeyCreate 9 | 10 | farmOS_testing_server = pytest.mark.skipif( 11 | settings.TEST_FARM_URL is None, 12 | reason="farmOS Testing Server not configured. Skipping farmOS test server integration tests.", 13 | ) 14 | 15 | 16 | def random_lower_string(): 17 | return "".join(random.choices(string.ascii_lowercase, k=32)) 18 | 19 | 20 | def get_superuser_token_headers(client: TestClient): 21 | login_data = { 22 | "username": settings.FIRST_SUPERUSER, 23 | "password": settings.FIRST_SUPERUSER_PASSWORD, 24 | } 25 | r = client.post(f"{settings.API_V2_PREFIX}/login/access-token", data=login_data) 26 | tokens = r.json() 27 | a_token = tokens["access_token"] 28 | headers = {"Authorization": f"Bearer {a_token}"} 29 | # superuser_token_headers = headers 30 | return headers 31 | 32 | 33 | def get_all_scopes_token_headers(client: TestClient): 34 | return _create_headers_with_scopes( 35 | client=client, 36 | scopes="farm:create farm:read farm:update farm:delete farm:authorize farm.info", 37 | ) 38 | 39 | 40 | def get_scope_token_headers(client: TestClient, scopes): 41 | return _create_headers_with_scopes(client, scopes) 42 | 43 | 44 | def _create_headers_with_scopes(client: TestClient, scopes): 45 | login_data = { 46 | "username": settings.FIRST_SUPERUSER, 47 | "password": settings.FIRST_SUPERUSER_PASSWORD, 48 | "scope": scopes, 49 | } 50 | r = client.post(f"{settings.API_V2_PREFIX}/login/access-token", data=login_data) 51 | tokens = r.json() 52 | a_token = tokens["access_token"] 53 | headers = {"Authorization": f"Bearer {a_token}"} 54 | return headers 55 | 56 | 57 | def get_api_key_headers(client: TestClient, api_key_params: ApiKeyCreate): 58 | r = client.post( 59 | f"{settings.API_V2_PREFIX}/api-keys/", 60 | headers=get_superuser_token_headers(client=client), 61 | data=api_key_params.json(), 62 | ) 63 | api_key = r.json() 64 | key = api_key["key"] 65 | headers = {"api-key": f"{key}"} 66 | return headers 67 | -------------------------------------------------------------------------------- /backend/app/app/tests_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 4 | 5 | from app.db.session import SessionLocal 6 | 7 | logging.basicConfig(level=logging.INFO) 8 | logger = logging.getLogger(__name__) 9 | 10 | max_tries = 60 * 5 # 5 minutes 11 | wait_seconds = 1 12 | 13 | 14 | @retry( 15 | stop=stop_after_attempt(max_tries), 16 | wait=wait_fixed(wait_seconds), 17 | before=before_log(logger, logging.INFO), 18 | after=after_log(logger, logging.WARN), 19 | ) 20 | def init(): 21 | try: 22 | db = SessionLocal() 23 | # Try to create session to check if DB is awake 24 | db.execute("SELECT 1") 25 | # Wait for API to be awake, run one simple tests to authenticate 26 | except Exception as e: 27 | logger.error(e) 28 | raise e 29 | 30 | 31 | def main(): 32 | logger.info("Initializing service") 33 | init() 34 | logger.info("Service finished initializing") 35 | 36 | 37 | if __name__ == "__main__": 38 | main() 39 | -------------------------------------------------------------------------------- /backend/app/prestart.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Let the DB start 4 | python /app/app/backend_pre_start.py 5 | 6 | # Run migrations 7 | alembic upgrade head 8 | 9 | # Create initial data in DB 10 | python /app/app/initial_data.py 11 | -------------------------------------------------------------------------------- /backend/app/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "app" 3 | version = "1.0.0" 4 | description = "farmOS Aggregator Backend" 5 | authors = ["Paul Weidner "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.9" 9 | fastapi = "^0.70" 10 | pyjwt = "^1.7.1" 11 | python-multipart = "^0.0.5" 12 | email-validator = "^1.0.5" 13 | requests = "^2.23.0" 14 | passlib = {extras = ["bcrypt"], version = "^1.7.2"} 15 | tenacity = "^6.1.0" 16 | pydantic = "^1.8" 17 | emails = "^0.6" 18 | raven = "^6.10.0" 19 | gunicorn = "^20.0.4" 20 | jinja2 = "^3" 21 | psycopg2-binary = "^2.8.5" 22 | alembic = "^1.4.2" 23 | sqlalchemy = "^1.3.16" 24 | farmOS = "^1.0.0-beta.3" 25 | uvicorn = "^0.17.4" 26 | 27 | [tool.poetry.group.dev.dependencies] 28 | mypy = "^0.770" 29 | black = "^22" 30 | isort = "^5.10" 31 | autoflake = "^1.3.1" 32 | flake8 = "^3.7.9" 33 | pytest = "^7" 34 | vulture = "^1.4" 35 | safety = "^1.10.3" 36 | 37 | [build-system] 38 | requires = ["poetry>=0.12"] 39 | build-backend = "poetry.masonry.api" -------------------------------------------------------------------------------- /backend/app/scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | 5 | autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place app --exclude=__init__.py 6 | isort --multi-line=3 --trailing-comma --force-grid-wrap=0 --combine-as --line-width 88 --recursive --apply app 7 | black app 8 | vulture app --min-confidence 70 9 | -------------------------------------------------------------------------------- /backend/app/single-test-start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -e 3 | 4 | python /app/app/tests_pre_start.py 5 | 6 | pytest $* -------------------------------------------------------------------------------- /backend/app/tests-start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -e 3 | 4 | python /app/app/tests_pre_start.py 5 | 6 | pytest "$@" /app/app/tests/ 7 | -------------------------------------------------------------------------------- /docker-compose.deploy-template.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend: 4 | image: farmos/aggregator:backend 5 | depends_on: 6 | - db 7 | environment: 8 | - SERVER_NAME=${DOMAIN} 9 | - SERVER_HOST=https://${DOMAIN} 10 | # Configuration for postgres DB. 11 | - POSTGRES_SERVER 12 | - POSTGRES_USER 13 | - POSTGRES_PASSWORD 14 | - POSTGRES_DB 15 | # Configuration for backend app. 16 | - BACKEND_CORS_ORIGINS 17 | - SECRET_KEY 18 | - FIRST_SUPERUSER 19 | - FIRST_SUPERUSER_PASSWORD 20 | - SMTP_TLS 21 | - SMTP_PORT 22 | - SMTP_HOST 23 | - SMTP_USER 24 | - SMTP_PASSWORD 25 | - EMAILS_FROM_EMAIL 26 | - USERS_OPEN_REGISTRATION 27 | # Configuration for SQLALchemy. 28 | - SQLALCHEMY_POOL_SIZE 29 | - SQLALCHEMY_MAX_OVERFLOW 30 | # Configure aggregator admin errors 31 | - AGGREGATOR_ALERT_NEW_FARMS 32 | - AGGREGATOR_ALERT_ALL_ERRORS 33 | - AGGREGATOR_ALERT_PING_FARMS_ERRORS 34 | # General aggregator configuration. 35 | - AGGREGATOR_NAME 36 | - FARM_ACTIVE_AFTER_REGISTRATION 37 | - AGGREGATOR_OAUTH_INSECURE_TRANSPORT 38 | - AGGREGATOR_OPEN_FARM_REGISTRATION 39 | - AGGREGATOR_INVITE_FARM_REGISTRATION 40 | - AGGREGATOR_OAUTH_CLIENT_ID 41 | - AGGREGATOR_OAUTH_CLIENT_SECRET 42 | - AGGREGATOR_OAUTH_SCOPES 43 | - AGGREGATOR_OAUTH_DEFAULT_SCOPES 44 | - AGGREGATOR_OAUTH_REQUIRED_SCOPES 45 | restart: always 46 | frontend: 47 | image: farmos/aggregator:frontend 48 | environment: 49 | - SERVER_HOST=https://${DOMAIN} 50 | # General aggregator configuration. 51 | - AGGREGATOR_NAME 52 | - FARM_ACTIVE_AFTER_REGISTRATION 53 | - AGGREGATOR_OAUTH_INSECURE_TRANSPORT 54 | - AGGREGATOR_OPEN_FARM_REGISTRATION 55 | - AGGREGATOR_INVITE_FARM_REGISTRATION 56 | - AGGREGATOR_OAUTH_CLIENT_ID 57 | - AGGREGATOR_OAUTH_CLIENT_SECRET 58 | - AGGREGATOR_OAUTH_SCOPES 59 | - AGGREGATOR_OAUTH_DEFAULT_SCOPES 60 | - AGGREGATOR_OAUTH_REQUIRED_SCOPES 61 | restart: always 62 | db: 63 | image: postgres:11 64 | volumes: 65 | - app-db-data:/var/lib/postgresql/data/pgdata 66 | environment: 67 | - PGDATA=/var/lib/postgresql/data/pgdata 68 | - POSTGRES_SERVER 69 | - POSTGRES_USER 70 | - POSTGRES_PASSWORD 71 | - POSTGRES_DB 72 | restart: always 73 | proxy: 74 | image: nginx:stable-alpine 75 | depends_on: 76 | - backend 77 | - frontend 78 | ports: 79 | - '80:80' 80 | - '443:443' 81 | volumes: 82 | - './nginx.template:/etc/nginx/conf.d/default.conf:ro' 83 | - '/etc/letsencrypt:/etc/letsencrypt:ro' 84 | - '/var/www/letsencrypt:/var/www/letsencrypt:ro' 85 | restart: always 86 | 87 | volumes: 88 | app-db-data: -------------------------------------------------------------------------------- /docker-compose.deploy.build.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend: 4 | build: 5 | context: ./backend 6 | frontend: 7 | build: 8 | context: ./frontend 9 | -------------------------------------------------------------------------------- /docker-compose.deploy.images.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend: 4 | image: '${DOCKER_IMAGE_BACKEND}:${TAG-latest}' 5 | frontend: 6 | image: '${DOCKER_IMAGE_FRONTEND}:${TAG-latest}' 7 | -------------------------------------------------------------------------------- /docker-compose.deploy.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend: 4 | environment: 5 | - SERVER_NAME=${DOMAIN} 6 | - SERVER_HOST=https://${DOMAIN} 7 | frontend: 8 | environment: 9 | - SERVER_HOST=https://${DOMAIN} 10 | db: 11 | volumes: 12 | - app-db-data:/var/lib/postgresql/data/pgdata 13 | proxy: 14 | ports: 15 | - '80:80' 16 | - '443:443' 17 | volumes: 18 | - './nginx.template:/etc/nginx/conf.d/default.conf:ro' 19 | - '/etc/letsencrypt:/etc/letsencrypt:ro' 20 | - '/var/www/letsencrypt:/var/www/letsencrypt:ro' 21 | restart: always 22 | 23 | volumes: 24 | app-db-data: -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | backend: 4 | build: 5 | context: ./backend 6 | args: 7 | INSTALL_DEV: ${INSTALL_DEV-true} 8 | command: /start-reload.sh 9 | environment: 10 | - SERVER_NAME=${DOMAIN} 11 | - SERVER_HOST=http://${DOMAIN} 12 | networks: 13 | default: 14 | aliases: 15 | - ${DOMAIN} 16 | ports: 17 | - '8888:8888' 18 | volumes: 19 | - ./backend/app:/app 20 | frontend: 21 | build: 22 | context: ./frontend 23 | environment: 24 | - SERVER_HOST=http://${DOMAIN} 25 | ports: 26 | - '5555:5555' 27 | db: 28 | ports: 29 | - '5432:5432' 30 | proxy: 31 | ports: 32 | - '80:80' 33 | volumes: 34 | - './nginx.template:/etc/nginx/conf.d/default.conf:ro' 35 | -------------------------------------------------------------------------------- /docker-compose.shared.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | frontend: 4 | environment: 5 | # General aggregator configuration. 6 | - AGGREGATOR_NAME 7 | - FARM_ACTIVE_AFTER_REGISTRATION 8 | - AGGREGATOR_OAUTH_INSECURE_TRANSPORT 9 | - AGGREGATOR_OPEN_FARM_REGISTRATION 10 | - AGGREGATOR_INVITE_FARM_REGISTRATION 11 | - AGGREGATOR_OAUTH_CLIENT_ID 12 | - AGGREGATOR_OAUTH_CLIENT_SECRET 13 | - AGGREGATOR_OAUTH_SCOPES 14 | - AGGREGATOR_OAUTH_DEFAULT_SCOPES 15 | - AGGREGATOR_OAUTH_REQUIRED_SCOPES 16 | backend: 17 | depends_on: 18 | - db 19 | environment: 20 | # Configuration for postgres DB. 21 | - POSTGRES_SERVER 22 | - POSTGRES_USER 23 | - POSTGRES_PASSWORD 24 | - POSTGRES_DB 25 | # Configuration for backend app. 26 | - BACKEND_CORS_ORIGINS 27 | - SECRET_KEY 28 | - FIRST_SUPERUSER 29 | - FIRST_SUPERUSER_PASSWORD 30 | - SMTP_TLS 31 | - SMTP_PORT 32 | - SMTP_HOST 33 | - SMTP_USER 34 | - SMTP_PASSWORD 35 | - EMAILS_FROM_EMAIL 36 | - USERS_OPEN_REGISTRATION 37 | # Configuration for SQLALchemy. 38 | - SQLALCHEMY_POOL_SIZE 39 | - SQLALCHEMY_MAX_OVERFLOW 40 | # Configure aggregator admin errors 41 | - AGGREGATOR_ALERT_NEW_FARMS 42 | - AGGREGATOR_ALERT_ALL_ERRORS 43 | - AGGREGATOR_ALERT_PING_FARMS_ERRORS 44 | # General aggregator configuration. 45 | - AGGREGATOR_NAME 46 | - FARM_ACTIVE_AFTER_REGISTRATION 47 | - AGGREGATOR_OAUTH_INSECURE_TRANSPORT 48 | - AGGREGATOR_OPEN_FARM_REGISTRATION 49 | - AGGREGATOR_INVITE_FARM_REGISTRATION 50 | - AGGREGATOR_OAUTH_CLIENT_ID 51 | - AGGREGATOR_OAUTH_CLIENT_SECRET 52 | - AGGREGATOR_OAUTH_SCOPES 53 | - AGGREGATOR_OAUTH_DEFAULT_SCOPES 54 | - AGGREGATOR_OAUTH_REQUIRED_SCOPES 55 | db: 56 | image: postgres:11 57 | environment: 58 | - PGDATA=/var/lib/postgresql/data/pgdata 59 | - POSTGRES_SERVER 60 | - POSTGRES_USER 61 | - POSTGRES_PASSWORD 62 | - POSTGRES_DB 63 | proxy: 64 | image: nginx:stable-alpine 65 | depends_on: 66 | - backend 67 | - frontend 68 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | farmos_www: 4 | depends_on: 5 | - db 6 | image: farmos/farmos:2.x-dev 7 | # volumes: 8 | # - './www:/opt/drupal' 9 | ports: 10 | - '8080:80' 11 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | #farmOS Aggregator API Documentation 2 | 3 | Thanks to FastAPI, an OpenAPI JSON Schema is provided at [http://localhost/api/v1/openapi.json](http://localhost/api/v1/openapi/json) 4 | This schema is used in the interactive API documentation (provided by Swagger UI) at [http://localhost/docs](http://localhost/docs). 5 | **Note** that to make requests via the Swagger UI, you must login with the `authorize` button in the top right. 6 | 7 | Images of the OpenAPI documentation [here](#openapi-api-docs) 8 | 9 | ## Authentication 10 | 11 | The farmOS-Aggregator uses OAuth2 with `Bearer` tokens for authenticating with the REST API. With a registered user 12 | account, the OAuth2 Password Flow may be used to authenticate. Request a token at the 13 | [http://localhost/api/v1/login/access-token](http://localhost/api/v1/login/access-token) endpoint. Make sure to include 14 | the OAuth `scope` you are using to authorize with the API. Some endpoints require one or many authorized `scopes`. See 15 | the interactive API docs for further documentation on required scopes. 16 | 17 | Supported scopes: 18 | - `farm:create` 19 | - `farm:read` 20 | - `farm:update` 21 | - `farm:delete` 22 | - `farm:authorize` 23 | - `farm.info` 24 | - `farm.logs` 25 | - `farm.assets` 26 | - `farm.terms` 27 | - `farm.areas` 28 | 29 | ## Endpoints 30 | 31 | farmOS-aggregator provides the following endpoints: 32 | 33 | ### Connecting farmOS instances 34 | 35 | - `/api/v1/farms/` 36 | 37 | The root `farms` endpoint is used for managing farmOS profiles. It supports HTTP `GET`, `POST`, `PUT` and `DELETE` 38 | requests. 39 | 40 | ### Interacting with farmOS instances 41 | 42 | - `/api/v1/farms/info` 43 | - `/api/v1/farms/logs` 44 | - `/api/v1/farms/assets` 45 | - `/api/v1/farms/terms` 46 | - `/api/v1/farms/areas` 47 | 48 | All of the endpoints used for interacting with farmOS instances are name-spaced from the `/api/v1/farms` endpoint. All 49 | endpoints support HTTP `GET`, `POST`, `PUT` and `DELETE` requests, with the exception of `/farms/info` which only 50 | supports `GET` requests. By default, all endpoints will attempt to run queries on **all** farmOS instances saved in the 51 | aggregator. Passing a list of `farm_id` or a single `farm_url` as a query parameter, with any request, will only 52 | perform actions on the listed farms. For example, a `GET` to `/api/v1/farms/info?farm_id=1&farm_id=5` will only return 53 | the farmOS instance info from farms of ID `1` and `5`. The `farm_id` of farmOS instances can be retrieved with a `GET` 54 | to `/api/v1/farms`. 55 | 56 | ### Managing farmOS-aggregator users 57 | 58 | - `api/v1/users` 59 | 60 | farmOS-aggregator users can be added from the GUI or via the API at this endpoint. 61 | 62 | ### Utility Endpoints 63 | 64 | - `api/v1/utils/` 65 | 66 | Endpoints under the `/utils` namespace provide various functionalities, primarily for the frontend vue app. 67 | Documentation of each endpoint is provided in the interactive UI. Notable util endpoints: 68 | - `utils/ping-farms/` attempts to connect to all `active` farm profiles saved in the Aggregator. Because regular 69 | communication with farmOS servers is required to prevent OAuth Tokens from expiring, this endpoint is useful for 70 | setting up CRON jobs that auto-ping farmOS servers. A unix CRON job could be schedule to `POST` to this endpoint 71 | every 12 hours. 72 | - `utils/farm-registration-link` can be used to generate a link with an embedded `api_token` for registering farm 73 | profiles. This endpoint is used to generate the link in the admin UI, but may be useful in other 3rd party integrations 74 | for allowing users to join the Aggregator. When the Aggregator is not configured with Open Farm Registration, farm 75 | profiles can only be added by the Aggregator admin or someone with a valid registration link. 76 | - `utils/farm-auth-link` can be used to generate a link to re-authorize a farm profile. Similar to the 77 | `farm-registration-link`, this endpoint generates a link with an embedded `farm_id` and `api_token`. 78 | 79 | ### OpenAPI API Docs 80 | 81 | Screenshots of autogenerated docs. 82 | 83 | ![Farms APIS](../img/api/farms_api.png) 84 | 85 | CRUD endpoints for managing farmOS server profiles. 86 | 87 | ![farmOS Record APIs](../img/api/farmos_records_api.png) 88 | 89 | All farmOS related APIs for interacting with records are listed under the `farm` tag prefix. 90 | 91 | ![Farm Create](../img/api/farm_create.png) 92 | 93 | The OpenAPI docs are interactive, allowing to perform requests from the "Try it out" button. This displays the fields 94 | required to perform requests at each endpoint. 95 | 96 | ![Farm Info](../img/api/farm_info.png) 97 | 98 | `GET` farm info. 99 | 100 | ![Farm Log Create](../img/api/farm_log_create.png) 101 | 102 | `POST` a sample log to farm ID #3. -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | # Variables for the default hosts 2 | SERVER_HOST=http://localhost 3 | 4 | # General Aggregator Settings 5 | AGGREGATOR_NAME=farmOS-aggregator 6 | 7 | # Add aggregator config vars for local development 8 | AGGREGATOR_OPEN_FARM_REGISTRATION=true 9 | AGGREGATOR_INVITE_FARM_REGISTRATION=true 10 | 11 | # Aggregator OAuth Config 12 | AGGREGATOR_OAUTH_CLIENT_ID=farm 13 | AGGREGATOR_OAUTH_CLIENT_SECRET= 14 | AGGREGATOR_OAUTH_SCOPES=[{"name":"farm_manager","label":"farmOS Manager","description":"Manager level access."}] 15 | AGGREGATOR_OAUTH_DEFAULT_SCOPES=["farm_manager"] 16 | AGGREGATOR_OAUTH_REQUIRED_SCOPES=[] 17 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | public/env.js 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw* 23 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 0, "build-stage", based on Node.js, to build and compile the frontend 2 | FROM tiangolo/node-frontend:10 as build-stage 3 | 4 | WORKDIR /app 5 | 6 | COPY package*.json /app/ 7 | 8 | RUN npm install 9 | 10 | COPY ./ /app/ 11 | 12 | # Comment out the next line to disable tests 13 | # RUN npm run test:unit 14 | 15 | RUN npm run build 16 | 17 | 18 | # Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx 19 | FROM nginx:1.15 20 | 21 | # Copy env-template.js for configuring the Aggregator with env variables. 22 | COPY --from=build-stage /app/env-template.js / 23 | 24 | # Copy entrypoint script for building env template. 25 | COPY --from=build-stage /app/build-env.sh / 26 | 27 | COPY --from=build-stage /app/dist/ /usr/share/nginx/html 28 | 29 | COPY --from=build-stage /nginx.conf /etc/nginx/conf.d/default.conf 30 | COPY ./nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf 31 | 32 | ENTRYPOINT ["/build-env.sh"] 33 | 34 | CMD ["nginx", "-g", "daemon off;"] 35 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Run your tests 19 | ``` 20 | npm run test 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | npm run lint 26 | ``` 27 | 28 | ### Run your unit tests 29 | ``` 30 | npm run test:unit 31 | ``` 32 | -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "presets": [ 3 | [ 4 | "@vue/app", 5 | { 6 | "useBuiltIns": "entry" 7 | } 8 | ] 9 | ] 10 | } -------------------------------------------------------------------------------- /frontend/build-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Loading Aggregator config from env var..." 5 | echo "SERVER_HOST: $SERVER_HOST" 6 | echo "AGGREGATOR_NAME: $AGGREGATOR_NAME" 7 | echo "AGGREGATOR_OPEN_FARM_REGISTRATION: $AGGREGATOR_OPEN_FARM_REGISTRATION" 8 | echo "AGGREGATOR_INVITE_FARM_REGISTRATION: $AGGREGATOR_INVITE_FARM_REGISTRATION" 9 | echo "AGGREGATOR_OAUTH_CLIENT_ID: $AGGREGATOR_OAUTH_CLIENT_ID" 10 | echo "AGGREGATOR_OAUTH_CLIENT_SECRET: $AGGREGATOR_OAUTH_SECRET" 11 | echo "AGGREGATOR_OAUTH_SCOPES: $AGGREGATOR_OAUTH_SCOPES" 12 | echo "AGGREGATOR_OAUTH_DEFAULT_SCOPES: $AGGREGATOR_OAUTH_DEFAULT_SCOPES" 13 | echo "AGGREGATOR_OAUTH_REQUIRED_SCOPES: $AGGREGATOR_OAUTH_REQUIRED_SCOPES" 14 | 15 | # Build the env.js config file to be loaded at runtime. 16 | envsubst < /env-template.js > /usr/share/nginx/html/env.js 17 | 18 | # Execute the arguments passed into this script. 19 | echo "Attempting: $@" 20 | exec "$@" 21 | -------------------------------------------------------------------------------- /frontend/env-template.js: -------------------------------------------------------------------------------- 1 | // Set the apiUrl to the same variable used to configure backend. 2 | const apiUrl = '${SERVER_HOST}'; 3 | 4 | // General Aggregator config. 5 | const appName = '${AGGREGATOR_NAME}'; 6 | const openFarmRegistration = '${AGGREGATOR_OPEN_FARM_REGISTRATION}' === 'true'; 7 | const inviteFarmRegistration = '${AGGREGATOR_INVITE_FARM_REGISTRATION}' === 'true'; 8 | 9 | const oauthClientId = '${AGGREGATOR_OAUTH_CLIENT_ID}'; 10 | 11 | // Optional Client Secret. Null if not provided. 12 | let oauthClientSecret = '${AGGREGATOR_OAUTH_CLIENT_SECRET}'; 13 | if (oauthClientSecret === '') { 14 | oauthClientSecret = null; 15 | } 16 | 17 | // OAuth Scope Config. 18 | let oauthScopes = '${AGGREGATOR_OAUTH_SCOPES}'; 19 | if (oauthScopes !== '') { 20 | oauthScopes = JSON.parse(oauthScopes); 21 | } 22 | 23 | // OAuth Scopes that are "checked" by default. 24 | let oauthDefaultScopes = '${AGGREGATOR_OAUTH_DEFAULT_SCOPES}'; 25 | if (oauthDefaultScopes !== '') { 26 | oauthDefaultScopes = JSON.parse(oauthDefaultScopes); 27 | } 28 | 29 | // OAuth Scopes that are required by the aggregator. 30 | let oauthRequiredScopes = '${AGGREGATOR_OAUTH_REQUIRED_SCOPES}'; 31 | if (oauthRequiredScopes !== '') { 32 | oauthRequiredScopes = JSON.parse(oauthRequiredScopes); 33 | } 34 | 35 | // Assign values to global window. 36 | window._env = { 37 | apiUrl: apiUrl, 38 | appName: appName, 39 | openFarmRegistration: openFarmRegistration, 40 | inviteFarmRegistration: inviteFarmRegistration, 41 | oauthClientId: oauthClientId, 42 | oauthClientSecret: oauthClientSecret, 43 | oauthScopes: oauthScopes, 44 | oauthDefaultScopes: oauthDefaultScopes, 45 | oauthRequiredScopes: oauthRequiredScopes 46 | }; 47 | -------------------------------------------------------------------------------- /frontend/nginx-backend-not-found.conf: -------------------------------------------------------------------------------- 1 | location /api { 2 | return 404; 3 | } 4 | location /docs { 5 | return 404; 6 | } 7 | location /redoc { 8 | return 404; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "env": "envsub --all -S -f .env env-template.js public/env.js", 7 | "serve": "npm run env && vue-cli-service serve", 8 | "build": "npm run env && vue-cli-service build", 9 | "lint": "vue-cli-service lint", 10 | "test:unit": "npm run env && vue-cli-service test:unit" 11 | }, 12 | "dependencies": { 13 | "@babel/polyfill": "^7.12.1", 14 | "@mdi/font": "^4.9.95", 15 | "axios": "^0.21.1", 16 | "envsub": "^4.0.7", 17 | "register-service-worker": "^1.7.2", 18 | "sass": "^1.32.8", 19 | "sass-loader": "^8.0.2", 20 | "typesafe-vuex": "^3.2.2", 21 | "vee-validate": "^2.2.15", 22 | "vue": "^2.6.12", 23 | "vue-class-component": "^7.2.6", 24 | "vue-property-decorator": "^8.5.1", 25 | "vue-router": "^3.5.1", 26 | "vuetify": "^2.4.5", 27 | "vuex": "^3.6.2", 28 | "vuex-persist": "^2.3.0" 29 | }, 30 | "devDependencies": { 31 | "@types/jest": "^23.3.14", 32 | "@vue/cli-plugin-babel": "^3.12.1", 33 | "@vue/cli-plugin-pwa": "^3.12.1", 34 | "@vue/cli-plugin-typescript": "^3.12.1", 35 | "@vue/cli-plugin-unit-jest": "^3.12.1", 36 | "@vue/cli-service": "^3.12.1", 37 | "@vue/test-utils": "^1.1.3", 38 | "babel-core": "7.0.0-bridge.0", 39 | "ts-jest": "^23.10.5", 40 | "typescript": "^3.9.9", 41 | "vue-cli-plugin-vuetify": "^0.2.1", 42 | "vue-template-compiler": "^2.6.12" 43 | }, 44 | "postcss": { 45 | "plugins": { 46 | "autoprefixer": {} 47 | } 48 | }, 49 | "browserslist": [ 50 | "> 1%", 51 | "last 2 versions", 52 | "not ie <= 10" 53 | ], 54 | "jest": { 55 | "moduleFileExtensions": [ 56 | "js", 57 | "jsx", 58 | "json", 59 | "vue", 60 | "ts", 61 | "tsx" 62 | ], 63 | "transform": { 64 | "^.+\\.vue$": "vue-jest", 65 | ".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": "jest-transform-stub", 66 | "^.+\\.tsx?$": "ts-jest" 67 | }, 68 | "moduleNameMapper": { 69 | "^@/(.*)$": "/src/$1" 70 | }, 71 | "snapshotSerializers": [ 72 | "jest-serializer-vue" 73 | ], 74 | "testMatch": [ 75 | "**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)" 76 | ], 77 | "testURL": "http://localhost/" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /frontend/public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | farmOS Aggregator 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "short_name": "frontend", 4 | "icons": [ 5 | { 6 | "src": "/img/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/img/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "background_color": "#000000", 19 | "theme_color": "#4DBA87" 20 | } 21 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 44 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/component-hooks.ts: -------------------------------------------------------------------------------- 1 | import Component from 'vue-class-component'; 2 | 3 | // Register the router hooks with their names 4 | Component.registerHooks([ 5 | 'beforeRouteEnter', 6 | 'beforeRouteLeave', 7 | 'beforeRouteUpdate', // for vue-router 2.2+ 8 | ]); 9 | -------------------------------------------------------------------------------- /frontend/src/components/FarmAuthorizationStatus.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 40 | 41 | 44 | -------------------------------------------------------------------------------- /frontend/src/components/FarmRequestRegistrationDialog.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 118 | -------------------------------------------------------------------------------- /frontend/src/components/FarmTagsChips.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 28 | 29 | 32 | -------------------------------------------------------------------------------- /frontend/src/components/NotificationsManager.vue: -------------------------------------------------------------------------------- 1 | 9 | 78 | -------------------------------------------------------------------------------- /frontend/src/components/RouterComponent.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /frontend/src/components/UploadButton.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 25 | 26 | 35 | -------------------------------------------------------------------------------- /frontend/src/env.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { _env: any; } 3 | } 4 | 5 | window._env = window._env || {}; 6 | 7 | export function env(key = '') { 8 | // Reloading a page may make the Vue app make an API 9 | // before knowing the apiURL that is loaded in with a JS file. 10 | // This allows the window origin to be used in these cases. 11 | if (window._env[key] === undefined && key === 'apiUrl') { 12 | return window.location.origin; 13 | } 14 | return window._env[key]; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export interface IUserProfile { 2 | email: string; 3 | is_active: boolean; 4 | is_superuser: boolean; 5 | full_name: string; 6 | id: number; 7 | } 8 | 9 | export interface IUserProfileUpdate { 10 | email?: string; 11 | full_name?: string; 12 | password?: string; 13 | is_active?: boolean; 14 | is_superuser?: boolean; 15 | } 16 | 17 | export interface IUserProfileCreate { 18 | email: string; 19 | full_name?: string; 20 | password?: string; 21 | is_active?: boolean; 22 | is_superuser?: boolean; 23 | } 24 | 25 | export interface ApiKey { 26 | id: number; 27 | time_created: string; 28 | key: string; 29 | enabled: boolean; 30 | name: string; 31 | notes: string; 32 | farm_id: number[]; 33 | all_farms: boolean; 34 | scopes: string[]; 35 | } 36 | 37 | export interface ApiKeyCreate { 38 | enabled?: boolean; 39 | name: string; 40 | notes?: string; 41 | farm_id?: number[]; 42 | all_farms?: boolean; 43 | scopes: string[]; 44 | } 45 | 46 | export interface ApiKeyUpdate { 47 | enabled?: boolean; 48 | name?: string; 49 | notes?: string; 50 | } 51 | 52 | export interface FarmToken { 53 | access_token: string; 54 | refresh_token: string; 55 | expires_at: string; 56 | expires_in: string; 57 | } 58 | 59 | export interface FarmProfile { 60 | id: number; 61 | time_updated?: string; 62 | time_created?: string; 63 | last_accessed?: string; 64 | farm_name: string; 65 | url: string; 66 | notes?: string; 67 | tags?: string; 68 | info?: object[]; 69 | scope?: string; 70 | active?: boolean; 71 | 72 | is_authorized: boolean; 73 | auth_error?: string; 74 | token: FarmToken; 75 | } 76 | 77 | export interface FarmInfo { 78 | name: string; 79 | url: string; 80 | api_version: string; 81 | } 82 | 83 | export interface FarmProfileUpdate { 84 | farm_name?: string; 85 | url?: string; 86 | notes?: string; 87 | tags?: string; 88 | active?: boolean; 89 | } 90 | 91 | export interface FarmProfileCreate { 92 | farm_name: string; 93 | url: string; 94 | notes?: string; 95 | tags?: string; 96 | token?: FarmToken; 97 | active?: boolean; 98 | scope?: string; 99 | } 100 | 101 | export interface FarmProfileAuthorize { 102 | grant_type: string; 103 | code: string; 104 | state: string; 105 | client_id: string; 106 | client_secret?: string; 107 | redirect_uri?: string; 108 | scope?: string; 109 | } 110 | 111 | export interface FarmAuthorizationNonce { 112 | apiToken?: string; 113 | state?: string; 114 | registerNewFarm?: boolean; 115 | farmId?: number; 116 | farmUrl?: string; 117 | scopes?: string[]; 118 | } 119 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill'; 2 | // Import Component hooks before component definitions 3 | import './component-hooks'; 4 | import Vue from 'vue'; 5 | import vuetify from './plugins/vuetify'; 6 | import 'vuetify/dist/vuetify.min.css'; 7 | import '@mdi/font/css/materialdesignicons.css'; // Ensure you are using css-loader 8 | import './plugins/vee-validate'; 9 | import App from './App.vue'; 10 | import router from './router'; 11 | import store from '@/store'; 12 | import './registerServiceWorker'; 13 | 14 | Vue.config.productionTip = false; 15 | 16 | new Vue({ 17 | vuetify, 18 | router, 19 | store, 20 | render: (h) => h(App), 21 | }).$mount('#app'); 22 | -------------------------------------------------------------------------------- /frontend/src/plugins/vee-validate.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VeeValidate from 'vee-validate'; 3 | 4 | Vue.use(VeeValidate); 5 | -------------------------------------------------------------------------------- /frontend/src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuetify from 'vuetify'; 3 | 4 | Vue.use(Vuetify); 5 | 6 | export default new Vuetify({ 7 | icons: { 8 | iconfont: 'mdi', 9 | }, 10 | theme: { 11 | dark: false, 12 | themes: { 13 | light: { 14 | primary: '#336633', // farmos-green-dark: #336633; 15 | secondary: '#4e8b31', // farmos-green: #4e8b31; 16 | accent: '#60af32', // farmos-green-light: #60af32; 17 | }, 18 | dark: { 19 | primary: '#336633', // farmos-green-dark: #336633; 20 | secondary: '#4e8b31', // farmos-green: #4e8b31; 21 | accent: '#60af32', // farmos-green-light: #60af32; 22 | }, 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/registerServiceWorker.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | 3 | import { register } from 'register-service-worker'; 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log( 9 | 'App is being served from cache by a service worker.\n' + 10 | 'For more details, visit https://goo.gl/AFskqB', 11 | ); 12 | }, 13 | cached() { 14 | console.log('Content has been cached for offline use.'); 15 | }, 16 | updated() { 17 | console.log('New content is available; please refresh.'); 18 | }, 19 | offline() { 20 | console.log('No internet connection found. App is running in offline mode.'); 21 | }, 22 | error(error) { 23 | console.error('Error during service worker registration:', error); 24 | }, 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue'; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue'; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/store/admin/getters.ts: -------------------------------------------------------------------------------- 1 | import { AdminState } from './state'; 2 | import { getStoreAccessors } from 'typesafe-vuex'; 3 | import { State } from '../state'; 4 | 5 | export const getters = { 6 | adminUsers: (state: AdminState) => state.users, 7 | adminOneUser: (state: AdminState) => (userId: number) => { 8 | const filteredUsers = state.users.filter((user) => user.id === userId); 9 | if (filteredUsers.length > 0) { 10 | return { ...filteredUsers[0] }; 11 | } 12 | }, 13 | apiKeys: (state: AdminState) => state.apiKeys, 14 | }; 15 | 16 | const { read } = getStoreAccessors(''); 17 | 18 | export const readAdminOneUser = read(getters.adminOneUser); 19 | export const readAdminUsers = read(getters.adminUsers); 20 | export const readApiKeys = read(getters.apiKeys); 21 | -------------------------------------------------------------------------------- /frontend/src/store/admin/index.ts: -------------------------------------------------------------------------------- 1 | import { mutations } from './mutations'; 2 | import { getters } from './getters'; 3 | import { actions } from './actions'; 4 | import { AdminState } from './state'; 5 | 6 | const defaultState: AdminState = { 7 | users: [], 8 | apiKeys: [], 9 | }; 10 | 11 | export const adminModule = { 12 | state: defaultState, 13 | mutations, 14 | actions, 15 | getters, 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/store/admin/mutations.ts: -------------------------------------------------------------------------------- 1 | import { IUserProfile, ApiKey } from '@/interfaces'; 2 | import { AdminState } from './state'; 3 | import { getStoreAccessors } from 'typesafe-vuex'; 4 | import { State } from '../state'; 5 | 6 | export const mutations = { 7 | setUsers(state: AdminState, payload: IUserProfile[]) { 8 | state.users = payload; 9 | }, 10 | setUser(state: AdminState, payload: IUserProfile) { 11 | const users = state.users.filter((user: IUserProfile) => user.id !== payload.id); 12 | users.push(payload); 13 | state.users = users; 14 | }, 15 | setApiKeys(state: AdminState, payload: ApiKey[]) { 16 | state.apiKeys = payload; 17 | }, 18 | setApiKey(state: AdminState, payload: ApiKey) { 19 | const keys = state.apiKeys.filter((key: ApiKey) => key.id !== payload.id); 20 | keys.push(payload); 21 | state.apiKeys = keys; 22 | }, 23 | deleteApiKey(state: AdminState, id: number) { 24 | const i = state.apiKeys.map((item) => item.id).indexOf(id); 25 | state.apiKeys.splice(i, 1); 26 | }, 27 | }; 28 | 29 | const { commit } = getStoreAccessors(''); 30 | 31 | export const commitSetUser = commit(mutations.setUser); 32 | export const commitSetUsers = commit(mutations.setUsers); 33 | export const commitSetApiKeys = commit(mutations.setApiKeys); 34 | export const commitSetApiKey = commit(mutations.setApiKey); 35 | export const commitDeleteApiKey = commit(mutations.deleteApiKey); 36 | -------------------------------------------------------------------------------- /frontend/src/store/admin/state.ts: -------------------------------------------------------------------------------- 1 | import { IUserProfile, ApiKey } from '@/interfaces'; 2 | 3 | export interface AdminState { 4 | users: IUserProfile[]; 5 | apiKeys: ApiKey[]; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/store/farm/getters.ts: -------------------------------------------------------------------------------- 1 | import { FarmState } from './state'; 2 | import { getStoreAccessors } from 'typesafe-vuex'; 3 | import { State } from '../state'; 4 | 5 | export const getters = { 6 | farms: (state: FarmState) => state.farms, 7 | oneFarm: (state: FarmState) => (farmId: number) => { 8 | const filteredFarms = state.farms.filter((farm) => farm.id === farmId); 9 | if (filteredFarms.length > 0) { 10 | return { ...filteredFarms[0] }; 11 | } 12 | }, 13 | }; 14 | 15 | const { read } = getStoreAccessors(''); 16 | 17 | export const readOneFarm = read(getters.oneFarm); 18 | export const readFarms = read(getters.farms); 19 | -------------------------------------------------------------------------------- /frontend/src/store/farm/index.ts: -------------------------------------------------------------------------------- 1 | import { mutations } from './mutations'; 2 | import { getters } from './getters'; 3 | import { actions } from './actions'; 4 | import { FarmState } from './state'; 5 | 6 | const defaultState: FarmState = { 7 | farms: [], 8 | }; 9 | 10 | export const farmModule = { 11 | state: defaultState, 12 | mutations, 13 | actions, 14 | getters, 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/store/farm/mutations.ts: -------------------------------------------------------------------------------- 1 | import { FarmProfile } from '@/interfaces'; 2 | import { FarmState } from './state'; 3 | import { getStoreAccessors } from 'typesafe-vuex'; 4 | import { State } from '../state'; 5 | 6 | export const mutations = { 7 | setFarms(state: FarmState, payload: FarmProfile[]) { 8 | state.farms = payload; 9 | }, 10 | setFarm(state: FarmState, payload: FarmProfile) { 11 | const farms = state.farms.filter((farm: FarmProfile) => farm.id !== payload.id); 12 | farms.push(payload); 13 | state.farms = farms; 14 | }, 15 | }; 16 | 17 | const { commit } = getStoreAccessors(''); 18 | 19 | export const commitSetFarm = commit(mutations.setFarm); 20 | export const commitSetFarms = commit(mutations.setFarms); 21 | -------------------------------------------------------------------------------- /frontend/src/store/farm/state.ts: -------------------------------------------------------------------------------- 1 | import { FarmProfile } from '@/interfaces'; 2 | 3 | export interface FarmState { 4 | farms: FarmProfile[]; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex, { StoreOptions } from 'vuex'; 3 | import VuexPersistence from 'vuex-persist'; 4 | 5 | import { mainModule } from './main'; 6 | import { State } from './state'; 7 | import { adminModule } from './admin'; 8 | import { farmModule } from './farm'; 9 | 10 | Vue.use(Vuex); 11 | 12 | const vuexLocal = new VuexPersistence({ 13 | storage: window.localStorage, 14 | reducer: (state) => ({ 15 | main: { 16 | farmAuthorization: state.main.farmAuthorization, 17 | }, 18 | }), 19 | }); 20 | 21 | const storeOptions: StoreOptions = { 22 | modules: { 23 | main: mainModule, 24 | admin: adminModule, 25 | farm: farmModule, 26 | }, 27 | plugins: [vuexLocal.plugin], 28 | }; 29 | 30 | export const store = new Vuex.Store(storeOptions); 31 | 32 | export default store; 33 | -------------------------------------------------------------------------------- /frontend/src/store/main/getters.ts: -------------------------------------------------------------------------------- 1 | import { MainState } from './state'; 2 | import { getStoreAccessors } from 'typesafe-vuex'; 3 | import { State } from '../state'; 4 | 5 | export const getters = { 6 | hasAdminAccess: (state: MainState) => { 7 | return ( 8 | state.userProfile && 9 | state.userProfile.is_superuser && state.userProfile.is_active); 10 | }, 11 | loginError: (state: MainState) => state.logInError, 12 | dashboardShowDrawer: (state: MainState) => state.dashboardShowDrawer, 13 | dashboardMiniDrawer: (state: MainState) => state.dashboardMiniDrawer, 14 | userProfile: (state: MainState) => state.userProfile, 15 | token: (state: MainState) => state.token, 16 | isLoggedIn: (state: MainState) => state.isLoggedIn, 17 | firstNotification: (state: MainState) => state.notifications.length > 0 && state.notifications[0], 18 | farmAuthorizationNonce: (state: MainState) => { 19 | return state.farmAuthorization; 20 | }, 21 | }; 22 | 23 | const {read} = getStoreAccessors(''); 24 | 25 | export const readDashboardMiniDrawer = read(getters.dashboardMiniDrawer); 26 | export const readDashboardShowDrawer = read(getters.dashboardShowDrawer); 27 | export const readHasAdminAccess = read(getters.hasAdminAccess); 28 | export const readIsLoggedIn = read(getters.isLoggedIn); 29 | export const readLoginError = read(getters.loginError); 30 | export const readToken = read(getters.token); 31 | export const readUserProfile = read(getters.userProfile); 32 | export const readFirstNotification = read(getters.firstNotification); 33 | export const readFarmAuthorizationNonce = read(getters.farmAuthorizationNonce); 34 | -------------------------------------------------------------------------------- /frontend/src/store/main/index.ts: -------------------------------------------------------------------------------- 1 | import { mutations } from './mutations'; 2 | import { getters } from './getters'; 3 | import { actions } from './actions'; 4 | import { MainState } from './state'; 5 | 6 | const defaultState: MainState = { 7 | isLoggedIn: null, 8 | token: '', 9 | logInError: false, 10 | userProfile: null, 11 | dashboardMiniDrawer: false, 12 | dashboardShowDrawer: true, 13 | notifications: [], 14 | farmAuthorization: null!, 15 | }; 16 | 17 | export const mainModule = { 18 | state: defaultState, 19 | mutations, 20 | actions, 21 | getters, 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/src/store/main/mutations.ts: -------------------------------------------------------------------------------- 1 | import {FarmAuthorizationNonce, IUserProfile} from '@/interfaces'; 2 | import { MainState, AppNotification } from './state'; 3 | import { getStoreAccessors } from 'typesafe-vuex'; 4 | import { State } from '../state'; 5 | 6 | 7 | export const mutations = { 8 | setToken(state: MainState, payload: string) { 9 | state.token = payload; 10 | }, 11 | setLoggedIn(state: MainState, payload: boolean) { 12 | state.isLoggedIn = payload; 13 | }, 14 | setLogInError(state: MainState, payload: boolean) { 15 | state.logInError = payload; 16 | }, 17 | setUserProfile(state: MainState, payload: IUserProfile) { 18 | state.userProfile = payload; 19 | }, 20 | setDashboardMiniDrawer(state: MainState, payload: boolean) { 21 | state.dashboardMiniDrawer = payload; 22 | }, 23 | setDashboardShowDrawer(state: MainState, payload: boolean) { 24 | state.dashboardShowDrawer = payload; 25 | }, 26 | addNotification(state: MainState, payload: AppNotification) { 27 | state.notifications.push(payload); 28 | }, 29 | removeNotification(state: MainState, payload: AppNotification) { 30 | state.notifications = state.notifications.filter((notification) => notification !== payload); 31 | }, 32 | setFarmAuthorizationNonce(state: MainState, payload: FarmAuthorizationNonce) { 33 | state.farmAuthorization = payload; 34 | }, 35 | removeFarmAuthorizationNonce(state: MainState) { 36 | state.farmAuthorization = null; 37 | }, 38 | }; 39 | 40 | const {commit} = getStoreAccessors(''); 41 | 42 | export const commitSetDashboardMiniDrawer = commit(mutations.setDashboardMiniDrawer); 43 | export const commitSetDashboardShowDrawer = commit(mutations.setDashboardShowDrawer); 44 | export const commitSetLoggedIn = commit(mutations.setLoggedIn); 45 | export const commitSetLogInError = commit(mutations.setLogInError); 46 | export const commitSetToken = commit(mutations.setToken); 47 | export const commitSetUserProfile = commit(mutations.setUserProfile); 48 | export const commitAddNotification = commit(mutations.addNotification); 49 | export const commitRemoveNotification = commit(mutations.removeNotification); 50 | export const commitSetFarmAuthorizationNonce = commit(mutations.setFarmAuthorizationNonce); 51 | export const commitRemoveFarmAuthorizationNonce = commit(mutations.removeFarmAuthorizationNonce); 52 | -------------------------------------------------------------------------------- /frontend/src/store/main/state.ts: -------------------------------------------------------------------------------- 1 | import {FarmAuthorizationNonce, IUserProfile} from '@/interfaces'; 2 | 3 | export interface AppNotification { 4 | content: string; 5 | color?: string; 6 | showProgress?: boolean; 7 | } 8 | 9 | export interface MainState { 10 | token: string; 11 | isLoggedIn: boolean | null; 12 | logInError: boolean; 13 | userProfile: IUserProfile | null; 14 | dashboardMiniDrawer: boolean; 15 | dashboardShowDrawer: boolean; 16 | notifications: AppNotification[]; 17 | farmAuthorization: FarmAuthorizationNonce | null; 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/store/state.ts: -------------------------------------------------------------------------------- 1 | import { MainState } from './main/state'; 2 | 3 | export interface State { 4 | main: MainState; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const getLocalToken = () => localStorage.getItem('token'); 2 | 3 | export const saveLocalToken = (token: string) => localStorage.setItem('token', token); 4 | 5 | export const removeLocalToken = () => localStorage.removeItem('token'); 6 | -------------------------------------------------------------------------------- /frontend/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 58 | 59 | 61 | -------------------------------------------------------------------------------- /frontend/src/views/PasswordRecovery.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 50 | 51 | 53 | -------------------------------------------------------------------------------- /frontend/src/views/ResetPassword.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 85 | -------------------------------------------------------------------------------- /frontend/src/views/main/Start.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 39 | -------------------------------------------------------------------------------- /frontend/src/views/main/admin/Admin.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 29 | -------------------------------------------------------------------------------- /frontend/src/views/main/admin/AdminUsers.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 82 | -------------------------------------------------------------------------------- /frontend/src/views/main/admin/CreateUser.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 98 | -------------------------------------------------------------------------------- /frontend/src/views/main/farm/AddFarm.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 105 | -------------------------------------------------------------------------------- /frontend/src/views/main/farm/AuthorizeFarm.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 127 | -------------------------------------------------------------------------------- /frontend/src/views/main/farm/Farm.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 29 | -------------------------------------------------------------------------------- /frontend/src/views/main/profile/UserProfile.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 47 | -------------------------------------------------------------------------------- /frontend/src/views/main/profile/UserProfileEdit.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 98 | -------------------------------------------------------------------------------- /frontend/src/views/main/profile/UserProfileEditPassword.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 87 | -------------------------------------------------------------------------------- /frontend/tests/unit/upload-button.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils'; 2 | import UploadButton from '@/components/UploadButton.vue'; 3 | import '@/plugins/vuetify'; 4 | 5 | describe('UploadButton.vue', () => { 6 | it('renders props.title when passed', () => { 7 | const title = 'upload a file'; 8 | const wrapper = shallowMount(UploadButton, { 9 | slots: { 10 | default: title, 11 | }, 12 | }); 13 | expect(wrapper.text()).toMatch(title); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": false, 4 | "target": "esnext", 5 | "module": "esnext", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "importHelpers": true, 9 | "moduleResolution": "node", 10 | "experimentalDecorators": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "resolveJsonModule": true, 16 | "types": [ 17 | "webpack-env", 18 | "jest" 19 | ], 20 | "paths": { 21 | "@/*": [ 22 | "src/*" 23 | ] 24 | }, 25 | "lib": [ 26 | "esnext", 27 | "dom", 28 | "dom.iterable", 29 | "scripthost" 30 | ] 31 | }, 32 | "include": [ 33 | "src/**/*.ts", 34 | "src/**/*.tsx", 35 | "src/**/*.vue", 36 | "tests/**/*.ts", 37 | "tests/**/*.tsx", 38 | ], 39 | "exclude": [ 40 | "node_modules" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /frontend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "warning", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "linterOptions": { 7 | "exclude": [ 8 | "node_modules/**", 9 | "src/*.json" 10 | ] 11 | }, 12 | "rules": { 13 | "quotemark": [true, "single"], 14 | "indent": [true, "spaces", 2], 15 | "interface-name": false, 16 | "ordered-imports": false, 17 | "object-literal-sort-keys": false, 18 | "no-consecutive-blank-lines": false 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Fix Vuex-typescript in prod: https://github.com/istrib/vuex-typescript/issues/13#issuecomment-409869231 3 | configureWebpack: (config) => { 4 | if (process.env.NODE_ENV === 'production') { 5 | config.optimization.minimizer[0].options.terserOptions = Object.assign( 6 | {}, 7 | config.optimization.minimizer[0].options.terserOptions, 8 | { 9 | ecma: 5, 10 | compress: { 11 | keep_fnames: true, 12 | }, 13 | warnings: false, 14 | mangle: { 15 | keep_fnames: true, 16 | }, 17 | }, 18 | ); 19 | } 20 | }, 21 | chainWebpack: config => { 22 | config.module 23 | .rule('vue') 24 | .use('vue-loader') 25 | .loader('vue-loader') 26 | .tap(options => Object.assign(options, { 27 | transformAssetUrls: { 28 | 'v-img': ['src', 'lazy-src'], 29 | 'v-card': 'src', 30 | 'v-card-media': 'src', 31 | 'v-responsive': 'src', 32 | } 33 | })); 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /img/aggregator_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/aggregator_logo.png -------------------------------------------------------------------------------- /img/api/farm_create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/api/farm_create.png -------------------------------------------------------------------------------- /img/api/farm_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/api/farm_info.png -------------------------------------------------------------------------------- /img/api/farm_log_create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/api/farm_log_create.png -------------------------------------------------------------------------------- /img/api/farmos_records_api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/api/farmos_records_api.png -------------------------------------------------------------------------------- /img/api/farms_api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/api/farms_api.png -------------------------------------------------------------------------------- /img/ui/add_farm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/ui/add_farm.png -------------------------------------------------------------------------------- /img/ui/aggregator_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/ui/aggregator_dashboard.png -------------------------------------------------------------------------------- /img/ui/cron_last_accessed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/ui/cron_last_accessed.png -------------------------------------------------------------------------------- /img/ui/manage_farms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/ui/manage_farms.png -------------------------------------------------------------------------------- /img/ui/manage_users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/ui/manage_users.png -------------------------------------------------------------------------------- /img/ui/re-authorize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/ui/re-authorize.png -------------------------------------------------------------------------------- /img/ui/register_step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/ui/register_step2.png -------------------------------------------------------------------------------- /img/ui/request_authorization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/ui/request_authorization.png -------------------------------------------------------------------------------- /img/ui/request_registration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/farmOS/farmOS-aggregator/3cef3bcf580a7814b5f7352c7fed647ea4f6f81c/img/ui/request_registration.png -------------------------------------------------------------------------------- /nginx.deploy.template: -------------------------------------------------------------------------------- 1 | # Redirect port 80 traffic (except ACME challenges). 2 | server { 3 | listen 80; 4 | server_name ${DOMAIN}; 5 | location ^~ /.well-known/acme-challenge/ { 6 | root /var/www/letsencrypt; 7 | } 8 | location / { 9 | return 301 https://$host$request_uri; 10 | } 11 | } 12 | 13 | # Proxy traffic to backend. 14 | server { 15 | error_log syslog:server=unix:/dev/log; 16 | access_log syslog:server=unix:/dev/log; 17 | listen 443 ssl; 18 | server_name ${DOMAIN}; 19 | 20 | # Configure SSL protocols. 21 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; 22 | ssl_prefer_server_ciphers on; 23 | 24 | # Enable SSL session cache to improve performance. 25 | ssl_session_cache shared:SSL:20m; 26 | ssl_session_timeout 10m; 27 | 28 | # Add Strict-Transport-Security to prevent man in the middle attacks. 29 | add_header Strict-Transport-Security "max-age=31536000"; 30 | 31 | # Point to SSL certificates. 32 | ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem; 33 | ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem; 34 | 35 | location / { 36 | proxy_set_header Host $http_host; 37 | proxy_set_header X-Forwarded-Host $http_host; 38 | proxy_set_header X-Real-IP $remote_addr; 39 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 40 | proxy_set_header X-Forwarded-Proto $scheme; 41 | proxy_connect_timeout 300; 42 | proxy_send_timeout 300; 43 | proxy_read_timeout 300; 44 | send_timeout 300; 45 | proxy_pass http://frontend; 46 | } 47 | 48 | location ~ ^/(docs|api|redoc) { 49 | proxy_set_header Host $http_host; 50 | proxy_set_header X-Forwarded-Host $http_host; 51 | proxy_set_header X-Real-IP $remote_addr; 52 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 53 | proxy_set_header X-Forwarded-Proto $scheme; 54 | proxy_connect_timeout 300; 55 | proxy_send_timeout 300; 56 | proxy_read_timeout 300; 57 | send_timeout 300; 58 | proxy_pass http://backend; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /nginx.template: -------------------------------------------------------------------------------- 1 | # Redirect port 80 traffic (except ACME challenges). 2 | server { 3 | listen 80; 4 | server_name localhost; 5 | location / { 6 | proxy_set_header Host $http_host; 7 | proxy_set_header X-Forwarded-Host $http_host; 8 | proxy_set_header X-Real-IP $remote_addr; 9 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 10 | proxy_set_header X-Forwarded-Proto $scheme; 11 | proxy_connect_timeout 300; 12 | proxy_send_timeout 300; 13 | proxy_read_timeout 300; 14 | send_timeout 300; 15 | proxy_pass http://frontend; 16 | } 17 | location ~ ^/(docs|api|redoc) { 18 | proxy_set_header Host $http_host; 19 | proxy_set_header X-Forwarded-Host $http_host; 20 | proxy_set_header X-Real-IP $remote_addr; 21 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 22 | proxy_set_header X-Forwarded-Proto $scheme; 23 | proxy_connect_timeout 300; 24 | proxy_send_timeout 300; 25 | proxy_read_timeout 300; 26 | send_timeout 300; 27 | proxy_pass http://backend; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /scripts/build-push.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | TAG=${TAG} \ 7 | FRONTEND_ENV=${FRONTEND_ENV-production} \ 8 | source ./scripts/build.sh 9 | 10 | docker-compose -f docker-stack.yml push 11 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | TAG=${TAG} \ 7 | FRONTEND_ENV=${FRONTEND_ENV-production} \ 8 | docker-compose \ 9 | -f docker-compose.deploy.build.yml \ 10 | -f docker-compose.deploy.images.yml \ 11 | config > docker-stack.yml 12 | 13 | docker-compose -f docker-stack.yml build 14 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | DOMAIN=${DOMAIN} \ 7 | TAG=${TAG} \ 8 | docker-compose \ 9 | -f docker-compose.shared.yml \ 10 | -f docker-compose.deploy.yml \ 11 | -f docker-compose.deploy.images.yml \ 12 | up -d 13 | 14 | -------------------------------------------------------------------------------- /scripts/test-local.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | if [ $(uname -s) = "Linux" ]; then 7 | echo "Remove __pycache__ files" 8 | sudo find . -type d -name __pycache__ -exec rm -r {} \+ 9 | fi 10 | 11 | docker-compose \ 12 | -f docker-compose.shared.yml \ 13 | -f docker-compose.dev.yml \ 14 | config > docker-stack.yml 15 | 16 | 17 | docker-compose -f docker-stack.yml build 18 | docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error 19 | docker-compose -f docker-stack.yml up -d 20 | docker-compose -f docker-stack.yml exec -T backend bash /app/tests-start.sh "$@" 21 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -ex 5 | 6 | DOMAIN=backend \ 7 | INSTALL_DEV=true \ 8 | AGGREGATOR_OAUTH_INSECURE_TRANSPORT=true \ 9 | docker-compose \ 10 | -f docker-compose.shared.yml \ 11 | -f docker-compose.dev.yml \ 12 | -f docker-compose.test.yml \ 13 | config > docker-stack.yml 14 | 15 | docker-compose -f docker-stack.yml build 16 | docker-compose -f docker-stack.yml down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error 17 | docker-compose -f docker-stack.yml up -d 18 | docker-compose -f docker-stack.yml exec -u www-data -T farmos_www drush site-install -y --db-url=pgsql://postgres:postgres@db/app --db-prefix=farm --account-pass=admin 19 | docker-compose -f docker-stack.yml exec -u www-data -T farmos_www drush user-create tester --password test 20 | docker-compose -f docker-stack.yml exec -u www-data -T farmos_www drush user-add-role farm_manager tester 21 | docker-compose -f docker-stack.yml exec -T -e TEST_FARM_URL=http://farmos_www -e TEST_FARM_USERNAME=tester -e TEST_FARM_PASSWORD=test backend bash /app/tests-start.sh "$@" 22 | docker-compose -f docker-stack.yml down -v --remove-orphans 23 | --------------------------------------------------------------------------------