├── .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 | [](https://opensource.org/licenses/GPL-3.0/)
4 | [](https://github.com/farmOS/farmOS-aggregator/releases)
5 | [](https://gitlab.com/farmOS/farmOS-aggregator/commits/master)
6 | [](https://hub.docker.com/r/farmos/aggregator/)
7 | [](https://github.com/farmOS/farmOS-aggregator/commits)
8 | [](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 | 
84 |
85 | CRUD endpoints for managing farmOS server profiles.
86 |
87 | 
88 |
89 | All farmOS related APIs for interacting with records are listed under the `farm` tag prefix.
90 |
91 | 
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 | 
97 |
98 | `GET` farm info.
99 |
100 | 
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
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 |
2 |
3 |
10 | Authorized
11 |
12 |
13 |
14 |
21 | Re-Authorize
22 | error
23 |
24 |
25 | {{ farm.auth_error }}
26 |
27 |
28 |
29 |
30 |
40 |
41 |
44 |
--------------------------------------------------------------------------------
/frontend/src/components/FarmRequestRegistrationDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 | Request Registration
8 |
9 |
10 | Input the email to send a registration link. This should be the farmOS admin email.
11 |
12 |
13 |
14 | OR
15 |
16 |
23 | Generate Registration Link
24 |
25 |
26 |
32 |
33 |
34 |
35 |
36 |
37 |
42 | Cancel
43 |
44 |
45 |
51 | Send
52 |
53 |
54 |
55 |
56 |
57 |
58 |
118 |
--------------------------------------------------------------------------------
/frontend/src/components/FarmTagsChips.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 | {{ tag }}
12 |
13 |
14 |
15 |
16 |
28 |
29 |
32 |
--------------------------------------------------------------------------------
/frontend/src/components/NotificationsManager.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ currentNotificationContent }}
5 | Close
6 |
7 |
8 |
9 |
78 |
--------------------------------------------------------------------------------
/frontend/src/components/RouterComponent.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
12 |
--------------------------------------------------------------------------------
/frontend/src/components/UploadButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Choose File
4 |
5 |
6 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{appName}}
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | Incorrect email or password
19 |
20 |
21 | Forgot your password?
22 |
23 |
24 |
25 | Login
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
58 |
59 |
61 |
--------------------------------------------------------------------------------
/frontend/src/views/PasswordRecovery.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{appName}} - Password Recovery
9 |
10 |
11 | A password recovery email will be sent to the registered account
12 |
13 |
14 |
15 |
16 |
17 |
18 | Cancel
19 |
20 | Recover Password
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
50 |
51 |
53 |
--------------------------------------------------------------------------------
/frontend/src/views/ResetPassword.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{appName}} - Reset Password
9 |
10 |
11 | Enter your new password below
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | Cancel
22 | Clear
23 | Save
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
85 |
--------------------------------------------------------------------------------
/frontend/src/views/main/Start.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
39 |
--------------------------------------------------------------------------------
/frontend/src/views/main/admin/Admin.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
29 |
--------------------------------------------------------------------------------
/frontend/src/views/main/admin/AdminUsers.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Manage Users
6 |
7 |
8 | Create User
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
23 |
26 | edit
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
82 |
--------------------------------------------------------------------------------
/frontend/src/views/main/admin/CreateUser.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Create User
6 |
7 |
8 |
9 |
10 |
11 |
12 | User is superuser (currently is a superuser)(currently is not a superuser)
13 |
14 | User is active (currently active)(currently not active)
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | Cancel
30 | Reset
31 |
32 | Save
33 |
34 |
35 |
36 |
37 |
38 |
39 |
98 |
--------------------------------------------------------------------------------
/frontend/src/views/main/farm/AddFarm.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Add a Farm
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
24 |
25 |
26 |
27 |
30 |
31 |
32 |
33 | Request Registration
34 | Cancel
35 | Reset
36 |
41 | Save
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
105 |
--------------------------------------------------------------------------------
/frontend/src/views/main/farm/AuthorizeFarm.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Authorize Farm
6 |
7 |
8 |
9 | Farm Info
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | OAuth Token
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | Cancel
36 |
41 | Request Authorization
42 |
43 |
44 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
127 |
--------------------------------------------------------------------------------
/frontend/src/views/main/farm/Farm.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
29 |
--------------------------------------------------------------------------------
/frontend/src/views/main/profile/UserProfile.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | User Profile
6 |
7 |
8 |
9 |
Full Name
10 |
{{userProfile.full_name}}
11 |
-----
12 |
13 |
14 |
Email
15 |
{{userProfile.email}}
16 |
-----
17 |
18 |
19 |
20 | Edit
21 | Change password
22 |
23 |
24 |
25 |
26 |
27 |
47 |
--------------------------------------------------------------------------------
/frontend/src/views/main/profile/UserProfileEdit.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Edit User Profile
6 |
7 |
8 |
9 |
14 |
19 |
28 |
29 |
30 |
31 |
32 |
33 | Cancel
34 | Reset
35 |
39 | Save
40 |
41 |
42 |
43 |
44 |
45 |
46 |
98 |
--------------------------------------------------------------------------------
/frontend/src/views/main/profile/UserProfileEditPassword.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Set Password
6 |
7 |
8 |
9 |
10 |
User
11 |
{{userProfile.full_name}}
12 |
{{userProfile.email}}
13 |
14 |
15 |
25 |
26 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | Cancel
43 | Reset
44 | Save
45 |
46 |
47 |
48 |
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 |
--------------------------------------------------------------------------------