├── .env ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── gh-pages.yml │ ├── side-effects.yml │ └── unit-test.yml ├── .gitignore ├── .worker.env ├── LICENCE ├── README.md ├── backend ├── Dockerfile ├── Dockerfile-test ├── backend_django │ ├── __init__.py │ ├── config │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── settings.py │ │ ├── settings_dev.py │ │ ├── settings_prod.py │ │ ├── urls.py │ │ └── wsgi.py │ ├── galv │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── apps.py │ │ ├── fixtures │ │ │ ├── DataColumnType.json │ │ │ └── DataUnit.json │ │ ├── management │ │ │ ├── __init__.py │ │ │ └── commands │ │ │ │ ├── __init__.py │ │ │ │ ├── create_superuser.py │ │ │ │ └── init_db.py │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── pagination.py │ │ ├── permissions.py │ │ ├── schema.py │ │ ├── serializers.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── factories.py │ │ │ ├── test_api_tokens.py │ │ │ ├── test_view_cells.py │ │ │ ├── test_view_datasets.py │ │ │ ├── test_view_equipment.py │ │ │ ├── test_view_files.py │ │ │ ├── test_view_harvester.py │ │ │ ├── test_view_path.py │ │ │ ├── test_view_user.py │ │ │ └── utils.py │ │ ├── utils.py │ │ └── views.py │ └── manage.py ├── requirements-test.txt ├── requirements.txt └── server.sh ├── docker-compose.dev.yml ├── docker-compose.docs.yml ├── docker-compose.harvester.yml ├── docker-compose.test.yml ├── docker-compose.yml ├── docs ├── Makefile ├── README.txt ├── make.bat └── source │ ├── DevelopmentGuide.rst │ ├── FirstTimeQuickSetup.rst │ ├── UserGuide.rst │ ├── conf.py │ ├── img │ ├── Galv-logo-lg.png │ ├── Galv-logo-shortened.png │ ├── Galv-logo-sm.png │ ├── GalvStructure.PNG │ ├── Galv_DB_ERD.png │ ├── galv_frontend.jpg │ └── galv_frontend_v1.png │ └── index.rst ├── frontend ├── .dockerignore ├── .env ├── .gitignore ├── Dockerfile ├── Dockerfile_dev ├── README.md ├── index.html ├── inject_envvars.sh ├── nginx.conf.template ├── package.json ├── public │ ├── Galvanalyser-icon.svg │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── APIConnection.ts │ ├── ActionButtons.tsx │ ├── Api.js │ ├── App.css │ ├── App.tsx │ ├── ApproveUsers.tsx │ ├── AsyncTable.tsx │ ├── CellList.tsx │ ├── Cells.tsx │ ├── DatasetChart.tsx │ ├── DatasetDetail.js │ ├── Datasets.tsx │ ├── Equipment.tsx │ ├── Files.tsx │ ├── FormComponents.js │ ├── Galv-icon.svg │ ├── Galv-logo.svg │ ├── GetDatasetJulia.js │ ├── GetDatasetMatlab.js │ ├── GetDatasetPython.js │ ├── HarvesterEnv.tsx │ ├── Harvesters.tsx │ ├── Login.tsx │ ├── MonitoredPaths.tsx │ ├── PaginatedTable.tsx │ ├── Tokens.tsx │ ├── UseStyles.ts │ ├── UserProfile.tsx │ ├── UserRoleSet.tsx │ ├── __mocks__ │ │ ├── CellList.tsx │ │ ├── DatasetChart.tsx │ │ ├── Files.tsx │ │ ├── GetDatasetJulia.js │ │ ├── GetDatasetMatlab.js │ │ ├── GetDatasetPython.js │ │ ├── HarvesterEnv.tsx │ │ ├── MonitoredPaths.tsx │ │ └── UserRoleSet.tsx │ ├── auth │ │ └── index.js │ ├── conf.json │ ├── index.css │ ├── index.d.ts │ ├── index.jsx │ ├── logo.svg │ ├── react-app-env.d.ts │ ├── reportWebVitals.js │ ├── setupTests.js │ └── test │ │ ├── ActionButtons.test.js │ │ ├── ActivateUsers.test.js │ │ ├── CellList.test.js │ │ ├── Cells.test.js │ │ ├── DatasetChart.test.js │ │ ├── Datasets.test.js │ │ ├── Equipment.test.js │ │ ├── Files.test.js │ │ ├── Harvester.test.js │ │ ├── HarvesterEnv.test.js │ │ ├── MonitoredPaths.test.js │ │ ├── README.md │ │ ├── Tokens.test.js │ │ ├── UserProfile.test.js │ │ ├── UserRoleSet.test.js │ │ ├── fixtures │ │ ├── cell_families.json │ │ ├── cells.json │ │ ├── columns.json │ │ ├── datasets.json │ │ ├── equipment.json │ │ ├── files.json │ │ ├── harvesters.json │ │ ├── inactive_users.json │ │ ├── monitored_paths.json │ │ ├── tokens.json │ │ └── users.json │ │ └── run_tests.sh ├── tsconfig.json └── yarn.lock ├── galv.service ├── harvester ├── Dockerfile ├── harvester │ ├── __init__.py │ ├── api.py │ ├── harvest.py │ ├── parse │ │ ├── __init__.py │ │ ├── biologic_input_file.py │ │ ├── exceptions.py │ │ ├── input_file.py │ │ ├── ivium_input_file.py │ │ └── maccor_input_file.py │ ├── run.py │ ├── settings.py │ └── utils.py ├── requirements.txt ├── start.py └── test │ ├── __init__.py │ └── test_harvester.py ├── nginx-proxy ├── Dockerfile └── default ├── restructure.png └── setup.py /.env: -------------------------------------------------------------------------------- 1 | # this is the directory that the galv postgres database will be located 2 | GALV_DATA_PATH=./.run/data/galv 3 | 4 | # this is the directory that will be scanned for battery tester output files when 5 | # running the harvester test suite 6 | GALV_HARVESTER_TEST_PATH=./.run/test_datafiles 7 | 8 | # Required to get react running: 9 | NODE_OPTIONS=--openssl-legacy-provider 10 | 11 | # database access; other credentials in .env.secret 12 | POSTGRES_USER=postgres 13 | # (container name) 14 | POSTGRES_HOST=postgres 15 | POSTGRES_PORT=5432 16 | 17 | # CORS configuration for backend 18 | VIRTUAL_HOST_ROOT=localhost 19 | 20 | # NGINX configuration 21 | # LETSENCRYPT_HOST=localhost 22 | LETSENCRYPT_EMAIL=oxfordrse@gmail.com 23 | LETSENCRYPT_TEST=true 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve Galv 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Run when Docs workflow completes 6 | workflow_run: 7 | branches: 8 | - main 9 | workflows: [Docs] 10 | types: 11 | - completed 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 17 | permissions: 18 | contents: read 19 | pages: write 20 | id-token: write 21 | 22 | # Allow one concurrent deployment 23 | concurrency: 24 | group: "pages" 25 | cancel-in-progress: true 26 | 27 | jobs: 28 | # Single deploy job since we're just deploying 29 | deploy: 30 | environment: 31 | name: github-pages 32 | url: ${{ steps.deployment.outputs.page_url }} 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Checkout 36 | uses: actions/checkout@v3 37 | with: 38 | ref: 'gh-pages' 39 | - name: Setup Pages 40 | uses: actions/configure-pages@v3 41 | - name: Upload artifact 42 | uses: actions/upload-pages-artifact@v1 43 | with: 44 | # Upload entire repository 45 | path: '.' 46 | - name: Deploy to GitHub Pages 47 | id: deployment 48 | uses: actions/deploy-pages@v1 49 | -------------------------------------------------------------------------------- /.github/workflows/side-effects.yml: -------------------------------------------------------------------------------- 1 | # Generate documentation and associated files 2 | # If file gets unweildy with just one job, could refactor to use outputs: 3 | # https://docs.github.com/en/actions/using-jobs/defining-outputs-for-jobs 4 | 5 | # This name is referenced by gh-pages.yml workflow. Update there if this changes. 6 | name: Docs 7 | on: 8 | push: 9 | branches: 10 | - main 11 | pull_request: 12 | branches: 13 | - '**' 14 | workflow_dispatch: 15 | inputs: 16 | deploy_docs: 17 | type: boolean 18 | description: 'Run the deploy-to-gh-pages step' 19 | required: false 20 | default: false 21 | debug_enabled: 22 | type: boolean 23 | description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' 24 | required: false 25 | default: false 26 | 27 | jobs: 28 | build-erd: 29 | runs-on: ubuntu-latest 30 | env: 31 | POSTGRES_PASSWORD: "galv" 32 | DJANGO_SECRET_KEY: "long-and-insecure-key-12345" 33 | steps: 34 | - uses: actions/checkout@v3 35 | - name: Install additional requirements 36 | run: | 37 | sudo apt-get install -y graphviz 38 | sudo pip install sphinx 39 | mkdir docs/source/resources 40 | echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" > .env.secret 41 | echo "DJANGO_SECRET_KEY=$DJANGO_SECRET_KEY" >> .env.secret 42 | echo "django-extensions==3.2.1" >> backend/requirements.txt 43 | sed -i "s+'rest_framework',+'rest_framework', 'django_extensions',+g" backend/backend_django/config/settings_prod.py 44 | 45 | - name: Create Entity Relationship Diagram 46 | run: | 47 | docker-compose -f docker-compose.docs.yml run app python manage.py graph_models --dot --output output.dot galv 48 | dot -Tpng backend/backend_django/output.dot -o docs/source/resources/ERD.png 49 | 50 | - name: Create API spec 51 | run: | 52 | docker-compose -f docker-compose.docs.yml run app python manage.py spectacular --file schema.yml 53 | docker-compose -f docker-compose.docs.yml run app python manage.py spectacular --format openapi-json --file schema.json 54 | mv backend/backend_django/schema.* docs/source/resources/ 55 | 56 | # - name: Create API client 57 | # run: | 58 | # echo "{\"lang\": \"python\", \"type\": \"CLIENT\", \"codegenVersion\": \"V3\", \"spec\": $(cat docs/schema.json)}" > payload.json 59 | # curl -d @payload.json --output docs/source/resources/galv-client-python.zip -H "Content-Type: application/json" https://generator3.swagger.io/api/generate 60 | # # Check size 61 | # if [ ! -s docs/source/resources/galv-client-python.zip ]; then 62 | # echo "Downloaded python client zip file is zero bytes" 63 | # exit 1 64 | # fi 65 | # # Check we can unzip 66 | # unzip -t docs/source/resources/galv-client-python.zip 67 | 68 | # Enable tmate debugging of manually-triggered workflows if the input option was provided 69 | - name: Setup tmate session 70 | uses: mxschmitt/action-tmate@v3 71 | if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} 72 | 73 | - name: Sphinx build 74 | run: | 75 | cd docs 76 | make html 77 | 78 | - name: Push to gh-pages branch 79 | if: (github.ref_name == 'main' && github.event_name == 'push') || inputs.deploy_docs 80 | run: | 81 | git worktree add gh-pages 82 | git config user.name "Deploy from CI" 83 | git config user.email "" 84 | cd gh-pages 85 | # Delete the ref to avoid keeping history. 86 | git update-ref -d refs/heads/gh-pages 87 | rm -rf * 88 | mv ../docs/build/html/* . 89 | git add . 90 | git commit -m "Deploy $GITHUB_SHA to gh-pages" 91 | git push --set-upstream origin gh-pages --force 92 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests (Docker) 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - '**' 9 | workflow_dispatch: 10 | inputs: 11 | debug_enabled: 12 | type: boolean 13 | description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' 14 | required: false 15 | default: false 16 | 17 | jobs: 18 | test-harvester: 19 | runs-on: ubuntu-latest 20 | env: 21 | GALV_HARVESTER_TEST_PATH: .test_datafiles 22 | steps: 23 | - uses: actions/checkout@v3 24 | 25 | - name: Install smbclient 26 | run: | 27 | sudo apt-get update 28 | sudo apt-get install -y smbclient 29 | 30 | - name: Restore cached test suite 31 | id: cache-restore 32 | uses: actions/cache/restore@v3 33 | with: 34 | path: ${{ env.GALV_HARVESTER_TEST_PATH }} 35 | key: liionsden-test-suite 36 | 37 | - name: Download test suite 38 | if: steps.cache-restore.outputs.cache-hit != 'true' 39 | env: 40 | LIIONSDEN_SMB_PATH: ${{ secrets.LIIONSDEN_SMB_PATH }} 41 | LIIONSDEN_SMB_USERNAME: ${{ secrets.LIIONSDEN_SMB_USERNAME}} 42 | LIIONSDEN_SMB_PASSWORD: ${{ secrets.LIIONSDEN_SMB_PASSWORD}} 43 | run: | 44 | sudo mkdir -p $GALV_HARVESTER_TEST_PATH 45 | cd $GALV_HARVESTER_TEST_PATH 46 | sudo smbget -R $LIIONSDEN_SMB_PATH/test-suite-small -U "$LIIONSDEN_SMB_USERNAME%$LIIONSDEN_SMB_PASSWORD" 47 | 48 | - name: Cache test suite 49 | id: cache-save 50 | if: steps.cache-restore.outputs.cache-hit != 'true' 51 | uses: actions/cache/save@v3 52 | with: 53 | path: ${{ env.GALV_HARVESTER_TEST_PATH }} 54 | key: ${{ steps.cache-restore.outputs.cache-primary-key }} 55 | 56 | - name: Build the stack 57 | run: touch .env.secret && docker-compose -f docker-compose.test.yml build harvester_test 58 | 59 | # Enable tmate debugging of manually-triggered workflows if the input option was provided 60 | - name: Setup tmate session 61 | uses: mxschmitt/action-tmate@v3 62 | if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} 63 | 64 | - name: Run tests 65 | run: docker-compose -f docker-compose.test.yml up harvester_test 66 | 67 | test-backend: 68 | runs-on: ubuntu-latest 69 | env: 70 | POSTGRES_PASSWORD: "galv" 71 | DJANGO_SECRET_KEY: "long-and-insecure-key-12345" 72 | FRONTEND_VIRTUAL_HOST: "http://localhost" 73 | VIRTUAL_HOST: "localhost" 74 | steps: 75 | - uses: actions/checkout@v3 76 | 77 | - name: Set up secrets 78 | run: | 79 | touch .env.secret 80 | echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" > .env.secret 81 | echo "DJANGO_SECRET_KEY=$DJANGO_SECRET_KEY" >> .env.secret 82 | echo "FRONTEND_VIRTUAL_HOST=$FRONTEND_VIRTUAL_HOST" >> .env.secret 83 | echo "VIRTUAL_HOST=$VIRTUAL_HOST" >> .env.secret 84 | echo "POSTGRES_HOST=postgres_test" >> .env.secret 85 | 86 | # Enable tmate debugging of manually-triggered workflows if the input option was provided 87 | - name: Setup tmate session 88 | uses: mxschmitt/action-tmate@v3 89 | if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} 90 | 91 | - name: Build the stack 92 | run: docker-compose -f docker-compose.test.yml up -d --build app_test 93 | 94 | - name: Run tests 95 | run: docker-compose -f docker-compose.test.yml run --rm app_test bash -c "cd .. && ./server.sh" 96 | 97 | test-frontend: 98 | runs-on: ubuntu-latest 99 | steps: 100 | - uses: actions/checkout@v3 101 | 102 | - name: Set up secrets 103 | run: touch .env.secret 104 | 105 | - name: Build the stack 106 | run: docker-compose -f docker-compose.test.yml build frontend_test 107 | 108 | # Enable tmate debugging of manually-triggered workflows if the input option was provided 109 | - name: Setup tmate session 110 | uses: mxschmitt/action-tmate@v3 111 | if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} 112 | 113 | - name: Run tests 114 | run: docker-compose -f docker-compose.test.yml run --rm frontend_test bash -c "./src/test/run_tests.sh" 115 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .vscode 3 | .venv 4 | .removed 5 | galv/protobuf 6 | galv/webapp/assets/protobuf 7 | libs/galv-js-protobufs 8 | galv-protobuf.js 9 | webstack/.env 10 | .pnpm-store 11 | *.egg-info 12 | env 13 | 14 | .idea/ 15 | 16 | .run/ 17 | 18 | .test-data/ 19 | 20 | frontend/src/demo_matlab_code.m 21 | 22 | **/.env.* 23 | dev.sh 24 | backend/backend_django/django_celery_beat.schedulersDatabaseScheduler 25 | 26 | .harvester/ 27 | 28 | *.pptx 29 | 30 | docker-compose.override.yml 31 | backend/backend_django/galv/migrations/*.py 32 | !backend/backend_django/galv/migrations/__init__.py 33 | 34 | docs/build/ 35 | 36 | .certs/ 37 | 38 | .vhost/ 39 | 40 | .static_files/ 41 | 42 | node_modules/ 43 | 44 | .DS_Store 45 | -------------------------------------------------------------------------------- /.worker.env: -------------------------------------------------------------------------------- 1 | # this is the base directory for the harvesters run by the server (note harvesters can 2 | # also be setup independently from the server if required, see documentation for 3 | # details). New directories added for scanning will be relative to this base directory 4 | GALV_HARVESTER_BASE_PATH=/home/mrobins/git/tmp/datafiles 5 | 6 | RABBITMQ_URL=pyamqp://test-user:test-user@rabbitmq:5672 7 | REDIS_URL=redis://:redis@redis:6379 8 | CELERY_WORKER_LOG_DIR=/home/mrobins/git/tmp/data/celery 9 | 10 | HARVESTER_USERNAME=harv 11 | HARVESTER_PASSWORD=harv 12 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 2 | of Oxford, and the 'Galv' Developers. All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 🚨Galv has moved🚨 4 | 5 | Galv has moved to separate repositories for the [backend](https://github.com/Battery-Intelligence-Lab/galv-backend), [frontend](https://github.com/Battery-Intelligence-Lab/galv-frontend), and [harvester](https://github.com/Battery-Intelligence-Lab/galv-harvester). 6 | Future changes will be made to those repositories rather than this one. 7 | Please target issues at the relevant one of those repositories. 8 | 9 | Galv v2 has a rewritten structure providing more flexibility, better interlinking of resources, and a more streamlined codebase. 10 | 11 | # Readme 12 | 13 | [![Unit Tests (Docker)](https://github.com/Battery-Intelligence-Lab/galv/actions/workflows/unit-test.yml/badge.svg?branch=main)](https://github.com/Battery-Intelligence-Lab/galv/actions/workflows/unit-test.yml) 14 | [![Docs](https://github.com/Battery-Intelligence-Lab/galv/actions/workflows/side-effects.yml/badge.svg?branch=main)](https://battery-intelligence-lab.github.io/galv/index.html) 15 | 16 | [![Docs website](https://github.com/Battery-Intelligence-Lab/galv/actions/workflows/pages/pages-build-deployment/badge.svg?branch=gh-pages)](https://github.com/Battery-Intelligence-Lab/galv/actions/workflows/pages/pages-build-deployment) 17 | 18 | 19 | Galv is an open-source platform for automated storage of battery data with advanced metadata support for battery scientists. Galv is deployed with [Docker](https://docs.docker.com/) to support robust local and cloud instances. An example frontend view is displayed below. 20 | 21 |

22 | 23 |

24 | 25 | ## Features: 26 | - REST API for easy data storage and retrieval 27 | - A Python, Julia, and MATLAB client for the REST API 28 | - Metadata support using ontology definitions from BattINFO/EMMO 29 | - A distributed platform with local data harvesters 30 | - Docker based deployment 31 | 32 | 33 | ## Getting Started 34 | Deploying a [Galv](https://Battery-Intelligence-Lab.github.io/galv/UserGuide.html#galv-server) instance in a battery lab can make it easy to access, analyse, and share experimental data. The steps to achieve this are: 35 | 1. Set the cycler's data export/save location to a single directory. 36 | 37 | 2. Set up a [harvester](https://Battery-Intelligence-Lab.github.io/galv/UserGuide.html#harvesters) on a computer with access to the directory (can be local or via a shared directory/drive). 38 | 39 | 3. Deploy Galv onto a local machine, or onto a cloud instance. Selection between these depend on security and access requirements. Note, network connection between the harvester and the Galv instance is necessary. 40 | 41 | 4. Log into the lab [web frontend](https://Battery-Intelligence-Lab.github.io/galv/UserGuide.html#web-frontend) and configure the [harvester](https://Battery-Intelligence-Lab.github.io/galv/UserGuide.html#harvesters) to crawl the appropriate directories. 42 | 43 | 5. Use the [web frontend](https://Battery-Intelligence-Lab.github.io/galv/UserGuide.html#web-frontend) to add metadata and view data, or use the [Python](https://Battery-Intelligence-Lab.github.io/galv/UserGuide.html#python-client) client to ingest the data for analysis. 44 | 45 | The harvesters are able to parse the following file types: 46 | 47 | - MACCOR files in ``.txt``, ``.xsl``/``.xslx``, or ``raw`` format 48 | - Ivium files in ``.idf`` format 49 | - Biologic files in ``.mpr`` format (EC-Lab firmware < 11.50) 50 | 51 | Galv uses a relational database that stores each dataset along with information about column types, units, and other relevant metadata (e.g. cell information, owner, purpose of the experiment). The [REST API](https://Battery-Intelligence-Lab.github.io/galv/UserGuide.html#rest-api) provides its own definition via a downloadable OpenAPI schema file (`schema/`), and provides interactive documentation via SwaggerUI (`schema/swagger-ui/`) and Redoc (`schema/redoc/`). 52 | 53 | The schema can be downloaded from the [documentation page](https://Battery-Intelligence-Lab.github.io/galv/UserGuide.html#api-spec). The below diagram presents an overview of Galv's architecture. The arrows indicate the direction of data flow. 54 | 55 |

56 | Data flows from battery cycling machines to Galv Harvesters, then to the     Galv server and REST API. Metadata can be updated and data read using the web client, and data can be downloaded by the Python client. 57 |

58 | 59 | 60 | ## Project documentation 61 | 62 | Full documentation is available [here](https://Battery-Intelligence-Lab.github.io/galv/), build by Sphinx from `./docs/source/*.rst`. 63 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | FROM python:3.10.4-slim@sha256:a2e8240faa44748fe18c5b37f83e14101a38dd3f4a1425d18e9e0e913f89b562 6 | 7 | ENV PYTHONDONTWRITEBYTECODE=1 8 | ENV PYTHONUNBUFFERED=1 9 | 10 | # Install postgresql-client for healthchecking 11 | RUN apt-get update && \ 12 | apt-get install -y \ 13 | postgresql-client \ 14 | build-essential libssl-dev libffi-dev python3-dev python-dev && \ 15 | apt-get autoremove && \ 16 | apt-get autoclean 17 | 18 | RUN mkdir -p /usr/app 19 | WORKDIR /usr/app 20 | COPY requirements.txt /requirements.txt 21 | RUN pip install -r /requirements.txt 22 | COPY . /usr/app 23 | RUN cp -rf /usr/local/lib/python3.10/site-packages/rest_framework/static/* /static 24 | -------------------------------------------------------------------------------- /backend/Dockerfile-test: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | FROM python:3.10.4-slim@sha256:a2e8240faa44748fe18c5b37f83e14101a38dd3f4a1425d18e9e0e913f89b562 6 | 7 | ENV PYTHONDONTWRITEBYTECODE=1 8 | ENV PYTHONUNBUFFERED=1 9 | ENV DJANGO_TEST=TRUE 10 | 11 | # Install postgresql-client for healthchecking 12 | RUN apt-get update && \ 13 | apt-get install -y \ 14 | postgresql-client \ 15 | build-essential libssl-dev libffi-dev python3-dev python-dev && \ 16 | apt-get autoremove && \ 17 | apt-get autoclean 18 | 19 | RUN mkdir -p /usr/app 20 | WORKDIR /usr/app 21 | COPY requirements.txt /requirements.txt 22 | RUN pip install -r /requirements.txt 23 | COPY requirements-test.txt /requirements-test.txt 24 | RUN pip install -r /requirements-test.txt 25 | COPY . /usr/app 26 | -------------------------------------------------------------------------------- /backend/backend_django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/backend/backend_django/__init__.py -------------------------------------------------------------------------------- /backend/backend_django/config/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import galv.schema 6 | -------------------------------------------------------------------------------- /backend/backend_django/config/asgi.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | """ 6 | ASGI config for backend_django project. 7 | 8 | It exposes the ASGI callable as a module-level variable named ``application``. 9 | 10 | For more information on this path, see 11 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 12 | """ 13 | 14 | import os 15 | 16 | from django.core.asgi import get_asgi_application 17 | 18 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') 19 | 20 | application = get_asgi_application() 21 | -------------------------------------------------------------------------------- /backend/backend_django/config/settings.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import os 6 | 7 | if 'DJANGO_SETTINGS' in os.environ and os.environ['DJANGO_SETTINGS'] == "dev": 8 | from .settings_dev import * 9 | else: 10 | from .settings_prod import * 11 | -------------------------------------------------------------------------------- /backend/backend_django/config/urls.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | """backend_django URL Configuration 6 | 7 | The `urlpatterns` list routes URLs to views. For more information please see: 8 | https://docs.djangoproject.com/en/4.1/topics/http/urls/ 9 | Examples: 10 | Function views 11 | 1. Add an import: from my_app import views 12 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 13 | Class-based views 14 | 1. Add an import: from other_app.views import Home 15 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 16 | Including another URLconf 17 | 1. Import the include() function: from django.urls import include, path 18 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 19 | """ 20 | from django.contrib import admin 21 | from django.urls import include, path 22 | from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView 23 | from rest_framework import routers 24 | from galv import views 25 | 26 | router = routers.DefaultRouter() 27 | 28 | router.register(r'harvesters', views.HarvesterViewSet) 29 | router.register(r'harvest_errors', views.HarvestErrorViewSet) 30 | router.register(r'monitored_paths', views.MonitoredPathViewSet) 31 | router.register(r'files', views.ObservedFileViewSet) 32 | router.register(r'datasets', views.DatasetViewSet) 33 | router.register(r'columns', views.DataColumnViewSet) 34 | router.register(r'column_types', views.DataColumnTypeViewSet) 35 | router.register(r'units', views.DataUnitViewSet) 36 | router.register(r'equipment', views.EquipmentViewSet) 37 | router.register(r'cell_families', views.CellFamilyViewSet) 38 | router.register(r'cells', views.CellViewSet) 39 | router.register(r'inactive_users', views.InactiveViewSet, basename='inactive_user') 40 | router.register(r'users', views.UserViewSet, basename='user') 41 | router.register(r'groups', views.GroupViewSet) 42 | router.register(r'tokens', views.TokenViewSet, basename='tokens') 43 | 44 | # Wire up our API using automatic URL routing. 45 | # Additionally, we include login URLs for the browsable API. 46 | urlpatterns = [ 47 | path('', include(router.urls)), 48 | # path('data/{pk}/', views.TimeseriesDataViewSet.as_view({'get': 'detail'}), name='timeseriesdata-detail'), 49 | path('admin/', admin.site.urls), 50 | path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), 51 | path(r'login/', views.LoginView.as_view(), name='knox_login'), 52 | path(r'logout/', views.LogoutView.as_view(), name='knox_logout'), 53 | path(r'logoutall/', views.LogoutAllView.as_view(), name='knox_logoutall'), 54 | path(r'create_token/', views.CreateTokenView.as_view(), name='knox_create_token'), 55 | path('schema/', SpectacularAPIView.as_view(), name='schema'), 56 | path('schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), 57 | path('schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), 58 | ] 59 | pass 60 | -------------------------------------------------------------------------------- /backend/backend_django/config/wsgi.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | """ 6 | WSGI config for backend_django project. 7 | 8 | It exposes the WSGI callable as a module-level variable named ``application``. 9 | 10 | For more information on this path, see 11 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 12 | """ 13 | 14 | import os 15 | 16 | from django.core.wsgi import get_wsgi_application 17 | 18 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') 19 | 20 | application = get_wsgi_application() 21 | -------------------------------------------------------------------------------- /backend/backend_django/galv/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/backend/backend_django/galv/__init__.py -------------------------------------------------------------------------------- /backend/backend_django/galv/admin.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | from django.contrib import admin 6 | 7 | # Register your models here. 8 | -------------------------------------------------------------------------------- /backend/backend_django/galv/apps.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | from django.apps import AppConfig 6 | 7 | 8 | class GalvConfig(AppConfig): 9 | default_auto_field = 'django.db.models.BigAutoField' 10 | name = 'galv' 11 | -------------------------------------------------------------------------------- /backend/backend_django/galv/fixtures/DataColumnType.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "galv.DataColumnType", 5 | "fields": { 6 | "name": "Unknown", 7 | "description": "unknown column type", 8 | "is_default": true 9 | } 10 | }, 11 | { 12 | "pk": 2, 13 | "model": "galv.DataColumnType", 14 | "fields": { 15 | "name": "Sample Number", 16 | "description": "The sample or record number. Is increased by one each time a test machine records a reading. Usually counts from 1 at the start of a test", 17 | "unit": 1, 18 | "is_default": true 19 | } 20 | }, 21 | { 22 | "pk": 3, 23 | "model": "galv.DataColumnType", 24 | "fields": { 25 | "name": "Time", 26 | "description": "The time in seconds since the test run began.", 27 | "unit": 2, 28 | "is_default": true 29 | } 30 | }, 31 | { 32 | "pk": 4, 33 | "model": "galv.DataColumnType", 34 | "fields": { 35 | "name": "Volts", 36 | "description": "The voltage of the cell.", 37 | "unit": 3, 38 | "is_default": true 39 | } 40 | }, 41 | { 42 | "pk": 5, 43 | "model": "galv.DataColumnType", 44 | "fields": { 45 | "name": "Amps", 46 | "description": "The current current.", 47 | "unit": 4, 48 | "is_default": true 49 | } 50 | }, 51 | { 52 | "pk": 6, 53 | "model": "galv.DataColumnType", 54 | "fields": { 55 | "name": "Energy Capacity", 56 | "description": "The Energy Capacity.", 57 | "unit": 5, 58 | "is_default": true 59 | } 60 | }, 61 | { 62 | "pk": 7, 63 | "model": "galv.DataColumnType", 64 | "fields": { 65 | "name": "Charge Capacity", 66 | "description": "The Charge Capacity.", 67 | "unit": 6, 68 | "is_default": true 69 | } 70 | }, 71 | { 72 | "pk": 8, 73 | "model": "galv.DataColumnType", 74 | "fields": { 75 | "name": "Temperature", 76 | "description": "The temperature.", 77 | "unit": 7, 78 | "is_default": true 79 | } 80 | }, 81 | { 82 | "pk": 9, 83 | "model": "galv.DataColumnType", 84 | "fields": { 85 | "name": "Step Time", 86 | "description": "The time in seconds since the current step began.", 87 | "unit": 8, 88 | "is_default": true 89 | } 90 | }, 91 | { 92 | "pk": 10, 93 | "model": "galv.DataColumnType", 94 | "fields": { 95 | "name": "Impedence Magnitude", 96 | "description": "The magnitude of the impedence (EIS).", 97 | "unit": 9, 98 | "is_default": true 99 | } 100 | }, 101 | { 102 | "pk": 11, 103 | "model": "galv.DataColumnType", 104 | "fields": { 105 | "name": "Impedence Phase", 106 | "description": "The phase of the impedence (EIS).", 107 | "unit": 10, 108 | "is_default": true 109 | } 110 | }, 111 | { 112 | "pk": 12, 113 | "model": "galv.DataColumnType", 114 | "fields": { 115 | "name": "Frequency", 116 | "description": "The frequency of the input EIS voltage signal.", 117 | "unit": 11, 118 | "is_default": true 119 | } 120 | } 121 | ] -------------------------------------------------------------------------------- /backend/backend_django/galv/fixtures/DataUnit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "galv.DataUnit", 5 | "fields": { 6 | "name": "Unitless", 7 | "symbol": "", 8 | "description": "A value with no units", 9 | "is_default": true 10 | } 11 | }, 12 | { 13 | "pk": 2, 14 | "model": "galv.DataUnit", 15 | "fields": { 16 | "name": "Time", 17 | "symbol": "s", 18 | "description": "Time in seconds", 19 | "is_default": true 20 | } 21 | }, 22 | { 23 | "pk": 3, 24 | "model": "galv.DataUnit", 25 | "fields": { 26 | "name": "Volts", 27 | "symbol": "V", 28 | "description": "Voltage", 29 | "is_default": true 30 | } 31 | }, 32 | { 33 | "pk": 4, 34 | "model": "galv.DataUnit", 35 | "fields": { 36 | "name": "Amps", 37 | "symbol": "A", 38 | "description": "Current", 39 | "is_default": true 40 | } 41 | }, 42 | { 43 | "pk": 5, 44 | "model": "galv.DataUnit", 45 | "fields": { 46 | "name": "Energy", 47 | "symbol": "Wh", 48 | "description": "Energy in Watt-Hours", 49 | "is_default": true 50 | } 51 | }, 52 | { 53 | "pk": 6, 54 | "model": "galv.DataUnit", 55 | "fields": { 56 | "name": "Charge", 57 | "symbol": "Ah", 58 | "description": "Charge in Amp-Hours", 59 | "is_default": true 60 | } 61 | }, 62 | { 63 | "pk": 7, 64 | "model": "galv.DataUnit", 65 | "fields": { 66 | "name": "Temperature", 67 | "symbol": "°c", 68 | "description": "Temperature in Centigrade", 69 | "is_default": true 70 | } 71 | }, 72 | { 73 | "pk": 8, 74 | "model": "galv.DataUnit", 75 | "fields": { 76 | "name": "Power", 77 | "symbol": "W", 78 | "description": "Power in Watts", 79 | "is_default": true 80 | } 81 | }, 82 | { 83 | "pk": 9, 84 | "model": "galv.DataUnit", 85 | "fields": { 86 | "name": "Ohm", 87 | "symbol": "Ω", 88 | "description": "Resistance or impediance in Ohms", 89 | "is_default": true 90 | } 91 | }, 92 | { 93 | "pk": 10, 94 | "model": "galv.DataUnit", 95 | "fields": { 96 | "name": "Degrees", 97 | "symbol": "°", 98 | "description": "Angle in degrees", 99 | "is_default": true 100 | } 101 | }, 102 | { 103 | "pk": 11, 104 | "model": "galv.DataUnit", 105 | "fields": { 106 | "name": "Frequency", 107 | "symbol": "Hz", 108 | "description": "Frequency in Hz", 109 | "is_default": true 110 | } 111 | } 112 | ] -------------------------------------------------------------------------------- /backend/backend_django/galv/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/backend/backend_django/galv/management/__init__.py -------------------------------------------------------------------------------- /backend/backend_django/galv/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/backend/backend_django/galv/management/commands/__init__.py -------------------------------------------------------------------------------- /backend/backend_django/galv/management/commands/create_superuser.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | from django.core.management.base import BaseCommand 6 | from django.contrib.auth.models import User 7 | import os 8 | 9 | 10 | class Command(BaseCommand): 11 | help = """ 12 | Create superuser with login details from envvars 13 | DJANGO_SUPERUSER_USERNAME (default=admin), 14 | DJANGO_SUPERUSER_PASSWORD (required) 15 | """ 16 | 17 | def handle(self, *args, **options): 18 | password = os.getenv('DJANGO_SUPERUSER_PASSWORD', "") 19 | if not len(password): 20 | self.stdout.write(self.style.WARNING( 21 | 'No DJANGO_SUPERUSER_PASSWORD specified, skipping superuser creation.' 22 | )) 23 | return 24 | username = os.getenv('DJANGO_SUPERUSER_USERNAME', 'admin') 25 | if User.objects.filter(username=username).exists(): 26 | self.stdout.write(self.style.WARNING( 27 | f'User {username} already exists: skipping user creation.' 28 | )) 29 | return 30 | User.objects.create_user( 31 | username=username, 32 | password=password, 33 | is_superuser=True, 34 | is_staff=True, 35 | is_active=True 36 | ) 37 | self.stdout.write(self.style.SUCCESS(f'Created superuser {username}.')) 38 | -------------------------------------------------------------------------------- /backend/backend_django/galv/management/commands/init_db.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | from django.core.management.base import BaseCommand 6 | from django.db import connection 7 | 8 | 9 | class Command(BaseCommand): 10 | help = "Create timeseries_data table in database." 11 | 12 | def handle(self, *args, **options): 13 | self.stdout.write("Creating timeseries_data table... ") 14 | with connection.cursor() as curs: 15 | curs.execute(""" 16 | CREATE TABLE IF NOT EXISTS timeseries_data ( 17 | sample bigint NOT NULL, 18 | column_id bigint NOT NULL, 19 | value double precision NOT NULL, 20 | PRIMARY KEY (sample, column_id), 21 | FOREIGN KEY (column_id) 22 | REFERENCES "galv_datacolumn" (id) MATCH SIMPLE 23 | ON UPDATE CASCADE 24 | ON DELETE RESTRICT 25 | ) WITH (OIDS = FALSE) 26 | """) 27 | self.stdout.write(self.style.SUCCESS('Complete.')) 28 | -------------------------------------------------------------------------------- /backend/backend_django/galv/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/backend/backend_django/galv/migrations/__init__.py -------------------------------------------------------------------------------- /backend/backend_django/galv/pagination.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | from rest_framework.pagination import PageNumberPagination, BasePagination 6 | 7 | 8 | class Unpaginatable(BasePagination): 9 | def paginate_queryset(self, queryset, request, view=None): 10 | # s: str = request.query_params.get('all', "") 11 | # falsy = ['false', 'f', '0', 'no', 'n'] 12 | # if s.lower() in falsy or request.query_params.get('page', None): 13 | # page_num = PageNumberPagination 14 | # return page_num.paginate_queryset( 15 | # PageNumberPagination(), 16 | # queryset=queryset, 17 | # request=request, 18 | # view=view 19 | # ) 20 | 21 | return None 22 | -------------------------------------------------------------------------------- /backend/backend_django/galv/permissions.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | from django.http import Http404 5 | from django.urls import resolve, Resolver404 6 | from urllib.parse import urlparse 7 | from django.db.models import Q 8 | from rest_framework import permissions 9 | from .models import Harvester, MonitoredPath 10 | 11 | 12 | class HarvesterAccess(permissions.BasePermission): 13 | """ 14 | Object-level permission to only allow Harvester to edit its own attributes. 15 | """ 16 | message = "Invalid AUTHORIZATION header. Required 'Harvester [api_key]' or 'Bearer [api_token]'." 17 | 18 | endpoints = ['config', 'report'] 19 | 20 | def has_object_permission(self, request, view, obj): 21 | if view.action in self.endpoints: 22 | return obj.id == int(view.kwargs['pk']) and \ 23 | request.META.get('HTTP_AUTHORIZATION', '') == f"Harvester {obj.api_key}" 24 | # /harvesters/ returns all harvesters because their envvars are redacted 25 | if view.action == 'list' and request.method == 'GET': 26 | return True 27 | # Read/write detail test 28 | user_groups = request.user.groups.all() 29 | # Allow access to Harvesters where we have a Path 30 | path_harvesters = [p.harvester.id for p in MonitoredPath.objects.filter( 31 | Q(user_group__in=user_groups) | Q(admin_group__in=user_groups) 32 | )] 33 | user_harvesters = Harvester.objects.filter( 34 | Q(user_group__in=user_groups) | 35 | Q(id__in=path_harvesters) 36 | ) 37 | admin_harvesters = Harvester.objects.filter(admin_group__in=user_groups) 38 | if request.method in permissions.SAFE_METHODS: 39 | return obj in user_harvesters or obj in admin_harvesters 40 | return obj in admin_harvesters 41 | 42 | 43 | class MonitoredPathAccess(permissions.BasePermission): 44 | """ 45 | MonitoredPaths can be read by users in the user_group or admin_group. 46 | MonitoredPaths can be edited by users in the admin_group. 47 | 48 | MonitoredPaths can be created by users in the harvester's user_group and admin_group. 49 | """ 50 | def has_object_permission(self, request, view, obj): 51 | user_groups = request.user.groups.all() 52 | if request.method in permissions.SAFE_METHODS: 53 | return obj.user_group in user_groups or obj.admin_group in user_groups 54 | return obj.admin_group in user_groups 55 | 56 | def has_permission(self, request, view): 57 | if view.action == 'create': 58 | user_groups = request.user.groups.all() 59 | try: 60 | harvester_id = resolve(urlparse(request.data.get('harvester')).path).kwargs.get('pk') 61 | except Resolver404: 62 | raise Http404(f"Invalid harvester URL '{request.data.get('harvester')}'") 63 | harvester = Harvester.objects.get(id=harvester_id) 64 | return harvester.user_group in user_groups or harvester.admin_group in user_groups 65 | return True 66 | 67 | 68 | class ReadOnlyIfInUse(permissions.BasePermission): 69 | def has_object_permission(self, request, view, obj): 70 | if request.method in permissions.SAFE_METHODS: 71 | return True 72 | return not obj.in_use() 73 | -------------------------------------------------------------------------------- /backend/backend_django/galv/schema.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | from drf_spectacular.extensions import OpenApiAuthenticationExtension 6 | from knox.settings import knox_settings 7 | 8 | class KnoxTokenScheme(OpenApiAuthenticationExtension): 9 | target_class = 'knox.auth.TokenAuthentication' 10 | name = 'knoxTokenAuth' 11 | match_subclasses = True 12 | priority = 1 13 | 14 | def get_security_definition(self, auto_schema): 15 | if knox_settings.AUTH_HEADER_PREFIX == 'Bearer': 16 | return { 17 | 'type': 'http', 18 | 'scheme': 'bearer', 19 | } 20 | else: 21 | return { 22 | 'type': 'apiKey', 23 | 'in': 'header', 24 | 'name': 'Authorization', 25 | 'description': 'Token-based authentication with required prefix "%s"' % knox_settings.AUTH_HEADER_PREFIX 26 | } 27 | -------------------------------------------------------------------------------- /backend/backend_django/galv/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/backend/backend_django/galv/tests/__init__.py -------------------------------------------------------------------------------- /backend/backend_django/galv/tests/factories.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import os 6 | 7 | import factory 8 | import faker 9 | import django.conf.global_settings 10 | from django.utils import timezone 11 | from galv.models import Harvester, \ 12 | HarvestError, \ 13 | MonitoredPath, \ 14 | ObservedFile, \ 15 | Cell, \ 16 | CellFamily, \ 17 | Dataset, \ 18 | Equipment, \ 19 | DataUnit, \ 20 | DataColumnType, \ 21 | DataColumn, \ 22 | TimeseriesRangeLabel, \ 23 | FileState 24 | from django.contrib.auth.models import User, Group 25 | 26 | fake = faker.Faker(django.conf.global_settings.LANGUAGE_CODE) 27 | 28 | 29 | class UserFactory(factory.django.DjangoModelFactory): 30 | class Meta: 31 | model = User 32 | django_get_or_create = ('username',) 33 | 34 | username = factory.Faker('user_name') 35 | 36 | 37 | class GroupFactory(factory.django.DjangoModelFactory): 38 | class Meta: 39 | model = Group 40 | django_get_or_create = ('name',) 41 | exclude = ('n',) 42 | 43 | n = factory.Faker('random_int', min=1, max=100000) 44 | name = factory.LazyAttribute(lambda x: f"group_{x.n}") 45 | 46 | 47 | class HarvesterFactory(factory.django.DjangoModelFactory): 48 | class Meta: 49 | model = Harvester 50 | django_get_or_create = ('name',) 51 | exclude = ('first_name',) 52 | 53 | first_name = fake.unique.first_name() 54 | name = factory.LazyAttribute(lambda x: f"Harvester {x.first_name}") 55 | 56 | @factory.post_generation 57 | def groups(obj, *args, **kwargs): 58 | user_group = GroupFactory.create(name=f"harvester_{obj.id}_user_group") 59 | admin_group = GroupFactory.create(name=f"harvester_{obj.id}_admin_group") 60 | obj.user_group = user_group 61 | obj.admin_group = admin_group 62 | obj.save() 63 | 64 | 65 | class MonitoredPathFactory(factory.django.DjangoModelFactory): 66 | class Meta: 67 | model = MonitoredPath 68 | django_get_or_create = ('path', 'harvester',) 69 | exclude = ('p',) 70 | 71 | p = factory.Faker( 72 | 'file_path', 73 | absolute=False, 74 | depth=factory.Faker('random_int', min=1, max=10) 75 | ) 76 | 77 | path = factory.LazyAttribute(lambda x: os.path.dirname(x.p)) 78 | regex = ".*" 79 | harvester = factory.SubFactory(HarvesterFactory) 80 | 81 | @factory.post_generation 82 | def groups(obj, *args, **kwargs): 83 | user_group = GroupFactory.create(name=f"path_{obj.id}_user_group") 84 | admin_group = GroupFactory.create(name=f"path_{obj.id}_admin_group") 85 | obj.user_group = user_group 86 | obj.admin_group = admin_group 87 | obj.save() 88 | 89 | 90 | class ObservedFileFactory(factory.django.DjangoModelFactory): 91 | class Meta: 92 | model = ObservedFile 93 | django_get_or_create = ('harvester', 'path') 94 | 95 | path = factory.Faker('file_path') 96 | harvester = factory.SubFactory(HarvesterFactory) 97 | 98 | 99 | class DatasetFactory(factory.django.DjangoModelFactory): 100 | class Meta: 101 | model = Dataset 102 | django_get_or_create = ('file', 'date',) 103 | 104 | file = factory.SubFactory(ObservedFileFactory) 105 | date = timezone.make_aware(timezone.datetime.now()) 106 | 107 | 108 | class CellFamilyFactory(factory.django.DjangoModelFactory): 109 | class Meta: 110 | model = CellFamily 111 | 112 | name = factory.Faker('catch_phrase') 113 | form_factor = factory.Faker('bs') 114 | link_to_datasheet = factory.Faker('uri') 115 | anode_chemistry = factory.Faker('bs') 116 | cathode_chemistry = factory.Faker('bs') 117 | manufacturer = factory.Faker('company') 118 | nominal_capacity = factory.Faker('pyfloat', min_value=1.0, max_value=1000000.0) 119 | nominal_cell_weight = factory.Faker('pyfloat', min_value=1.0, max_value=1000000.0) 120 | 121 | 122 | class CellFactory(factory.django.DjangoModelFactory): 123 | class Meta: 124 | model = Cell 125 | 126 | uid = factory.Faker('bothify', text='?????-##??#-#?#??-?####-?#???') 127 | display_name = factory.Faker('catch_phrase') 128 | family = factory.SubFactory(CellFamilyFactory) 129 | 130 | 131 | class EquipmentFactory(factory.django.DjangoModelFactory): 132 | class Meta: 133 | model = Equipment 134 | 135 | name = factory.Faker('catch_phrase') 136 | type = factory.Faker('bs') 137 | -------------------------------------------------------------------------------- /backend/backend_django/galv/tests/test_api_tokens.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import unittest 6 | from django.urls import reverse 7 | from rest_framework import status 8 | from rest_framework.test import APITestCase 9 | import logging 10 | 11 | from galv.models import KnoxAuthToken 12 | 13 | from .utils import GalvTestCase 14 | from .factories import UserFactory 15 | 16 | logger = logging.getLogger(__file__) 17 | logger.setLevel(logging.INFO) 18 | 19 | 20 | class TokenTests(GalvTestCase): 21 | def setUp(self): 22 | self.user = UserFactory.create(username='test_user') 23 | self.other_user = UserFactory.create(username='test_user_other') 24 | 25 | def test_crud(self): 26 | self.client.force_login(self.user) 27 | body = {'name': 'Test API token', 'ttl': 600} 28 | url = reverse('knox_create_token') 29 | print("Test create API token") 30 | response = self.client.post(url, body) 31 | self.assertEqual(response.status_code, status.HTTP_200_OK) 32 | self.assertEqual(response.json().get('name'), body['name']) 33 | self.assertIn('token', response.json()) 34 | print("OK") 35 | 36 | print("Test list tokens") 37 | url = reverse('tokens-list') 38 | response = self.client.get(url) 39 | self.assertEqual(len(response.json()), 1) 40 | detail_url = response.json()[0]['url'] 41 | self.client.force_login(self.other_user) 42 | self.assertEqual(self.client.get(detail_url).status_code, status.HTTP_404_NOT_FOUND) 43 | print("OK") 44 | 45 | print("Test token detail") 46 | self.client.force_login(self.user) 47 | response = self.client.get(detail_url) 48 | self.assertEqual(response.json()['name'], body['name']) 49 | self.assertNotIn('token', response.json()) 50 | print("OK") 51 | 52 | print("Test update") 53 | new_name = "new token name" 54 | response = self.client.patch(detail_url, {"name": new_name}) 55 | self.assertEqual(response.status_code, status.HTTP_200_OK) 56 | self.assertEqual(response.json()['name'], new_name) 57 | print("OK") 58 | 59 | print("Test token delete") 60 | response = self.client.delete(detail_url) 61 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 62 | self.assertEqual(KnoxAuthToken.objects.filter(knox_token_key__regex=f"_{self.user.id}$").exists(), False) 63 | print("OK") 64 | 65 | 66 | if __name__ == '__main__': 67 | unittest.main() 68 | -------------------------------------------------------------------------------- /backend/backend_django/galv/tests/test_view_cells.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import unittest 6 | from django.urls import reverse 7 | from rest_framework import status 8 | from rest_framework.test import APITestCase 9 | import logging 10 | 11 | from .factories import CellFamilyFactory, CellFactory 12 | from galv.models import CellFamily, Cell 13 | 14 | logger = logging.getLogger(__file__) 15 | logger.setLevel(logging.INFO) 16 | 17 | 18 | class CellFamilyTests(APITestCase): 19 | def test_create(self): 20 | body = { 21 | 'name': 'Test CF', 'form_factor': 'test', 'link_to_datasheet': 'http', 22 | 'anode_chemistry': 'yes','cathode_chemistry': 'yes', 23 | 'nominal_capacity': 5.5, 'nominal_cell_weight': 1.2, 'manufacturer': 'na' 24 | } 25 | url = reverse('cellfamily-list') 26 | print("Test create Cell Family") 27 | response = self.client.post(url, body) 28 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 29 | family_url = response.json().get('url') 30 | self.assertIsInstance(family_url, str) 31 | print("OK") 32 | print("Test create Cell") 33 | response = self.client.post( 34 | reverse('cell-list'), 35 | {'uid': 'some-unique-id-1234', 'display_name': 'test cell', 'family': family_url} 36 | ) 37 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 38 | print("OK") 39 | 40 | def test_update(self): 41 | cell_family = CellFamilyFactory.create(name='Test family') 42 | url = reverse('cellfamily-detail', args=(cell_family.id,)) 43 | print("Test update Cell Family") 44 | response = self.client.patch(url, {'name': 'cell family'}) 45 | self.assertEqual(response.status_code, status.HTTP_200_OK) 46 | self.assertEqual(CellFamily.objects.get(id=cell_family.id).name, 'cell family') 47 | print("OK") 48 | print("Test update Cell") 49 | cell = CellFactory.create(family=cell_family) 50 | url = reverse('cell-detail', args=(cell.id,)) 51 | response = self.client.patch(url, {'display_name': 'c123'}) 52 | self.assertEqual(response.status_code, status.HTTP_200_OK) 53 | self.assertEqual(Cell.objects.get(id=cell.id).display_name, 'c123') 54 | response = self.client.patch(url, {'uid': 'c123'}) 55 | self.assertEqual(response.status_code, status.HTTP_200_OK) 56 | self.assertEqual(Cell.objects.get(id=cell.id).uid, 'c123') 57 | print("OK") 58 | 59 | 60 | if __name__ == '__main__': 61 | unittest.main() 62 | -------------------------------------------------------------------------------- /backend/backend_django/galv/tests/test_view_datasets.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import unittest 6 | from django.urls import reverse 7 | from rest_framework import status 8 | from rest_framework.test import APITestCase 9 | import logging 10 | 11 | from .factories import UserFactory, \ 12 | HarvesterFactory, \ 13 | DatasetFactory, MonitoredPathFactory 14 | 15 | logger = logging.getLogger(__file__) 16 | logger.setLevel(logging.INFO) 17 | 18 | 19 | class DatasetTests(APITestCase): 20 | def setUp(self): 21 | self.harvester = HarvesterFactory.create(name='Test Dataset') 22 | self.dataset = DatasetFactory.create(file__harvester=self.harvester) 23 | self.monitored_path = MonitoredPathFactory.create(harvester=self.harvester, path="/") 24 | self.user = UserFactory.create(username='test_user') 25 | self.admin_user = UserFactory.create(username='test_user_admin') 26 | self.user.groups.add(self.harvester.user_group) 27 | self.admin_user.groups.add(self.monitored_path.admin_group) 28 | self.url = reverse('dataset-detail', args=(self.dataset.id,)) 29 | 30 | def test_view(self): 31 | self.client.force_login(self.user) 32 | print("Test rejection of dataset view") 33 | self.assertNotEqual(self.client.get(self.url).status_code, status.HTTP_200_OK) 34 | print("OK") 35 | self.client.force_login(self.admin_user) 36 | print("Test dataset view") 37 | self.assertEqual(self.client.get(self.url).status_code, status.HTTP_200_OK) 38 | print("OK") 39 | 40 | 41 | if __name__ == '__main__': 42 | unittest.main() 43 | -------------------------------------------------------------------------------- /backend/backend_django/galv/tests/test_view_equipment.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import unittest 6 | from django.urls import reverse 7 | from rest_framework import status 8 | from rest_framework.test import APITestCase 9 | import logging 10 | 11 | from .factories import EquipmentFactory 12 | from galv.models import Equipment 13 | 14 | logger = logging.getLogger(__file__) 15 | logger.setLevel(logging.INFO) 16 | 17 | 18 | class EquipmentTests(APITestCase): 19 | def test_create(self): 20 | body = {'name': 'Test EQ', 'type': 'test'} 21 | url = reverse('equipment-list') 22 | print("Test create Equipment") 23 | response = self.client.post(url, body) 24 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 25 | print("OK") 26 | 27 | def test_update(self): 28 | equipment = EquipmentFactory.create(name='Test kit') 29 | url = reverse('equipment-detail', args=(equipment.id,)) 30 | print("Test update Equipment") 31 | response = self.client.patch(url, {'name': 'New kit'}) 32 | self.assertEqual(response.status_code, status.HTTP_200_OK) 33 | self.assertEqual(Equipment.objects.get(id=equipment.id).name, 'New kit') 34 | print("OK") 35 | 36 | 37 | if __name__ == '__main__': 38 | unittest.main() 39 | -------------------------------------------------------------------------------- /backend/backend_django/galv/tests/test_view_files.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import unittest 6 | from django.urls import reverse 7 | from rest_framework import status 8 | from rest_framework.test import APITestCase 9 | import logging 10 | 11 | from .factories import UserFactory, \ 12 | HarvesterFactory, \ 13 | MonitoredPathFactory, \ 14 | ObservedFileFactory 15 | from galv.models import ObservedFile, \ 16 | FileState 17 | 18 | logger = logging.getLogger(__file__) 19 | logger.setLevel(logging.INFO) 20 | 21 | 22 | class ObservedFileTests(APITestCase): 23 | def setUp(self): 24 | self.harvester = HarvesterFactory.create(name='Test Files') 25 | self.path = MonitoredPathFactory.create(harvester=self.harvester, path="/") 26 | self.files = ObservedFileFactory.create_batch(size=5, harvester=self.harvester) 27 | self.user = UserFactory.create(username='test_user') 28 | self.admin_user = UserFactory.create(username='test_user_admin') 29 | self.user.groups.add(self.harvester.user_group) 30 | self.admin_user.groups.add(self.path.admin_group) 31 | self.url = reverse('observedfile-detail', args=(self.files[0].id,)) 32 | 33 | def test_view(self): 34 | self.client.force_login(self.user) 35 | print("Test rejection of view path") 36 | self.assertNotEqual(self.client.get(self.url).status_code, status.HTTP_200_OK) 37 | print("OK") 38 | self.client.force_login(self.admin_user) 39 | print("Test view path") 40 | self.assertEqual(self.client.get(self.url).status_code, status.HTTP_200_OK) 41 | print("OK") 42 | 43 | def test_reimport(self): 44 | self.client.force_login(self.admin_user) 45 | print("Test reimport") 46 | url = reverse('observedfile-reimport', args=(self.files[0].id,)) 47 | self.assertEqual(self.client.get(url).status_code, status.HTTP_200_OK) 48 | self.assertEqual(ObservedFile.objects.get(id=self.files[0].id).state, FileState.RETRY_IMPORT) 49 | print("OK") 50 | 51 | 52 | if __name__ == '__main__': 53 | unittest.main() 54 | -------------------------------------------------------------------------------- /backend/backend_django/galv/tests/test_view_path.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | import json 5 | import unittest 6 | from django.urls import reverse 7 | from rest_framework import status 8 | from rest_framework.test import APITestCase 9 | import logging 10 | 11 | from .factories import UserFactory, \ 12 | HarvesterFactory, \ 13 | MonitoredPathFactory 14 | from galv.models import Harvester, \ 15 | MonitoredPath 16 | 17 | logger = logging.getLogger(__file__) 18 | logger.setLevel(logging.INFO) 19 | 20 | 21 | class MonitoredPathTests(APITestCase): 22 | def setUp(self): 23 | self.path = '/path/to/data' 24 | self.harvester = HarvesterFactory.create(name='Test Paths') 25 | self.non_user = UserFactory.create(username='test_paths') 26 | self.user = UserFactory.create(username='test_paths_user') 27 | self.user.groups.add(self.harvester.user_group) 28 | self.admin_user = UserFactory.create(username='test_paths_admin') 29 | self.admin_user.groups.add(self.harvester.admin_group) 30 | 31 | def test_create(self): 32 | self.client.force_login(self.non_user) 33 | url = reverse('monitoredpath-list') 34 | print("Test rejection of Create Path - no authorisation") 35 | self.client.force_login(self.user) 36 | body = { 37 | 'path': self.path, 38 | 'regex': '.*', 39 | 'harvester': reverse('harvester-detail', args=(self.harvester.id,)), 40 | 'stable_time': 60 41 | } 42 | self.assertEqual( 43 | self.client.post(url, body).status_code, 44 | status.HTTP_400_BAD_REQUEST 45 | ) 46 | print("OK") 47 | print("Test rejection of Create Path - no path") 48 | no_path = {**body} 49 | no_path.pop('path') 50 | self.assertEqual( 51 | self.client.post(url, no_path).status_code, 52 | status.HTTP_400_BAD_REQUEST 53 | ) 54 | print("OK") 55 | print("Test rejection of Create Path - invalid harvester") 56 | i = 1 if self.harvester.id != 1 else 2 57 | self.assertEqual( 58 | self.client.post(url, {'harvester': i}).status_code, 59 | status.HTTP_404_NOT_FOUND 60 | ) 61 | print("OK") 62 | print("Test successful Path creation") 63 | self.client.force_login(self.admin_user) 64 | self.assertEqual( 65 | self.client.post(url, body).status_code, 66 | status.HTTP_201_CREATED 67 | ) 68 | print("OK") 69 | print("Test rejection of duplicate name") 70 | self.assertEqual( 71 | self.client.post(url, body).status_code, 72 | status.HTTP_400_BAD_REQUEST 73 | ) 74 | print("OK") 75 | 76 | def test_update(self): 77 | path = MonitoredPathFactory.create(path=self.path, harvester=self.harvester) 78 | self.admin_user.groups.add(path.admin_group) 79 | url = reverse('monitoredpath-detail', args=(path.id,)) 80 | print("Test update rejected - authorisation") 81 | self.client.force_login(self.user) 82 | body = {'path': path.path, 'regex': '^abc', 'stable_time': 100} 83 | self.assertEqual( 84 | self.client.patch(url, body).status_code, 85 | status.HTTP_404_NOT_FOUND 86 | ) 87 | print("OK") 88 | print("Test update okay") 89 | self.client.force_login(self.admin_user) 90 | body = {'path': path.path, 'regex': '^abc', 'stable_time': 1} 91 | self.assertEqual( 92 | self.client.patch(url, body).status_code, 93 | status.HTTP_200_OK 94 | ) 95 | self.assertEqual( 96 | MonitoredPath.objects.get(path=path.path, harvester=self.harvester).stable_time, 97 | body.get('stable_time') 98 | ) 99 | self.assertEqual( 100 | MonitoredPath.objects.get(path=path.path, harvester=self.harvester).regex, 101 | body.get('regex') 102 | ) 103 | print("OK") 104 | 105 | 106 | if __name__ == '__main__': 107 | unittest.main() 108 | -------------------------------------------------------------------------------- /backend/backend_django/galv/tests/utils.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import base64 6 | 7 | from django.urls import reverse 8 | from rest_framework.test import APITestCase 9 | 10 | 11 | class GalvTestCase(APITestCase): 12 | 13 | def get_token_header_for_user(self, user): 14 | self.client.force_login(user) 15 | user.set_password('foobar') 16 | user.save() 17 | auth_str = base64.b64encode(bytes(f"{user.username}:foobar", 'utf-8')) 18 | basic_auth = self.client.post( 19 | reverse('knox_login'), 20 | {}, 21 | HTTP_AUTHORIZATION=f"Basic {auth_str.decode('utf-8')}" 22 | ) 23 | token = basic_auth.json()['token'] 24 | return {'HTTP_AUTHORIZATION': f"Bearer {token}"} 25 | -------------------------------------------------------------------------------- /backend/backend_django/galv/utils.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import os 6 | import re 7 | 8 | from .models import MonitoredPath, Harvester, ObservedFile 9 | 10 | 11 | def get_monitored_paths(path: os.PathLike|str, harvester: Harvester) -> list[MonitoredPath]: 12 | """ 13 | Return the MonitoredPaths on this Harvester that match the given path. 14 | MonitoredPaths are matched by path and regex. 15 | """ 16 | monitored_paths = MonitoredPath.objects.filter(harvester=harvester) 17 | monitored_paths = [p for p in monitored_paths if os.path.abspath(path).startswith(os.path.abspath(p.path))] 18 | return [p for p in monitored_paths if re.search(p.regex, os.path.relpath(path, p.path))] 19 | 20 | 21 | # TODO: If these lookups are too slow, we could keep track of the monitored_path used 22 | # each time a file is reported on, and use that to lookup files by path directly. 23 | def get_files_from_path(path: MonitoredPath) -> list[ObservedFile]: 24 | """ 25 | Return a list of files from the given path that match the MonitoredPath's regex. 26 | """ 27 | files = ObservedFile.objects.filter(path__startswith=path.path, harvester=path.harvester) 28 | if not path.regex: 29 | out = files 30 | else: 31 | regex = re.compile(path.regex) 32 | out = [p for p in files if re.search(regex, os.path.relpath(p.path, path.path))] 33 | return out 34 | -------------------------------------------------------------------------------- /backend/backend_django/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # SPDX-License-Identifier: BSD-2-Clause 4 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 5 | # of Oxford, and the 'Galv' Developers. All rights reserved. 6 | 7 | """Django's command-line utility for administrative tasks.""" 8 | import os 9 | import sys 10 | 11 | 12 | def main(): 13 | """Run administrative tasks.""" 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') 15 | try: 16 | from django.core.management import execute_from_command_line 17 | except ImportError as exc: 18 | raise ImportError( 19 | "Couldn't import Django. Are you sure it's installed and " 20 | "available on your PYTHONPATH environment variable? Did you " 21 | "forget to activate a virtual environment?" 22 | ) from exc 23 | execute_from_command_line(sys.argv) 24 | 25 | 26 | if __name__ == '__main__': 27 | main() 28 | -------------------------------------------------------------------------------- /backend/requirements-test.txt: -------------------------------------------------------------------------------- 1 | factory_boy==3.2.1 2 | faker==17.0.0 -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | Django==4.1.4 2 | django-cors-headers==3.13.0 3 | django-filter==22.1 4 | djangorestframework==3.14.0 5 | django-rest-knox==4.2.0 6 | psycopg2-binary==2.9.5 7 | redis==4.4.0 8 | drf-spectacular==0.25.1 9 | markdown==3.4.1 10 | gunicorn==20.1.0 11 | -------------------------------------------------------------------------------- /backend/server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # SPDX-License-Identifier: BSD-2-Clause 3 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 4 | # of Oxford, and the 'Galv' Developers. All rights reserved. 5 | 6 | # init.sh 7 | 8 | # adapted from https://docs.docker.com/compose/startup-order/ 9 | 10 | set -e 11 | PGUP=1 12 | 13 | cd backend_django || exit 1 14 | 15 | >&2 echo "Collecting static files" 16 | python manage.py collectstatic --noinput 17 | 18 | >&2 echo "Waiting for Postgres to start" 19 | 20 | while [ $PGUP -ne 0 ]; do 21 | pg_isready -d "postgresql://postgres:galv@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/postgres" 22 | PGUP=$? 23 | >&2 echo "Postgres is unavailable - sleeping" 24 | sleep 1 25 | done 26 | 27 | >&2 echo "Postgres ready - initialising" 28 | >&2 echo "DJANGO_TEST=${DJANGO_TEST}" 29 | >&2 echo "DJANGO_SETTINGS=${DJANGO_SETTINGS}" 30 | python manage.py makemigrations 31 | python manage.py migrate 32 | python manage.py init_db 33 | python manage.py create_superuser 34 | 35 | >&2 echo "... populating database" 36 | python manage.py loaddata galv/fixtures/DataUnit.json 37 | python manage.py loaddata galv/fixtures/DataColumnType.json 38 | 39 | >&2 echo "Initialisation complete - starting server" 40 | 41 | if [ -z "${DJANGO_TEST}" ]; then 42 | if [ "${DJANGO_SETTINGS}" = "dev" ]; then 43 | >&2 echo "Launching dev server" 44 | python manage.py runserver 0.0.0.0:80 45 | else 46 | WORKERS_PER_CPU=${GUNICORN_WORKERS_PER_CPU:-2} 47 | WORKERS=$(expr $WORKERS_PER_CPU \* $(grep -c ^processor /proc/cpuinfo)) 48 | >&2 echo "Launching production server with gunicorn ($WORKERS workers [${WORKERS_PER_CPU} per CPU])" 49 | gunicorn config.wsgi \ 50 | --env DJANGO_SETTINGS_MODULE=config.settings \ 51 | --bind 0.0.0.0:80 \ 52 | --access-logfile - \ 53 | --error-logfile - \ 54 | --workers=$WORKERS 55 | fi 56 | else 57 | python manage.py test --noinput 58 | fi 59 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | version: "2" 6 | services: 7 | harvester: 8 | build: 9 | dockerfile: Dockerfile 10 | context: ./harvester 11 | depends_on: 12 | - app 13 | volumes: 14 | - ./harvester:/usr/harvester 15 | - ./.harvester:/harvester_files 16 | - "${GALV_HARVESTER_TEST_PATH}:/usr/test_data" 17 | working_dir: /usr/harvester 18 | restart: unless-stopped 19 | command: python start.py --name "harvey" --url http://app/ --user_id 1 --run_foreground --restart 20 | # command: tail -F anything 21 | 22 | frontend: 23 | image: frontend_dev 24 | build: 25 | dockerfile: Dockerfile_dev 26 | context: ./frontend 27 | args: 28 | FORCE_HTTP: "true" 29 | volumes: 30 | - ./frontend:/app 31 | working_dir: /app 32 | command: > 33 | bash -c " 34 | yarn install && 35 | yarn start -p 80 36 | " 37 | restart: unless-stopped 38 | 39 | app: 40 | volumes: 41 | - ./backend:/usr/app 42 | environment: 43 | DJANGO_SETTINGS: "dev" 44 | 45 | postgres: 46 | ports: 47 | - "5432:5432" 48 | 49 | nginx-proxy-acme-companion: 50 | restart: "no" 51 | entrypoint: [ "echo", "Service nginx-proxy-acme-companion disabled in development mode" ] 52 | 53 | # old_worker: 54 | # build: 55 | # dockerfile: Dockerfile 56 | # context: ./backend 57 | # command: python manage.py test 58 | # depends_on: 59 | # - postgres 60 | # volumes: 61 | # - ./backend:/usr/app 62 | # - "${GALV_HARVESTER_BASE_PATH}:/usr/data" 63 | # - "${GALV_HARVESTER_TEST_PATH}:/usr/test_data" 64 | # - "${CELERY_LOG_DIR}:/var/log/celery" 65 | # working_dir: /usr/app 66 | # env_file: 67 | # - ./.env 68 | -------------------------------------------------------------------------------- /docker-compose.docs.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | # Docker-compose file for the containers that generate documentation artefacts. 6 | # Should allow generation of 7 | # - entity relationship diagram 8 | # - API schema 9 | # - API client libraries 10 | version: "2" 11 | services: 12 | app: 13 | image: app 14 | build: 15 | dockerfile: Dockerfile 16 | context: backend 17 | depends_on: 18 | - postgres 19 | expose: 20 | - 80 21 | volumes: 22 | # local volume allows us to copy generated ER diagram easily 23 | - ./backend:/usr/app 24 | working_dir: /usr/app/backend_django 25 | env_file: 26 | - ./.env 27 | - ./.env.secret 28 | environment: 29 | FRONTEND_VIRTUAL_HOST: "http://localhost" 30 | restart: unless-stopped 31 | command: ./server.sh 32 | 33 | postgres: 34 | image: "postgres" 35 | stop_signal: SIGINT # Fast Shutdown mode 36 | volumes: 37 | - "${GALV_DATA_PATH}:/var/lib/postgresql/data" 38 | env_file: 39 | - ./.env 40 | - ./.env.secret 41 | restart: unless-stopped 42 | -------------------------------------------------------------------------------- /docker-compose.harvester.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | version: "2" 6 | services: 7 | harvester: 8 | build: 9 | dockerfile: Dockerfile 10 | context: ./harvester 11 | volumes: 12 | - ./harvester:/usr/harvester 13 | - ./.harvester:/harvester_files 14 | working_dir: /usr/harvester 15 | restart: unless-stopped 16 | stdin_open: true 17 | tty: true 18 | command: python start.py -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | version: "2" 6 | services: 7 | 8 | postgres_test: 9 | image: "postgres" 10 | stop_signal: SIGINT # Fast Shutdown mode 11 | volumes: 12 | - "${GALV_DATA_PATH}:/var/lib/postgresql/data" 13 | env_file: 14 | - .env.secret 15 | restart: unless-stopped 16 | 17 | app_test: 18 | build: 19 | dockerfile: Dockerfile-test 20 | context: backend 21 | depends_on: 22 | - postgres_test 23 | ports: 24 | - "5005:5005" 25 | volumes: 26 | - ./backend:/usr/app 27 | restart: unless-stopped 28 | working_dir: /usr/app/backend_django 29 | command: ../server.sh 30 | # command: tail -F anything 31 | env_file: 32 | - .env.secret 33 | environment: 34 | POSTGRES_PASSWORD: "galv" 35 | DJANGO_SECRET_KEY: "long-and-insecure-key-12345" 36 | FRONTEND_VIRTUAL_HOST: "http://localhost" 37 | VIRTUAL_HOST: "localhost" 38 | POSTGRES_HOST: "postgres_test" 39 | 40 | harvester_test: 41 | build: 42 | dockerfile: Dockerfile 43 | context: ./harvester 44 | volumes: 45 | - ./harvester:/usr/harvester 46 | - ./.harvester:/harvester_files 47 | - "${GALV_HARVESTER_TEST_PATH}:/usr/test_data" 48 | working_dir: /usr 49 | command: python -m unittest discover -s /usr/harvester/test 50 | 51 | frontend_test: 52 | build: 53 | dockerfile: Dockerfile_dev 54 | context: ./frontend 55 | ports: 56 | - "3000:3000" 57 | volumes: 58 | - ./frontend:/app 59 | working_dir: /app 60 | command: > 61 | bash -c "yarn start" 62 | env_file: 63 | - ./.env 64 | restart: "no" 65 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | # docker-compose automatically loads contents of .env so we can refer to those here 6 | 7 | version: "2" 8 | services: 9 | app: 10 | image: app 11 | build: 12 | dockerfile: Dockerfile 13 | context: backend 14 | depends_on: 15 | - postgres 16 | volumes: 17 | - ./.static_files:/static 18 | working_dir: /usr/app 19 | environment: 20 | VIRTUAL_HOST: "api.${VIRTUAL_HOST_ROOT}" 21 | LETSENCRYPT_HOST: "api.${VIRTUAL_HOST_ROOT}" 22 | FRONTEND_VIRTUAL_HOST: "http://${VIRTUAL_HOST_ROOT},https://${VIRTUAL_HOST_ROOT}" 23 | env_file: 24 | - ./.env 25 | - ./.env.secret 26 | restart: unless-stopped 27 | command: ./server.sh 28 | 29 | frontend: 30 | image: frontend 31 | build: 32 | dockerfile: Dockerfile 33 | context: ./frontend 34 | args: 35 | VIRTUAL_HOST_ROOT: "${VIRTUAL_HOST_ROOT}" # Required to inject API root into conf.json 36 | depends_on: 37 | - app 38 | environment: 39 | VIRTUAL_HOST: "${VIRTUAL_HOST_ROOT}" 40 | LETSENCRYPT_HOST: "${VIRTUAL_HOST_ROOT}" 41 | env_file: 42 | - ./.env 43 | restart: unless-stopped 44 | 45 | postgres: 46 | image: "postgres:14" 47 | stop_signal: SIGINT # Fast Shutdown mode 48 | volumes: 49 | - "${GALV_DATA_PATH}:/var/lib/postgresql/data" 50 | env_file: 51 | - ./.env 52 | - ./.env.secret 53 | restart: unless-stopped 54 | 55 | nginx-proxy: 56 | build: nginx-proxy 57 | container_name: nginx-proxy 58 | restart: always 59 | ports: 60 | - "443:443" 61 | - "80:80" 62 | environment: 63 | TRUST_DOWNSTREAM_PROXY: "true" 64 | DEFAULT_HOST: "${VIRTUAL_HOST_ROOT}" 65 | volumes: 66 | - ./.static_files:/app/static 67 | - ./.certs:/etc/nginx/certs 68 | - ./.html:/usr/share/nginx/html 69 | - vhost:/etc/nginx/vhost.d 70 | - /var/run/docker.sock:/tmp/docker.sock:ro 71 | depends_on: 72 | - app 73 | - frontend 74 | 75 | nginx-proxy-acme-companion: 76 | image: nginxproxy/acme-companion 77 | env_file: 78 | - .env 79 | environment: 80 | NGINX_PROXY_CONTAINER: "nginx-proxy" 81 | volumes: 82 | - /var/run/docker.sock:/var/run/docker.sock:ro 83 | - ./.certs:/etc/nginx/certs 84 | - ./.html:/usr/share/nginx/html 85 | - vhost:/etc/nginx/vhost.d 86 | - acme:/etc/acme.sh 87 | depends_on: 88 | - nginx-proxy 89 | 90 | volumes: 91 | # certs: 92 | # html: 93 | vhost: 94 | acme: -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/README.txt: -------------------------------------------------------------------------------- 1 | Documentation is done with sphinx. 2 | 3 | Run documentation build from this directory with 4 | make html 5 | 6 | Results appear in ./build/html -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = 'Galv' 10 | copyright = '2023, Oxford RSE' 11 | author = 'Oxford RSE' 12 | release = '2.0.0' 13 | 14 | # -- General configuration --------------------------------------------------- 15 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 16 | 17 | extensions = [] 18 | 19 | templates_path = ['_templates'] 20 | exclude_patterns = [] 21 | 22 | 23 | 24 | # -- Options for HTML output ------------------------------------------------- 25 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 26 | 27 | html_theme = 'alabaster' 28 | html_static_path = ['_static'] 29 | -------------------------------------------------------------------------------- /docs/source/img/Galv-logo-lg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/docs/source/img/Galv-logo-lg.png -------------------------------------------------------------------------------- /docs/source/img/Galv-logo-shortened.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/docs/source/img/Galv-logo-shortened.png -------------------------------------------------------------------------------- /docs/source/img/Galv-logo-sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/docs/source/img/Galv-logo-sm.png -------------------------------------------------------------------------------- /docs/source/img/GalvStructure.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/docs/source/img/GalvStructure.PNG -------------------------------------------------------------------------------- /docs/source/img/Galv_DB_ERD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/docs/source/img/Galv_DB_ERD.png -------------------------------------------------------------------------------- /docs/source/img/galv_frontend.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/docs/source/img/galv_frontend.jpg -------------------------------------------------------------------------------- /docs/source/img/galv_frontend_v1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/docs/source/img/galv_frontend_v1.png -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Galv documentation master file, created by 2 | sphinx-quickstart on Thu Mar 9 11:40:09 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. image:: img/Galv-logo-lg.png 7 | 8 | Welcome to Galv's documentation! 9 | ====================================================================================== 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | :caption: Contents: 14 | 15 | FirstTimeQuickSetup 16 | UserGuide 17 | DevelopmentGuide 18 | 19 | 20 | 21 | Indices and tables 22 | ====================================================================================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | CHOKIDAR_USEPOLLING=true 2 | FAST_REFRESH=false 3 | SKIP_PREFLIGHT_CHECK=true -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | FROM node:lts@sha256:132309f5136555725debd57b711eb4b329fff22a00588834dbec391a3f9782cf as build 6 | 7 | ARG VIRTUAL_HOST_ROOT="localhost" 8 | ARG FORCE_HTTP="" 9 | 10 | RUN mkdir -p /app 11 | WORKDIR /app 12 | COPY package.json yarn.lock /app/ 13 | 14 | # Required to get react running: 15 | ENV NODE_OPTIONS=--openssl-legacy-provider 16 | 17 | RUN yarn install 18 | 19 | COPY . /app 20 | ENV VIRTUAL_HOST_ROOT=$VIRTUAL_HOST_ROOT 21 | ENV FORCE_HTTP=$FORCE_HTTP 22 | RUN ["/bin/sh", "-c", "./inject_envvars.sh"] 23 | RUN yarn build 24 | 25 | FROM nginx:alpine 26 | COPY --from=build /app/build /usr/share/nginx/html 27 | COPY --from=build /app/nginx.conf.template /etc/nginx/conf.d/custom.conf 28 | 29 | EXPOSE 80 30 | CMD ["/bin/sh" , "-c" , "exec nginx -g 'daemon off;'"] 31 | 32 | -------------------------------------------------------------------------------- /frontend/Dockerfile_dev: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | FROM node:lts@sha256:132309f5136555725debd57b711eb4b329fff22a00588834dbec391a3f9782cf 6 | 7 | ARG VIRTUAL_HOST_ROOT="localhost" 8 | ARG FORCE_HTTP="true" 9 | 10 | RUN mkdir -p /app 11 | WORKDIR /app 12 | COPY . /app 13 | 14 | # Required to get react running: 15 | ENV NODE_OPTIONS=--openssl-legacy-provider 16 | 17 | # Make react-scripts serve on desired port 18 | ENV PORT=80 19 | 20 | RUN yarn install 21 | 22 | ENV VIRTUAL_HOST_ROOT=$VIRTUAL_HOST_ROOT 23 | ENV FORCE_HTTP=$FORCE_HTTP 24 | # NB in dev mode we use a volume for the source code, and inject_envvars.sh won't override 25 | # the contents of conf.json. 26 | RUN ["/bin/sh", "-c", "./inject_envvars.sh"] 27 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `yarn build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | 22 | 23 | 32 | Galv 33 | 34 | 35 | 36 |
37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /frontend/inject_envvars.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # get some vars from env and write to json 3 | if [ -z "$FORCE_HTTP" ]; then 4 | API_ROOT="https://api.$VIRTUAL_HOST_ROOT/" 5 | else 6 | API_ROOT="http://api.$VIRTUAL_HOST_ROOT/" 7 | fi 8 | RUNTIME_CONF="{ 9 | \"API_ROOT\": \"$API_ROOT\" 10 | }" 11 | echo $RUNTIME_CONF > ./src/conf.json 12 | -------------------------------------------------------------------------------- /frontend/nginx.conf.template: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | error_log /var/log/nginx/error.log warn; 6 | 7 | server { 8 | listen 80; 9 | server_name localhost; 10 | 11 | client_max_body_size 100M; 12 | 13 | access_log /var/log/nginx/access.log main; 14 | error_log /var/log/nginx/error.log debug; 15 | 16 | location / { 17 | root /usr/share/nginx/html; 18 | index index.html index.htm; 19 | # Serve index for any route that doesn't have a file extension (e.g. /devices) 20 | # https://stackoverflow.com/a/45599233 21 | try_files $uri $uri/ /index.html; 22 | } 23 | 24 | #error_page 404 /404.html; 25 | 26 | # redirect server error pages to the static page /50x.html 27 | error_page 500 502 503 504 /50x.html; 28 | location = /50x.html { 29 | root /usr/share/nginx/html; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "proxy": "http://app", 6 | "dependencies": { 7 | "@emotion/react": "^11.10.5", 8 | "@emotion/styled": "^11.10.5", 9 | "@mui/icons-material": "^5.10.15", 10 | "@mui/lab": "^5.0.0-alpha.109", 11 | "@mui/material": "^5.10.15", 12 | "@mui/x-data-grid": "^5.17.12", 13 | "@nivo/core": "^0.80.0", 14 | "@nivo/line": "^0.80.0", 15 | "@testing-library/jest-dom": "^5.16.5", 16 | "@testing-library/react": "^14.0.0", 17 | "@testing-library/user-event": "^14.4.3", 18 | "@types/jest": "^29.2.5", 19 | "@types/node": "^18.11.18", 20 | "@types/react": "^18.0.26", 21 | "@types/react-dom": "^18.0.10", 22 | "classnames": "^2.3.2", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "react-hook-form": "^7.6.3", 26 | "react-router-dom": "^6.4.3", 27 | "react-scripts": "4.0.3", 28 | "react-syntax-highlighter": "^15.5.0", 29 | "react-token-auth": "^2.3.8", 30 | "tss-react": "^4.8.2", 31 | "typescript": "^4.9.4", 32 | "web-vitals": "^3.1.0" 33 | }, 34 | "devDependencies": { 35 | "react-styleguidist": "^13.0.0" 36 | }, 37 | "scripts": { 38 | "start": "react-scripts start", 39 | "build": "react-scripts build", 40 | "test": "react-scripts test --CI=true --watchAll=false --noStackTrace", 41 | "eject": "react-scripts eject", 42 | "styleguide": "styleguidist server", 43 | "styleguide:build": "styleguidist build" 44 | }, 45 | "eslintConfig": { 46 | "extends": [ 47 | "react-app", 48 | "react-app/jest" 49 | ] 50 | }, 51 | "browserslist": { 52 | "production": [ 53 | ">0.2%", 54 | "not dead", 55 | "not op_mini all" 56 | ], 57 | "development": [ 58 | "last 1 chrome version", 59 | "last 1 firefox version", 60 | "last 1 safari version" 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/public/Galvanalyser-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | 22 | 23 | 32 | Galv 33 | 34 | 35 | 36 |
37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/ActionButtons.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React, {Component} from "react"; 6 | import Stack, {StackProps} from "@mui/material/Stack"; 7 | import IconButton, {IconButtonProps} from "@mui/material/IconButton"; 8 | import Icon from "@mui/material/Icon"; 9 | import SearchIcon from "@mui/icons-material/Search"; 10 | import SaveIcon from "@mui/icons-material/Save"; 11 | import DeleteIcon from "@mui/icons-material/Delete"; 12 | import {SvgIconProps} from "@mui/material/SvgIcon" 13 | import { withStyles } from "tss-react/mui"; 14 | 15 | export type ActionButtonsProps = { 16 | classes: Record; 17 | onInspect?: () => void; 18 | onSave?: () => void; 19 | onDelete?: () => void; 20 | inspectButtonProps?: IconButtonProps; 21 | saveButtonProps?: IconButtonProps; 22 | deleteButtonProps?: IconButtonProps; 23 | inspectIconProps?: SvgIconProps & {component?: any}; 24 | saveIconProps?: SvgIconProps & {component?: any}; 25 | deleteIconProps?: SvgIconProps & {component?: any}; 26 | wrapperElementProps?: StackProps; 27 | } 28 | 29 | /** 30 | * Group together commonly displayed action buttons. 31 | * 32 | * Buttons included are: 33 | * - Inspect 34 | * - Save 35 | * - Delete 36 | * 37 | * Buttons are included where the on[ButtonName] property is specified. 38 | * Their properties can be customised with [buttonName]ButtonProps, 39 | * and the properties of the child SvgIcon by [buttonName]IconProps. 40 | * 41 | * The wrapper element is a element and 42 | * can be customised with the wrapperElementProps prop. 43 | */ 44 | class ActionButtons extends Component { 45 | render() { 46 | const classes = withStyles.getClasses(this.props); 47 | return ( 48 | 49 | { 50 | this.props.onInspect !== undefined && 51 | 56 | 62 | 63 | } 64 | { 65 | this.props.onSave !== undefined && 66 | 71 | 77 | 78 | } 79 | { 80 | this.props.onDelete !== undefined && 81 | 87 | 93 | 94 | } 95 | 96 | ) 97 | } 98 | } 99 | 100 | const StyledActionButtons = withStyles( 101 | ActionButtons, 102 | (theme, props) => ({ 103 | infoIcon: {}, 104 | saveIcon: {}, 105 | deleteIcon: {} 106 | }) 107 | ) 108 | 109 | export default StyledActionButtons -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | /* SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | */ 5 | 6 | .App { 7 | text-align: center; 8 | } 9 | 10 | .App-logo { 11 | height: 40vmin; 12 | pointer-events: none; 13 | } 14 | 15 | @media (prefers-reduced-motion: no-preference) { 16 | .App-logo { 17 | animation: App-logo-spin infinite 20s linear; 18 | } 19 | } 20 | 21 | .App-header { 22 | background-color: #282c34; 23 | min-height: 100vh; 24 | display: flex; 25 | flex-direction: column; 26 | align-items: center; 27 | justify-content: center; 28 | font-size: calc(10px + 2vmin); 29 | color: white; 30 | } 31 | 32 | .App-link { 33 | color: #61dafb; 34 | } 35 | 36 | @keyframes App-logo-spin { 37 | from { 38 | transform: rotate(0deg); 39 | } 40 | to { 41 | transform: rotate(360deg); 42 | } 43 | } -------------------------------------------------------------------------------- /frontend/src/ApproveUsers.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React, { Fragment } from 'react'; 6 | import Paper from '@mui/material/Paper'; 7 | import Container from '@mui/material/Container'; 8 | import HowToRegIcon from '@mui/icons-material/HowToReg'; 9 | import AsyncTable from './AsyncTable'; 10 | import Connection, {User} from "./APIConnection"; 11 | import useStyles from "./UseStyles"; 12 | import Typography from '@mui/material/Typography'; 13 | import IconButton from "@mui/material/IconButton"; 14 | 15 | const columns = [ 16 | {label: 'Username'}, 17 | {label: 'Approve', help: 'Authorize a user to access Galv'} 18 | ] 19 | 20 | export default function ApproveUsers() { 21 | const { classes } = useStyles(); 22 | 23 | const approveUser = (user: User) => Connection.fetch(`${user.url}vouch_for/`); 24 | 25 | return ( 26 | 27 | 28 | 29 | classes={classes} 30 | columns={columns} 31 | row_generator={(user, context) => [ 32 | 33 | {user.username} 34 | , 35 | 36 | approveUser(user).then(() => context.refresh_all_rows(false))} 40 | > 41 | 42 | 43 | 44 | ]} 45 | url={`inactive_users/`} 46 | styles={classes} 47 | /> 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/Equipment.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React, { Fragment } from 'react'; 6 | import TextField from '@mui/material/TextField'; 7 | import Paper from '@mui/material/Paper'; 8 | import AddIcon from '@mui/icons-material/Add'; 9 | import Container from '@mui/material/Container'; 10 | import SaveIcon from '@mui/icons-material/Save'; 11 | import AsyncTable from './AsyncTable'; 12 | import Connection from "./APIConnection"; 13 | import ActionButtons from "./ActionButtons"; 14 | import useStyles from "./UseStyles"; 15 | 16 | export type EquipmentFields = { 17 | url: string; 18 | id: number; 19 | name: string; 20 | type: string; 21 | in_use: boolean; 22 | } 23 | 24 | const columns = [ 25 | {label: 'Name', help: 'Equipment Name'}, 26 | {label: 'Type', help: 'Equipment Type'}, 27 | {label: 'Save', help: 'Save / Delete equipment. Edits are disabled for equipment that is in use'}, 28 | ] 29 | 30 | const string_fields = ['name', 'type'] as const 31 | 32 | export default function Equipment() { 33 | const { classes } = useStyles(); 34 | 35 | const get_write_data: (data: EquipmentFields) => Partial = (data) => { 36 | return { name: data.name, type: data.type } 37 | } 38 | 39 | const addNewEquipment = (data: EquipmentFields) => { 40 | const insert_data = get_write_data(data) 41 | return Connection.fetch('equipment/', {body: JSON.stringify(insert_data), method: 'POST'}) 42 | }; 43 | 44 | const updateEquipment = (data: EquipmentFields) => { 45 | const insert_data = get_write_data(data) 46 | return Connection.fetch(data.url, {body: JSON.stringify(insert_data), method: 'PATCH'}) 47 | .then(r => r.content) 48 | }; 49 | 50 | const deleteEquipment = (data: EquipmentFields) => Connection.fetch(data.url, {method: 'DELETE'}) 51 | 52 | return ( 53 | 54 | 55 | 56 | classes={classes} 57 | columns={columns} 58 | row_generator={(equipment, context) => [ 59 | ...string_fields.map(n => 60 | { 61 | equipment.in_use ? equipment[n] : 72 | } 73 | ), 74 | 75 | addNewEquipment(equipment).then(() => context.refresh_all_rows()) : 80 | () => updateEquipment(equipment).then(context.refresh) 81 | } 82 | saveButtonProps={{disabled: !context.value_changed || equipment.in_use}} 83 | saveIconProps={{component: context.is_new_row? AddIcon : SaveIcon}} 84 | onDelete={ 85 | () => 86 | window.confirm(`Delete equipment ${equipment.name}?`) && 87 | deleteEquipment(equipment).then(context.refresh) 88 | } 89 | deleteButtonProps={{disabled: context.is_new_row || equipment.in_use}} 90 | /> 91 | 92 | ]} 93 | new_row_values={{name: '', type: ''}} 94 | url={`equipment/`} 95 | styles={classes} 96 | /> 97 | 98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /frontend/src/Files.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React, { Fragment } from 'react'; 6 | import Container from '@mui/material/Container'; 7 | import Tooltip from "@mui/material/Tooltip"; 8 | import Typography from "@mui/material/Typography"; 9 | import Paper from '@mui/material/Paper'; 10 | import AsyncTable from './AsyncTable'; 11 | import Connection from "./APIConnection"; 12 | import {MonitoredPathFields} from "./MonitoredPaths"; 13 | import IconButton from "@mui/material/IconButton"; 14 | import RefreshIcon from "@mui/icons-material/Refresh"; 15 | import useStyles from "./UseStyles"; 16 | 17 | export type FileFields = { 18 | url: string; 19 | id: number; 20 | path: string; 21 | state: string; 22 | last_observed_time: string; 23 | last_observed_size: number; 24 | errors: { 25 | error: string; 26 | timestamp: string; 27 | [key: string]: any; 28 | } 29 | datasets: string[]; 30 | } 31 | 32 | export type FilesProps = { path: MonitoredPathFields } 33 | 34 | export default function Files(props: FilesProps) { 35 | const { classes } = useStyles(); 36 | 37 | const forceReimport = (file: FileFields) => Connection.fetch(`${file.url}reimport/`) 38 | 39 | const datetimeOptions: Intl.DateTimeFormatOptions = { 40 | year: 'numeric', month: 'numeric', day: 'numeric', 41 | hour: 'numeric', minute: 'numeric', second: 'numeric', 42 | }; 43 | 44 | const columns = [ 45 | {label: props.path.path, help: 'File path'}, 46 | {label: 'Last Observed Size', help: 'Size of the file in bytes'}, 47 | {label: 'Last Observed Time', help: 'Time file was last scanned'}, 48 | {label: 'State', help: 'File state'}, 49 | {label: 'Datasets', help: 'View datasets linked to this file'}, 50 | {label: 'Force Re-import', help: 'Retry the import operation next time the file is scanned'} 51 | ] 52 | 53 | const file_state = (file: FileFields) => { 54 | let state = file.state === 'IMPORT FAILED' ? 55 | {file.state} : {file.state} 56 | if (file.errors.length) 57 | state = {state} 58 | return state 59 | } 60 | 61 | return ( 62 | 63 | 64 | 65 | classes={classes} 66 | key="files" 67 | columns={columns} 68 | row_generator={(file, context) => [ 69 | {file.path}, 70 | {file.last_observed_size}, 71 | { 72 | Intl.DateTimeFormat('en-GB', datetimeOptions).format( 73 | Date.parse(file.last_observed_time) 74 | )} 75 | , 76 | {file_state(file)}, 77 | {file.datasets.length}, 78 | 79 | forceReimport(file).then(context.refresh)}> 82 | 83 | 84 | 85 | ]} 86 | url={`${props.path.url}files/`} 87 | styles={classes} 88 | /> 89 | 90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /frontend/src/FormComponents.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React from "react"; 6 | import TextField from '@mui/material/TextField'; 7 | import { makeStyles } from '@mui/styles' 8 | import InputLabel from '@mui/material/InputLabel'; 9 | import MenuItem from '@mui/material/MenuItem'; 10 | import Autocomplete from '@mui/lab/Autocomplete'; 11 | import FormControl from '@mui/material/FormControl'; 12 | import Select from '@mui/material/Select'; 13 | import { Controller } from "react-hook-form"; 14 | //import DateFnsUtils from '@date-io/date-fns'; // choose your lib 15 | //import { DateTimePicker } from '@mui/pickers'; 16 | import Chip from '@mui/material/Chip'; 17 | 18 | const useStyles = makeStyles((theme) => ({ 19 | container: { 20 | paddingTop: theme.spacing(4), 21 | paddingBottom: theme.spacing(4), 22 | height: '100%', 23 | }, 24 | formInput: { 25 | margin: theme.spacing(1), 26 | width: '100%', 27 | }, 28 | chips: { 29 | display: 'flex', 30 | flexWrap: 'wrap', 31 | }, 32 | chip: { 33 | margin: 2, 34 | }, 35 | })); 36 | 37 | //export function FormDateTimeField({control, name, defaultValue, label, ...rest}) { 38 | // const { classes } = useStyles(); 39 | // return ( 40 | // ( 45 | // 52 | // )} 53 | // /> 54 | // ) 55 | //} 56 | 57 | export function FormTextField({control, name, defaultValue, label, ...rest}) { 58 | const { classes } = useStyles(); 59 | return ( 60 | ( 65 | 71 | )} 72 | /> 73 | ) 74 | } 75 | 76 | export function FormMultiSelectField({control, name, defaultValue, label, options, ...rest}) { 77 | const { classes } = useStyles(); 78 | return ( 79 | 80 | 81 | {label} 82 | 83 | ( 88 | 111 | )} 112 | /> 113 | 114 | ) 115 | } 116 | 117 | export function FormAutocompleteField({control, name, defaultValue, label, options, ...rest}) { 118 | const { classes } = useStyles(); 119 | 120 | return ( 121 | ( 126 | 132 | 133 | } 134 | onChange={(_, data) => field.onChange(data)} 135 | /> 136 | )} 137 | /> 138 | ) 139 | } 140 | 141 | export function FormSelectField({control, name, defaultValue, label, options, ...rest}) { 142 | const { classes } = useStyles(); 143 | return ( 144 | 145 | 146 | {label} 147 | 148 | ( 153 | 164 | )} 165 | /> 166 | 167 | ) 168 | } 169 | -------------------------------------------------------------------------------- /frontend/src/Galv-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/GetDatasetMatlab.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import SyntaxHighlighter from 'react-syntax-highlighter'; 3 | import { docco } from 'react-syntax-highlighter/dist/cjs/styles/hljs'; 4 | import Typography from '@mui/material/Typography'; 5 | import Connection from "./APIConnection"; 6 | 7 | 8 | export default function GetDatasetMatlab({dataset}) { 9 | const token = Connection.user?.token; 10 | 11 | let domain = window.location.href.split('/')[2]; 12 | domain = domain.split(':')[0] 13 | 14 | const host = Connection.url || `${window.location.protocol}//api.${domain}/`; 15 | const codeString = `%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 16 | % 17 | % galv REST API access 18 | % - Matt Jaquiery (Oxford RSE) 19 | % 20 | % 2022-11-21 21 | % 22 | % Download datasets from the REST API. 23 | % Downloads all data for all columns for the dataset and reads them 24 | % into a cell array. Data are under datasets{x} as Tables. 25 | % Column names are coerced to valid MATLAB variable names using 26 | % matlab.lang.makeValidName. 27 | % 28 | % Dataset and column metadata are under dataset_metadata{x} and 29 | % column_metadata{x} respectively. 30 | % 31 | % SPDX-License-Identifier: BSD-2-Clause 32 | % Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 33 | % of Oxford, and the 'Galv' Developers. All rights reserved. 34 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 35 | 36 | % login to galv > Generate API Token 37 | token = '${token}'; 38 | apiURL = '${host}datasets'; 39 | options = weboptions('HeaderFields', {'Authorization' ['Bearer ' token]}); 40 | 41 | % Datasets are referenced by id. 42 | % You can add in additional dataset_names or dataset_ids to also 43 | % fetch the contents of those datasets. 44 | dataset_ids = [${dataset.id}]; % add additional dataset ids here if required 45 | 46 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 47 | n = max(dataset_ids); 48 | dataset_metadata = cell(n, 1); 49 | column_metadata = cell(n, 1); 50 | datasets = cell(n, 1); 51 | 52 | dataset_ids = unique(dataset_ids); 53 | 54 | for i = 1:length(dataset_ids) 55 | d = dataset_ids(i); 56 | 57 | % get data 58 | dsURL = strcat(apiURL, '/', num2str(d), '/'); 59 | meta = webread(dsURL, options); 60 | dataset_metadata{d} = meta; 61 | 62 | column_metadata{i} = cell(length(meta.columns), 1); 63 | datasets{i} = table(); 64 | 65 | % append column data in columns 66 | for c = 1:length(meta.columns) 67 | cURL = meta.columns{c}; 68 | stream = webread(cURL, options); 69 | column_metadata{i}{c} = stream; 70 | column_content = webread(stream.values, options); 71 | % drop final newline 72 | column_content = regexprep(column_content, '\n$', ''); 73 | column_content = strsplit(column_content, '\n'); 74 | column_content = arrayfun(@(c) str2num(c{1}), column_content); 75 | datasets{i}.(matlab.lang.makeValidName(stream.name)) = rot90(column_content, -1); 76 | end 77 | end 78 | ` 79 | 80 | return ( 81 | 82 | 83 | 84 | MATLAB Code 85 | 86 | 87 | 88 | {codeString} 89 | 90 | 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /frontend/src/GetDatasetPython.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from "react"; 2 | import SyntaxHighlighter from 'react-syntax-highlighter'; 3 | import { docco } from 'react-syntax-highlighter/dist/cjs/styles/hljs'; 4 | import Typography from '@mui/material/Typography'; 5 | import Connection from "./APIConnection"; 6 | import {CircularProgress} from "@mui/material"; 7 | 8 | 9 | export default function GetDatasetPython({dataset}) { 10 | const token = Connection.user?.token; 11 | const [columns, setColumns] = useState("") 12 | const [code, setCode] = useState() 13 | 14 | let domain = window.location.href.split('/')[2]; 15 | domain = domain.split(':')[0] 16 | 17 | const host = `http://api.${domain}` 18 | 19 | useEffect(() => { 20 | Promise.all(dataset.columns.map(column => 21 | Connection.fetch(column) 22 | .then(r => r.content) 23 | .then(col => ` '${col.name}': ${col.id},`) 24 | )) 25 | .then(cols => setColumns(cols.join('\n'))) 26 | }, [dataset]) 27 | 28 | useEffect(() => { 29 | if (!columns) 30 | setCode() 31 | else 32 | setCode( 33 | { 34 | `# SPDX-License-Identifier: BSD-2-Clause 35 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 36 | # of Oxford, and the 'Galv' Developers. All rights reserved. 37 | 38 | # By Matt Jaquiery 39 | 40 | # Download datasets from the REST API. 41 | # Downloads all data for all columns for the dataset and reads them 42 | # into a Dict object. Data are under datasets[x] as DataFrames. 43 | # 44 | # Dataset and column metadata are under dataset_metadata[x] and 45 | # column_metadata[x] respectively. 46 | 47 | import urllib3 # install via pip if not available 48 | 49 | host = "${host}" 50 | headers = {'Authorization': 'Bearer ${token}'} 51 | 52 | # Configuration 53 | verbose = True 54 | if verbose: 55 | import time 56 | 57 | # Add additional dataset ids to download additional datasets 58 | dataset_ids = [${dataset.id}] 59 | dataset_metadata = {} # Will have keys=dataset_id, values=Dict of dataset metadata 60 | column_metadata = {} # Will have keys=dataset_id, values=Dict of column metadata 61 | datasets = {} # Will have keys=dataset_id, values=pandas DataFrame of data 62 | 63 | # Download data 64 | start_time = time.time() 65 | if verbose: 66 | print(f"Downloading {len(dataset_ids)} datasets from {host}") 67 | 68 | for dataset_id in dataset_ids: 69 | dataset_start_time = time.time() 70 | if verbose: 71 | print(f"Downloading dataset {dataset_id}") 72 | r = urllib3.request('GET', f"{host}/datasets/{dataset_id}/", headers=headers) 73 | try: 74 | json = r.json() 75 | except: 76 | print(f"Non-JSON response while downloading {dataset_id}: {r.status}") 77 | continue 78 | if r.status != 200: 79 | print(f"Error downloading dataset {dataset_id}: {r.status}") 80 | continue 81 | columns = json.get('columns', []) 82 | json['columns'] = [] 83 | 84 | dataset_metadata[dataset_id] = json 85 | 86 | if verbose: 87 | print(f"Dataset {dataset_id} has {len(columns)} columns to download") 88 | # Download the data from all columns in the dataset 89 | datasets[dataset_id] = pandas.DataFrame() 90 | for i, column in enumerate(columns): 91 | if verbose: 92 | print(f"Downloading dataset {dataset_id} column {i}") 93 | r = urllib3.request('GET', column, headers=headers) 94 | try: 95 | json = r.json() 96 | except: 97 | print(f"Non-JSON response while downloading from {column}: {r.status}") 98 | continue 99 | if r.status != 200: 100 | print(f"Error downloading column {dataset_id}: {r.status}") 101 | continue 102 | 103 | # Download the data from all rows in the column 104 | v = urllib3.request('GET', json.get('values'), headers=headers) 105 | if v.status != 200: 106 | print(f"Error downloading values for dataset {dataset_id} column {json.get('name')}: {v.status}") 107 | continue 108 | try: 109 | datasets[dataset_id][json.get('name')] = v.data.decode('utf-8').split('\\n') 110 | except: 111 | print(f"Cannot translate JSON response into DataFrame for column values {json.get['values']}") 112 | continue 113 | 114 | column_metadata[dataset_id] = json 115 | 116 | if verbose: 117 | print(f"Finished downloading dataset {dataset_id} in {time.time() - dataset_start_time} seconds") 118 | 119 | if verbose: 120 | print(f"Finished downloading {len(dataset_ids)} datasets in {time.time() - start_time} seconds") 121 | 122 | ` 123 | } 124 | ) 125 | }, [columns, dataset.id, host, token]) 126 | 127 | return ( 128 | 129 | 130 | Python Code 131 | 132 | 133 | {code} 134 | 135 | ) 136 | } 137 | -------------------------------------------------------------------------------- /frontend/src/PaginatedTable.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | export type unused = any 6 | // /** 7 | // * Offer a table with previous/next links. 8 | // */ 9 | // import classNames from 'classnames' 10 | // import React, {Component, ReactComponentElement} from "react"; 11 | // import TableContainer from "@mui/material/TableContainer"; 12 | // import Table from "@mui/material/Table"; 13 | // import TableBody from "@mui/material/TableBody"; 14 | // import Button from '@mui/material/Button'; 15 | // import Connection, {APIResponse, APIObject, SingleAPIResponse} from "./APIConnection"; 16 | // import TableRow from "@mui/material/TableRow"; 17 | // import TableCell, {TableCellProps} from "@mui/material/TableCell"; 18 | // import LoadingButton from "@mui/lab/LoadingButton"; 19 | // import Tooltip, {TooltipProps} from "@mui/material/Tooltip"; 20 | // import Typography, {TypographyProps} from "@mui/material/Typography"; 21 | // import TableHead from "@mui/material/TableHead"; 22 | // import AsyncTable from "./AsyncTable"; 23 | // 24 | // type PaginatedTableProps = { 25 | // initial_url: string; 26 | // } 27 | // 28 | // type PaginatedTableState = { 29 | // links: PaginationLinks; 30 | // current_url: string; 31 | // } 32 | // 33 | // export type PaginationLinks = { 34 | // previous?: string | null, 35 | // next?: string | null, 36 | // } 37 | // 38 | // type PaginationDataFun = (url?: string | null) => APIResponse 39 | // 40 | // type PaginationProps = PaginationLinks & { 41 | // get_data_fun: PaginationDataFun; 42 | // position: string; 43 | // } 44 | // 45 | // 46 | // class Pagination extends AsyncTable { 47 | // render() { 48 | // return ( 49 | //
54 | // 63 | // 73 | //
74 | // ) 75 | // } 76 | // } 77 | // 78 | // export default class PaginatedTable extends Component { 79 | // 80 | // state: PaginatedTableState = { 81 | // links: {previous: null, next: null}, 82 | // current_url: "" 83 | // } 84 | // 85 | // constructor(props: PaginatedTableProps) { 86 | // super(props) 87 | // if (!this.state.current_url) this.state.current_url = props.initial_url 88 | // } 89 | // 90 | // componentDidMount() { 91 | // this.get_data(this.props.initial_url) 92 | // console.log("Mounted PaginatedTable", this) 93 | // } 94 | // 95 | // render() { 96 | // return ( 97 | //
98 | // 105 | // 106 | // 113 | //
114 | // ) 115 | // } 116 | // } -------------------------------------------------------------------------------- /frontend/src/UseStyles.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import { makeStyles } from 'tss-react/mui'; 6 | 7 | export default makeStyles()((theme) => { 8 | return { 9 | button: { 10 | margin: theme.spacing(1), 11 | }, 12 | chips: { 13 | display: 'flex', 14 | flexWrap: 'wrap', 15 | }, 16 | chip: { 17 | margin: 2, 18 | }, 19 | container: { 20 | paddingTop: theme.spacing(4), 21 | paddingBottom: theme.spacing(4), 22 | }, 23 | deleteIcon: { 24 | "&:hover": {color: theme.palette.error.light}, 25 | "&:focus": {color: theme.palette.error.light} 26 | }, 27 | head: { 28 | backgroundColor: theme.palette.primary.light, 29 | }, 30 | headCell: { 31 | color: theme.palette.common.black, 32 | }, 33 | iconButton: { 34 | padding: 10, 35 | }, 36 | infoIcon: { 37 | "&:hover": {color: theme.palette.info.light}, 38 | "&:focus": {color: theme.palette.info.light} 39 | }, 40 | input: { 41 | marginLeft: theme.spacing(0), 42 | flex: 1, 43 | }, 44 | inputAdornment: { 45 | color: theme.palette.text.disabled, 46 | }, 47 | newTableCell: {paddingTop: theme.spacing(4)}, 48 | newTableRow: {}, 49 | paper: {marginBottom: theme.spacing(2)}, 50 | refreshIcon: { 51 | "&:hover": {color: theme.palette.warning.light}, 52 | "&:focus": {color: theme.palette.warning.light} 53 | }, 54 | resize: { 55 | fontSize: '10pt', 56 | }, 57 | saveIcon: { 58 | "&:hover": {color: theme.palette.success.light}, 59 | "&:focus": {color: theme.palette.success.light} 60 | }, 61 | table: { 62 | minWidth: 650, 63 | }, 64 | } 65 | }); 66 | -------------------------------------------------------------------------------- /frontend/src/UserProfile.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React, {useState} from 'react'; 6 | import TextField from '@mui/material/TextField'; 7 | import Paper from '@mui/material/Paper'; 8 | import Container from '@mui/material/Container'; 9 | import Connection, {APIMessage} from "./APIConnection"; 10 | import useStyles from "./UseStyles"; 11 | import Typography from "@mui/material/Typography"; 12 | import Stack from "@mui/material/Stack"; 13 | import Button from "@mui/material/Button"; 14 | import Alert from "@mui/material/Alert"; 15 | import Snackbar from "@mui/material/Snackbar"; 16 | 17 | export default function UserProfile() { 18 | const { classes } = useStyles(); 19 | const [email, setEmail] = useState(Connection.user?.email || '') 20 | const [password, setPassword] = useState('') 21 | const [currentPassword, setCurrentPassword] = useState('') 22 | const [updateResult, setUpdateResult] = useState() 23 | const [open, setOpen] = useState(false) 24 | 25 | const updateUser = () => Connection.update_user(email, password, currentPassword) 26 | .then(setUpdateResult) 27 | .then(() => { 28 | setOpen(true) 29 | setEmail(Connection.user?.email || '') 30 | setPassword('') 31 | setCurrentPassword('') 32 | }) 33 | 34 | const handleClose = (e: any, reason?: string) => { 35 | if (reason !== 'clickaway') 36 | setOpen(false) 37 | } 38 | 39 | return ( 40 | 41 | 42 | 43 | {Connection.user?.username} profile 44 | setEmail(e.target.value)} 54 | /> 55 | setPassword(e.target.value)} 67 | error={password !== undefined && password.length > 0 && password.length < 8} 68 | /> 69 | setCurrentPassword(e.target.value)} 81 | /> 82 | 91 | 92 | 93 | 94 | {updateResult?.message} 95 | 96 | 97 | 98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /frontend/src/__mocks__/CellList.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React from "react"; 6 | import {CellDetailProps} from "../CellList"; 7 | 8 | export default function DummyCellList(props: CellDetailProps) { 9 | return ( 10 |
11 |

MockCellList

12 |

{JSON.stringify(props)}

13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/__mocks__/DatasetChart.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React from "react"; 6 | import {DatasetChartProps} from "../DatasetChart"; 7 | 8 | export default function DummyDatasetChart(props: DatasetChartProps) { 9 | return ( 10 |
11 |

MockDatasetChart

12 |

{JSON.stringify(props)}

13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/__mocks__/Files.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React from "react"; 6 | import {FilesProps} from "../Files"; 7 | 8 | export default function DummyFiles(props: FilesProps) { 9 | return ( 10 |
11 |

Files

12 |

{JSON.stringify(props)}

13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/__mocks__/GetDatasetJulia.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React from "react"; 6 | 7 | export default function DummyGetDatasetJulia({dataset}) { 8 | return ( 9 |
10 |

GetDatasetJulia

11 |

{JSON.stringify({dataset})}

12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/__mocks__/GetDatasetMatlab.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React from "react"; 6 | 7 | export default function DummyGetDatasetMatlab({dataset}) { 8 | return ( 9 |
10 |

GetDatasetMatlab

11 |

{JSON.stringify({dataset})}

12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/__mocks__/GetDatasetPython.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React from "react"; 6 | 7 | export default function DummyGetDatasetPython({dataset}) { 8 | return ( 9 |
10 |

GetDatasetPython

11 |

{JSON.stringify({dataset})}

12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/__mocks__/HarvesterEnv.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React from "react"; 6 | import {HarvesterEnvProps} from "../HarvesterEnv"; 7 | 8 | export default function DummyHarvesterEnv(props: HarvesterEnvProps) { 9 | return ( 10 |
11 |

MockHarvesterEnv

12 |

{JSON.stringify(props)}

13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/__mocks__/MonitoredPaths.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React from "react"; 6 | import {MonitoredPathProps} from "../MonitoredPaths"; 7 | 8 | export default function DummyHarvesterDetail(props: MonitoredPathProps) { 9 | return ( 10 |
11 |

MockMonitoredPaths

12 |

{JSON.stringify(props)}

13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/__mocks__/UserRoleSet.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React from "react"; 6 | import {UserSetProps, UserSet} from "../UserRoleSet"; 7 | 8 | export const user_in_sets = (sets: UserSet[]) => true 9 | 10 | export default function DummyUserRoleSet(props: UserSetProps) { 11 | return ( 12 |
13 |

UserRoleSet

14 |

{JSON.stringify(props)}

15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/auth/index.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import {createAuthProvider} from 'react-token-auth'; 6 | 7 | 8 | export const [useAuth, authFetch, login, logout] = 9 | createAuthProvider({ 10 | accessTokenKey: 'access_token', 11 | onUpdateToken: (token) => fetch( 12 | '/api-auth/refresh', { 13 | method: 'POST', 14 | body: token.access_token, 15 | headers: {'Content-Type': 'application/json'} 16 | } 17 | ).then((response) => { 18 | if (response.ok) { 19 | console.log('refresh good'); 20 | return response.json(); 21 | } 22 | console.log('refresh failed'); 23 | }) 24 | }); 25 | -------------------------------------------------------------------------------- /frontend/src/conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "API_ROOT": "http://api.localhost/" 3 | } -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 4 | // of Oxford, and the 'Galv' Developers. All rights reserved. 5 | */ 6 | 7 | body { 8 | margin: 0; 9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 10 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 11 | sans-serif; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | code { 17 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 18 | monospace; 19 | } 20 | 21 | /* Override .MuiPopover-paper because we can't do it inside the UserRoleSet component */ 22 | .MuiPopover-paper { 23 | overflow: visible; 24 | } -------------------------------------------------------------------------------- /frontend/src/index.d.ts: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | // Prevent complaints that 'spacing' isn't a DefaultTheme property 6 | declare module "@mui/private-theming" { 7 | import type { Theme } from "@mui/material/styles"; 8 | 9 | interface DefaultTheme extends Theme {} 10 | } -------------------------------------------------------------------------------- /frontend/src/index.jsx: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import React from 'react'; 6 | import { createRoot } from 'react-dom/client'; 7 | import './index.css'; 8 | import App from './App'; 9 | import reportWebVitals from './reportWebVitals'; 10 | import { BrowserRouter as Router } from 'react-router-dom' 11 | 12 | const root = createRoot(document.getElementById('root')) 13 | 14 | if (module.hot) { 15 | module.hot.accept(); 16 | } 17 | 18 | root.render( 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | 26 | // If you want to start measuring performance in your app, pass a function 27 | // to log results (for example: reportWebVitals(console.log)) 28 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 29 | reportWebVitals(); 30 | -------------------------------------------------------------------------------- /frontend/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | const reportWebVitals = onPerfEntry => { 6 | if (onPerfEntry && onPerfEntry instanceof Function) { 7 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 8 | getCLS(onPerfEntry); 9 | getFID(onPerfEntry); 10 | getFCP(onPerfEntry); 11 | getLCP(onPerfEntry); 12 | getTTFB(onPerfEntry); 13 | }); 14 | } 15 | }; 16 | 17 | export default reportWebVitals; 18 | -------------------------------------------------------------------------------- /frontend/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 6 | // allows you to do things like: 7 | // expect(element).toHaveTextContent(/react/i) 8 | // learn more: https://github.com/testing-library/jest-dom 9 | import '@testing-library/jest-dom'; 10 | -------------------------------------------------------------------------------- /frontend/src/test/ActionButtons.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 6 | 7 | import React from 'react'; 8 | import { act, render, screen } from '@testing-library/react'; 9 | import userEvent from '@testing-library/user-event'; 10 | import ActionButtons from '../ActionButtons'; 11 | 12 | var mock_inspect = jest.fn() 13 | var mock_save = jest.fn() 14 | var mock_delete = jest.fn() 15 | 16 | describe('ActionButtons', () => { 17 | const user = userEvent.setup(); 18 | 19 | it('defaults to disabled buttons', async () => { 20 | await act(async () => render()); 21 | expect(screen.queryByRole('button', {label: /^Inspect$/})).toBeNull(); 22 | expect(screen.queryByRole('button', {label: /^Save$/})).toBeNull(); 23 | expect(screen.queryByRole('button', {label: /^Delete$/})).toBeNull(); 24 | }); 25 | 26 | it('calls the inspect function when clicked', async () => { 27 | await act(async () => render()); 28 | await act(async () => await user.click(screen.queryByRole('button', {label: /^Inspect$/}))); 29 | expect(mock_inspect).toHaveBeenCalled(); 30 | }); 31 | 32 | it('calls the save function when clicked', async () => { 33 | await act(async () => render()); 34 | await act(async () => await user.click(screen.queryByRole('button', {label: /^Save$/}))); 35 | expect(mock_save).toHaveBeenCalled(); 36 | }); 37 | 38 | it('calls the delete function when clicked', async () => { 39 | await act(async () => render()); 40 | await act(async () => await user.click(screen.queryByRole('button', {label: /^Delete$/}))); 41 | expect(mock_delete).toHaveBeenCalled(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /frontend/src/test/ActivateUsers.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | 6 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 7 | 8 | import React from 'react'; 9 | import { act, render, screen } from '@testing-library/react'; 10 | import userEvent from '@testing-library/user-event'; 11 | import Connection from "../APIConnection"; 12 | import ApproveUsers from "../ApproveUsers"; 13 | import mock_users from './fixtures/inactive_users.json'; 14 | 15 | // Mock the APIConnection.fetch function from the APIConnection module 16 | // This is because we don't want to actually make API calls in our tests 17 | // We just want to check that the correct calls are made 18 | const mocked_fetch = jest.spyOn(Connection, 'fetch') 19 | const mocked_fetchMany = jest.spyOn(Connection, 'fetchMany') 20 | 21 | it('ApproveUsers has appropriate columns', async () => { 22 | mocked_fetchMany.mockResolvedValue([]); 23 | await act(async () => render()); 24 | expect(screen.getAllByRole('columnheader').find(e => /Username/.test(e.textContent))).toBeInTheDocument(); 25 | expect(screen.getAllByRole('columnheader').find(e => /Approve/.test(e.textContent))).toBeInTheDocument(); 26 | }) 27 | 28 | describe('ApproveUsers', () => { 29 | let container; 30 | const user = userEvent.setup(); 31 | 32 | beforeEach(async () => { 33 | mocked_fetchMany.mockResolvedValue(mock_users.map(u => ({content: u}))); 34 | mocked_fetch.mockResolvedValue(null) 35 | let container 36 | await act(async () => container = render().container); 37 | }) 38 | 39 | it('has appropriate values', async () => { 40 | expect(mocked_fetchMany).toHaveBeenCalledWith( 41 | `inactive_users/`, 42 | {}, 43 | false 44 | ) 45 | 46 | expect(screen.getAllByRole('cell').find(e => e.textContent === mock_users[0].username)).toBeInTheDocument(); 47 | }); 48 | 49 | it('sends an API call when user is approved', async () => { 50 | await user.click(screen.getAllByRole('button', {label: 'approve'})[0]); 51 | expect(mocked_fetch).toHaveBeenCalledWith(`${mock_users[0].url}vouch_for/`) 52 | expect(mocked_fetchMany).toHaveBeenCalledTimes(2) 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /frontend/src/test/CellList.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 6 | 7 | import React from 'react'; 8 | import { act, render, screen } from '@testing-library/react'; 9 | import userEvent from '@testing-library/user-event'; 10 | import Connection from "../APIConnection"; 11 | import mock_cells from './fixtures/cells.json'; 12 | import mock_cell_families from './fixtures/cell_families.json'; 13 | 14 | const mock_cell_family = mock_cell_families.find(f => f.url === mock_cells[0].family) 15 | const CellList = jest.requireActual('../CellList').default; 16 | 17 | // Mock the APIConnection.fetch function from the APIConnection module 18 | // This is because we don't want to actually make API calls in our tests 19 | // We just want to check that the correct calls are made 20 | const mocked_fetch = jest.spyOn(Connection, 'fetch') 21 | const mocked_fetchMany = jest.spyOn(Connection, 'fetchMany') 22 | const mocked_login = jest.spyOn(Connection, 'login') 23 | 24 | it('CellList has appropriate columns', async () => { 25 | mocked_fetchMany.mockResolvedValue([]); 26 | await act(async () => render()); 27 | expect(screen.getAllByRole('columnheader').find(element => /UID/.test(element.textContent))).toBeInTheDocument() 28 | expect(screen.getAllByRole('columnheader').find(element => /Display Name/.test(element.textContent))).toBeInTheDocument() 29 | expect(screen.getAllByRole('columnheader').find(element => /Linked Datasets/.test(element.textContent))).toBeInTheDocument() 30 | expect(screen.getAllByRole('columnheader').find(element => /Actions/.test(element.textContent))).toBeInTheDocument() 31 | }) 32 | 33 | describe('CellList', () => { 34 | let container; 35 | const user = userEvent.setup(); 36 | 37 | beforeEach(async () => { 38 | mocked_login.mockImplementation(() => Connection.user = {username: 'admin'}) 39 | await Connection.login() 40 | // The mock implementation needs to occur here; if it's done outside it doesn't work! 41 | mocked_fetchMany.mockResolvedValue(mock_cells.map(h => ({content: h}))); 42 | mocked_fetch.mockImplementation(request => new Promise(resolve => ({content: mock_cells.find(h => h.url === request)}))); 43 | let container 44 | await act(async () => container = render().container); 45 | }) 46 | 47 | it('has appropriate values', async () => { 48 | expect(mocked_fetchMany).toHaveBeenCalledWith( 49 | 'cells/', 50 | {}, 51 | false 52 | ) 53 | 54 | expect(await screen.findByDisplayValue(mock_cells[0].uid)).toBeInTheDocument(); 55 | }); 56 | 57 | it('sends an API call when created', async () => { 58 | const n = mock_cells.length 59 | await act(async () => { 60 | await user.type(screen.getAllByRole('textbox', {name: /UID/i})[n], 'abc-123') 61 | await user.click(screen.getAllByRole('button', {name: /Save/i})[n]); 62 | }) 63 | expect(mocked_fetch).toHaveBeenCalledWith( 64 | 'cells/', 65 | { 66 | body: JSON.stringify({ 67 | uid: 'abc-123', 68 | family: mock_cell_family.url 69 | }), 70 | method: 'POST' 71 | } 72 | ) 73 | }) 74 | 75 | it('sends an update API call when saved', async () => { 76 | await act(async () => { 77 | const name = await screen.findByDisplayValue(mock_cells[0].uid) 78 | await user.type(name, '{Backspace>20}xyz-098') 79 | await user.click(await screen.getAllByLabelText(/^Save$/)[0]) 80 | }); 81 | expect(mocked_fetch).toHaveBeenCalledWith( 82 | mock_cells[0].url, 83 | { 84 | body: JSON.stringify({ 85 | uid: 'xyz-098', 86 | display_name: mock_cells[0].display_name, 87 | family: mock_cell_family.url 88 | }), 89 | method: 'PATCH' 90 | } 91 | ) 92 | }); 93 | 94 | it('sends an API call when deleted', async () => { 95 | window.confirm = jest.fn(() => true); 96 | await act(async () => await user.click(screen.getAllByTestId(/DeleteIcon/)[0])); 97 | expect(window.confirm).toHaveBeenCalledWith(`Delete cell ${mock_cells[0].display_name}?`); 98 | expect(mocked_fetch).toHaveBeenCalledWith( 99 | mock_cells[0].url, 100 | { 101 | method: 'DELETE' 102 | } 103 | ) 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /frontend/src/test/DatasetChart.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 6 | 7 | import React from 'react'; 8 | import {render, screen, waitFor} from '@testing-library/react'; 9 | import userEvent from '@testing-library/user-event'; 10 | import Connection from "../APIConnection"; 11 | import mock_datasets from './fixtures/datasets.json'; 12 | import mock_columns from './fixtures/columns.json'; 13 | const DatasetChart = jest.requireActual('../DatasetChart').default; 14 | 15 | var mock_dataset = mock_datasets[1] 16 | var mock_data = "" 17 | for (let i = 0; i < 20; i++) 18 | mock_data += "0\n" 19 | // Mock the APIConnection.fetch function from the APIConnection module 20 | // This is because we don't want to actually make API calls in our tests 21 | // We just want to check that the correct calls are made 22 | const mocked = jest.spyOn(Connection, 'fetch') 23 | const mockedRaw = jest.spyOn(Connection, 'fetchRaw') 24 | 25 | describe.skip('DatasetChart', () => { 26 | let container; 27 | const user = userEvent.setup(); 28 | 29 | beforeEach(() => { 30 | // The mock implementation needs to occur here; if it's done outside it doesn't work! 31 | mocked.mockImplementation((url) => { 32 | return new Promise((resolve) => ({content: mock_columns.find(c => c.url === url)})) 33 | }); 34 | mockedRaw.mockImplementation((url) => { 35 | return new Promise((resolve) => new ReadableStream( 36 | { 37 | start(controller) { 38 | controller.enqueue(JSON.stringify(mock_data)) 39 | controller.close() 40 | } 41 | } 42 | )) 43 | }); 44 | container = render( 45 | 46 | ).container; 47 | }) 48 | 49 | it('has appropriate columns', async () => { 50 | await new Promise((resolve) => setTimeout(resolve, 3000)) 51 | screen.debug(undefined, 200000000) 52 | }); 53 | }) 54 | -------------------------------------------------------------------------------- /frontend/src/test/Datasets.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 6 | 7 | import React from 'react'; 8 | import { act, render, screen } from '@testing-library/react'; 9 | import userEvent from '@testing-library/user-event'; 10 | import Connection from "../APIConnection"; 11 | import mock_datasets from './fixtures/datasets.json'; 12 | const Datasets = jest.requireActual('../Datasets').default; 13 | 14 | jest.mock('../DatasetChart') 15 | 16 | // Mock the APIConnection.fetch function from the APIConnection module 17 | // This is because we don't want to actually make API calls in our tests 18 | // We just want to check that the correct calls are made 19 | const mocked_fetch = jest.spyOn(Connection, 'fetch') 20 | const mocked_fetchMany = jest.spyOn(Connection, 'fetchMany') 21 | const mocked_login = jest.spyOn(Connection, 'login') 22 | 23 | it('Datasets has appropriate columns', async () => { 24 | mocked_fetchMany.mockResolvedValue([]); 25 | await act(async () => render()); 26 | expect(screen.getAllByRole('columnheader').find(e => /Date/.test(e.textContent))).toBeInTheDocument(); 27 | expect(screen.getAllByRole('columnheader').find(e => /Properties/.test(e.textContent))).toBeInTheDocument(); 28 | expect(screen.getAllByRole('columnheader').find(e => /Equipment/.test(e.textContent))).toBeInTheDocument(); 29 | expect(screen.getAllByRole('columnheader').find(e => /Actions/.test(e.textContent))).toBeInTheDocument(); 30 | }) 31 | 32 | describe('Datasets', () => { 33 | let container; 34 | const user = userEvent.setup(); 35 | 36 | beforeEach(async () => { 37 | mocked_login.mockImplementation(() => Connection.user = {username: 'admin'}) 38 | await Connection.login() 39 | // The mock implementation needs to occur here; if it's done outside it doesn't work! 40 | mocked_fetchMany.mockResolvedValue(mock_datasets.map(h => ({content: h}))); 41 | mocked_fetch.mockImplementation(request => new Promise(resolve => ({content: mock_datasets.find(h => h.url === request)}))); 42 | let container 43 | await act(async () => container = render().container); 44 | }) 45 | 46 | it('has appropriate values', async () => { 47 | expect(mocked_fetchMany).toHaveBeenCalledWith( 48 | 'datasets/', 49 | {}, 50 | false 51 | ) 52 | 53 | expect(await screen.findByDisplayValue(mock_datasets[0].name)).toBeInTheDocument(); 54 | }); 55 | 56 | it('spawns child components when the button is clicked', async () => { 57 | await act(async () => await user.click(screen.getAllByTestId(/SearchIcon/)[0])); 58 | expect(await screen.findByText(/MockDatasetChart/)).toBeInTheDocument(); 59 | }); 60 | 61 | it('sends an update API call when saved', async () => { 62 | const name = screen.getByDisplayValue(mock_datasets[0].name) 63 | await user.clear(name) 64 | await user.type(name, 'T') 65 | await user.click(await screen.getAllByRole('button').find(e => /^Save$/.test(e.textContent))) 66 | expect(mocked_fetch).toHaveBeenCalledWith( 67 | mock_datasets[0].url, 68 | { 69 | body: JSON.stringify({ 70 | name: "T", 71 | type: mock_datasets[0].type, 72 | cell: mock_datasets[0].cell, 73 | equipment: mock_datasets[0].equipment 74 | }), 75 | method: 'PATCH' 76 | } 77 | ) 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /frontend/src/test/Equipment.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 6 | 7 | import React from 'react'; 8 | import { act, render, screen } from '@testing-library/react'; 9 | import userEvent from '@testing-library/user-event'; 10 | import Connection from "../APIConnection"; 11 | import mock_equipment from './fixtures/equipment.json'; 12 | const Equipment = jest.requireActual('../Equipment').default; 13 | 14 | // Mock the APIConnection.fetch function from the APIConnection module 15 | // This is because we don't want to actually make API calls in our tests 16 | // We just want to check that the correct calls are made 17 | const mocked_fetch = jest.spyOn(Connection, 'fetch') 18 | const mocked_fetchMany = jest.spyOn(Connection, 'fetchMany') 19 | const mocked_login = jest.spyOn(Connection, 'login') 20 | 21 | it('Equipment has appropriate columns', async () => { 22 | mocked_fetchMany.mockResolvedValue([]); 23 | await act(async () => render()); 24 | expect(screen.getAllByRole('columnheader').find(e => /Name/.test(e.textContent))).toBeInTheDocument(); 25 | expect(screen.getAllByRole('columnheader').find(e => /Type/.test(e.textContent))).toBeInTheDocument(); 26 | expect(screen.getAllByRole('columnheader').find(e => /Save/.test(e.textContent))).toBeInTheDocument(); 27 | }) 28 | 29 | describe('Equipment', () => { 30 | let container; 31 | const user = userEvent.setup(); 32 | 33 | beforeEach(async () => { 34 | mocked_login.mockImplementation(() => Connection.user = {username: 'admin'}) 35 | await Connection.login() 36 | // The mock implementation needs to occur here; if it's done outside it doesn't work! 37 | mocked_fetchMany.mockResolvedValue(mock_equipment.map(h => ({content: h}))); 38 | mocked_fetch.mockImplementation(request => new Promise(resolve => ({content: mock_equipment.find(h => h.url === request)}))); 39 | let container 40 | await act(async () => container = render().container); 41 | }) 42 | 43 | it('has appropriate values', async () => { 44 | expect(mocked_fetchMany).toHaveBeenCalledWith( 45 | 'equipment/', 46 | {}, 47 | false 48 | ) 49 | 50 | expect(await screen.findByDisplayValue(mock_equipment[0].name)).toBeInTheDocument(); 51 | }); 52 | 53 | it('sends an API call when created', async () => { 54 | const n = mock_equipment.length 55 | await user.type(screen.getAllByRole('textbox').filter(e => /name/i.test(e.name))[n], 'x') 56 | await user.click(screen.getAllByRole('button', {name: /Save/i})[n]); 57 | 58 | expect(mocked_fetch).toHaveBeenCalledWith( 59 | 'equipment/', 60 | { 61 | body: JSON.stringify({ 62 | name: 'x', 63 | type: "", 64 | }), 65 | method: 'POST' 66 | } 67 | ) 68 | }, 10000) 69 | 70 | it('sends an update API call when saved', async () => { 71 | const name = await screen.findByDisplayValue(mock_equipment[0].name) 72 | await user.clear(name) 73 | await user.type(name, 'T') 74 | await user.click(await screen.getAllByRole('button').find(e => /^Save$/.test(e.textContent))) 75 | expect(mocked_fetch).toHaveBeenCalledWith( 76 | mock_equipment[0].url, 77 | { 78 | body: JSON.stringify({ 79 | name: "T", 80 | type: mock_equipment[0].type, 81 | }), 82 | method: 'PATCH' 83 | } 84 | ) 85 | }); 86 | 87 | it('sends an API call when deleted', async () => { 88 | window.confirm = jest.fn(() => true); 89 | await act(async () => await user.click(screen.getAllByRole('button').find(e => /^Delete$/.test(e.textContent)))) 90 | expect(window.confirm).toHaveBeenCalledWith(`Delete equipment ${mock_equipment[0].name}?`); 91 | expect(mocked_fetch).toHaveBeenCalledWith( 92 | mock_equipment[0].url, 93 | { 94 | method: 'DELETE' 95 | } 96 | ) 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /frontend/src/test/Files.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 6 | 7 | import React from 'react'; 8 | import { act, render, screen } from '@testing-library/react'; 9 | import userEvent from '@testing-library/user-event'; 10 | import Connection from "../APIConnection"; 11 | import mock_files from './fixtures/files.json'; 12 | import mock_monitored_paths from './fixtures/monitored_paths.json'; 13 | const mock_monitored_path = mock_monitored_paths[0]; 14 | const Files = jest.requireActual('../Files').default; 15 | 16 | // Mock the APIConnection.fetch function from the APIConnection module 17 | // This is because we don't want to actually make API calls in our tests 18 | // We just want to check that the correct calls are made 19 | const mocked_fetch = jest.spyOn(Connection, 'fetch') 20 | const mocked_fetchMany = jest.spyOn(Connection, 'fetchMany') 21 | const mocked_login = jest.spyOn(Connection, 'login') 22 | 23 | it('Files has appropriate columns', async () => { 24 | mocked_fetchMany.mockResolvedValue([]); 25 | await act(async () => render()); 26 | expect(screen.getAllByRole('columnheader').find(e => e.textContent === mock_monitored_path.path)).toBeInTheDocument(); 27 | expect(screen.getAllByRole('columnheader').find(e => /Size/.test(e.textContent))).toBeInTheDocument(); 28 | expect(screen.getAllByRole('columnheader').find(e => /Time/.test(e.textContent))).toBeInTheDocument(); 29 | expect(screen.getAllByRole('columnheader').find(e => /State/.test(e.textContent))).toBeInTheDocument(); 30 | expect(screen.getAllByRole('columnheader').find(e => /Datasets/.test(e.textContent))).toBeInTheDocument(); 31 | expect(screen.getAllByRole('columnheader').find(e => /Re-import/.test(e.textContent))).toBeInTheDocument(); 32 | }) 33 | 34 | describe('Files', () => { 35 | let container; 36 | const user = userEvent.setup(); 37 | 38 | beforeEach(async () => { 39 | mocked_login.mockImplementation(() => Connection.user = {username: 'admin'}) 40 | await Connection.login() 41 | // The mock implementation needs to occur here; if it's done outside it doesn't work! 42 | mocked_fetchMany.mockResolvedValue(mock_files.map(h => ({content: h}))); 43 | mocked_fetch.mockImplementation(request => new Promise(resolve => ({content: mock_files.find(h => h.url === request)}))); 44 | let container 45 | await act(async () => container = render().container); 46 | }) 47 | 48 | it('has appropriate values', async () => { 49 | expect(mocked_fetchMany).toHaveBeenCalledWith( 50 | `${mock_monitored_path.url}files/`, 51 | {}, 52 | false 53 | ) 54 | 55 | expect(await screen.findByText(mock_files[0].path)).toBeInTheDocument(); 56 | }); 57 | 58 | it('sends an API call when reimported', async () => { 59 | await user.click(screen.getAllByRole('button', {name: /Re-import/i})[0]); 60 | 61 | expect(mocked_fetch).toHaveBeenCalledWith(`${mock_files[0].url}reimport/`) 62 | }) 63 | }); 64 | -------------------------------------------------------------------------------- /frontend/src/test/Harvester.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 6 | 7 | import React from 'react'; 8 | import { act, render, screen } from '@testing-library/react'; 9 | import userEvent from '@testing-library/user-event'; 10 | import Connection from "../APIConnection"; 11 | import mock_harvester from './fixtures/harvesters.json'; 12 | const Harvesters = jest.requireActual('../Harvesters').default; 13 | 14 | jest.mock('../MonitoredPaths') 15 | jest.mock('../HarvesterEnv') 16 | 17 | // Mock the APIConnection.fetch function from the APIConnection module 18 | // This is because we don't want to actually make API calls in our tests 19 | // We just want to check that the correct calls are made 20 | const mocked_fetch = jest.spyOn(Connection, 'fetch') 21 | const mocked_fetchMany = jest.spyOn(Connection, 'fetchMany') 22 | const mocked_login = jest.spyOn(Connection, 'login') 23 | 24 | it('Harvester has appropriate columns', async () => { 25 | mocked_fetchMany.mockResolvedValue([]); 26 | await act(async () => render()); 27 | expect(screen.getAllByRole('columnheader').find(e => /ID/.test(e.textContent))).toBeInTheDocument(); 28 | expect(screen.getAllByRole('columnheader').find(e => /Name/.test(e.textContent))).toBeInTheDocument(); 29 | expect(screen.getAllByRole('columnheader').find(e => /Last Check In/.test(e.textContent))).toBeInTheDocument(); 30 | expect(screen.getAllByRole('columnheader').find(e => /Sleep Time \(s\)/.test(e.textContent))).toBeInTheDocument(); 31 | expect(screen.getAllByRole('columnheader').find(e => /Actions/.test(e.textContent))).toBeInTheDocument(); 32 | }) 33 | 34 | describe('Harvester', () => { 35 | let container; 36 | const user = userEvent.setup(); 37 | 38 | beforeEach(async () => { 39 | mocked_login.mockImplementation(() => Connection.user = {username: 'admin'}) 40 | await Connection.login() 41 | // The mock implementation needs to occur here; if it's done outside it doesn't work! 42 | mocked_fetchMany.mockResolvedValue(mock_harvester.map(h => ({content: h}))); 43 | mocked_fetch.mockImplementation(request => new Promise(resolve => ({content: mock_harvester.find(h => h.url === request)}))); 44 | let container 45 | await act(async () => container = render().container); 46 | }) 47 | 48 | it('has appropriate values', async () => { 49 | expect(mocked_fetchMany).toHaveBeenCalledWith( 50 | 'harvesters/mine', 51 | {}, 52 | false 53 | ) 54 | 55 | expect(await screen.findByDisplayValue(mock_harvester[0].name)).toBeInTheDocument(); 56 | }); 57 | 58 | it('spawns child components when the button is clicked', async () => { 59 | await act(async () => await user.click(screen.getAllByTestId(/SearchIcon/)[0])); 60 | expect(await screen.findByText(/MockMonitoredPaths/)).toBeInTheDocument(); 61 | expect(await screen.findByText(/MockHarvesterEnv/)).toBeInTheDocument(); 62 | }); 63 | 64 | it('sends an update API call when saved', async () => { 65 | await act(async () => { 66 | const name = await screen.findByDisplayValue(mock_harvester[0].name) 67 | await user.type(name, '{Backspace>20}TEST_NAME') 68 | await user.click(await screen.getAllByLabelText(/^Save$/)[0]) 69 | }); 70 | expect(mocked_fetch).toHaveBeenCalledWith( 71 | mock_harvester[0].url, 72 | { 73 | body: JSON.stringify({ 74 | name: 'TEST_NAME', 75 | sleep_time: mock_harvester[0].sleep_time, 76 | }), 77 | method: 'PATCH' 78 | } 79 | ) 80 | }); 81 | 82 | it('sends an API call when deleted', async () => { 83 | window.confirm = jest.fn(() => true); 84 | await act(async () => await user.click(screen.getAllByTestId(/DeleteIcon/)[0])); 85 | expect(window.confirm).toHaveBeenCalledWith(`Delete ${mock_harvester[0].name}?`); 86 | expect(mocked_fetch).toHaveBeenCalledWith( 87 | mock_harvester[0].url, 88 | { 89 | method: 'DELETE' 90 | } 91 | ) 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /frontend/src/test/HarvesterEnv.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 6 | 7 | import React from 'react'; 8 | import { render, screen } from '@testing-library/react'; 9 | import userEvent from '@testing-library/user-event'; 10 | import Connection from "../APIConnection"; 11 | import mock_harvesters from './fixtures/harvesters.json'; 12 | const HarvesterEnv = jest.requireActual('../HarvesterEnv').default; 13 | 14 | var mock_harvester = mock_harvesters[1] 15 | // Mock the APIConnection.fetch function from the APIConnection module 16 | // This is because we don't want to actually make API calls in our tests 17 | // We just want to check that the correct calls are made 18 | const mocked = jest.spyOn(Connection, 'fetch') 19 | 20 | describe('HarvesterEnv', () => { 21 | let container; 22 | const user = userEvent.setup(); 23 | 24 | beforeEach(() => { 25 | // The mock implementation needs to occur here; if it's done outside it doesn't work! 26 | mocked.mockResolvedValue({content: mock_harvester}); 27 | container = render( 28 | {}} /> 29 | ).container; 30 | }) 31 | 32 | it('has appropriate columns', () => { 33 | expect(screen.getAllByRole('columnheader').find(e => /Variable/.test(e.textContent))).toBeInTheDocument(); 34 | expect(screen.getAllByRole('columnheader').find(e => /Value/.test(e.textContent))).toBeInTheDocument(); 35 | expect(screen.getAllByRole('columnheader').find(e => /Actions/.test(e.textContent))).toBeInTheDocument(); 36 | 37 | expect(screen.getByText(`${mock_harvester.name} - environment variables`)).toBeInTheDocument(); 38 | expect(screen.getByText(`${Object.keys(mock_harvester.environment_variables)[0]}`)).toBeInTheDocument(); 39 | expect(screen.getByDisplayValue(`${mock_harvester.environment_variables.TMP_VAR}`)).toBeInTheDocument(); 40 | 41 | expect(screen.getByPlaceholderText(/NEW_VARIABLE/)).toBeInTheDocument(); 42 | expect(screen.getAllByPlaceholderText(/VALUE/)).toHaveLength(2); 43 | }); 44 | 45 | it('sends update API call for editing variables', async () => { 46 | const key = Object.keys(mock_harvester.environment_variables)[0] 47 | const new_value = 'new value' 48 | await user.type( 49 | screen.getByDisplayValue(mock_harvester.environment_variables[key]), 50 | `{Backspace>20}${new_value}` 51 | ) 52 | await user.click(screen.getAllByTestId('SaveIcon')[0]) 53 | 54 | expect (mocked).toHaveBeenCalledWith( 55 | mock_harvester.url, 56 | { 57 | body: JSON.stringify({ 58 | environment_variables: { 59 | [key]: new_value, 60 | } 61 | }), 62 | method: 'PATCH' 63 | } 64 | ) 65 | }); 66 | 67 | it('sends update API call for new var', async () => { 68 | // TODO currently doesn't work because of the auto-updating from HarvesterEnv 69 | await user.type(screen.getByPlaceholderText(/NEW_VARIABLE/), 'TEST_OUTCOME') 70 | await user.type(screen.getAllByPlaceholderText(/VALUE/)[1], 'success?') 71 | await user.click(screen.getByTestId('AddIcon')) 72 | 73 | expect (mocked).toHaveBeenCalledWith( 74 | mock_harvester.url, 75 | { 76 | body: JSON.stringify({ 77 | environment_variables: { 78 | ...mock_harvester.environment_variables, 79 | TEST_OUTCOME: 'success?' 80 | } 81 | }), 82 | method: 'PATCH' 83 | } 84 | ) 85 | }); 86 | }) 87 | -------------------------------------------------------------------------------- /frontend/src/test/README.md: -------------------------------------------------------------------------------- 1 | # Galv Testing Strategy 2 | 3 | ## Unit Testing 4 | 5 | Unit testing is done using the [Jest](https://jestjs.io/) framework. 6 | The tests are located in the `frontend/src/test` directory. 7 | The tests are run using the command `npm test` in the `frontend` directory. 8 | 9 | ### Mocking and Scoping 10 | 11 | Components are tested as a complete, isolated component. 12 | There are, however, a few shared custom components that other components use. 13 | These components are included in their actual form, and not mocked, 14 | while the rest of the components are mocked in `frontend/src/test/__mocks__`. 15 | 16 | The components that are not mocked are: 17 | - `ActionButtons` 18 | - used by many components to trigger CRUD actions 19 | - `AsyncTable` 20 | - used by many components to display data in a table 21 | Note that any errors introduced in these components will affect several tests. 22 | 23 | Additionally, the `APIConnection` module's default export, 24 | an instance of the `APIConnection` class, is mocked on a case-by-case basis in order to return appropriate data. 25 | Some tests require mocking the `APIConnection` login method to return a successful login. 26 | 27 | ### Fixtures 28 | 29 | API response fixtures are located in `frontend/src/test/__fixtures__`. 30 | Where a fixture's content would appear in another fixture, only the wrapping fixture is used. 31 | For example, the details of a fake administrator are included in the `user-sets` of the `harvesters.json` fixture, 32 | so no `user.json` fixture is needed. 33 | 34 | ### What to Test 35 | 36 | Roughly following [this guide](https://daveceddia.com/what-to-test-in-react-app/), 37 | tests are expected to cover the following: 38 | - Rendering 39 | - Calling the API to Create/Update/Delete data 40 | - Spawning child components (though those components themselves are mocked) 41 | - Any specific issues that a patch is submitted to address 42 | 43 | ## End-to-End Testing 44 | 45 | End-to-end testing is done using the [Cypress](https://www.cypress.io/) framework. -------------------------------------------------------------------------------- /frontend/src/test/Tokens.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 6 | 7 | import React from 'react'; 8 | import { act, render, screen } from '@testing-library/react'; 9 | import userEvent from '@testing-library/user-event'; 10 | import Connection from "../APIConnection"; 11 | import mock_tokens from './fixtures/tokens.json'; 12 | const Tokens = jest.requireActual('../Tokens').default; 13 | 14 | // Mock the APIConnection.fetch function from the APIConnection module 15 | // This is because we don't want to actually make API calls in our tests 16 | // We just want to check that the correct calls are made 17 | const mocked_fetch = jest.spyOn(Connection, 'fetch') 18 | const mocked_fetchMany = jest.spyOn(Connection, 'fetchMany') 19 | const mocked_login = jest.spyOn(Connection, 'login') 20 | 21 | it('Tokens has appropriate columns', async () => { 22 | mocked_fetchMany.mockResolvedValue([]); 23 | await act(async () => render()); 24 | expect(screen.getAllByRole('columnheader').find(e => /Name/.test(e.textContent))).toBeInTheDocument(); 25 | expect(screen.getAllByRole('columnheader').find(e => /Created/.test(e.textContent))).toBeInTheDocument(); 26 | expect(screen.getAllByRole('columnheader').find(e => /Expires/.test(e.textContent))).toBeInTheDocument(); 27 | expect(screen.getAllByRole('columnheader').find(e => /Actions/.test(e.textContent))).toBeInTheDocument(); 28 | }) 29 | 30 | describe('Tokens', () => { 31 | let container; 32 | const user = userEvent.setup(); 33 | 34 | beforeEach(async () => { 35 | mocked_login.mockImplementation(() => Connection.user = {username: 'admin'}) 36 | await Connection.login() 37 | // The mock implementation needs to occur here; if it's done outside it doesn't work! 38 | mocked_fetchMany.mockResolvedValue(mock_tokens.map(h => ({content: h}))); 39 | mocked_fetch.mockImplementation(request => new Promise(resolve => ({content: mock_tokens.find(h => h.url === request)}))); 40 | let container 41 | await act(async () => container = render().container); 42 | }) 43 | 44 | it('has appropriate values', async () => { 45 | expect(mocked_fetchMany).toHaveBeenCalledWith( 46 | 'tokens/', 47 | {}, 48 | false 49 | ) 50 | 51 | expect(await screen.findByDisplayValue(mock_tokens[0].name)).toBeInTheDocument(); 52 | }); 53 | 54 | it('sends an API call when created', async () => { 55 | const n = mock_tokens.length 56 | await user.type(screen.getAllByRole('textbox').filter(e => /name/i.test(e.name))[n], 'x') 57 | await user.click(screen.getAllByRole('button', {name: /Save/i})[n]); 58 | 59 | expect(mocked_fetch).toHaveBeenCalledWith( 60 | 'create_token/', 61 | { 62 | body: JSON.stringify({ 63 | name: 'x', 64 | ttl: null, 65 | }), 66 | method: 'POST' 67 | } 68 | ) 69 | }, 10000) 70 | 71 | it('sends an update API call when saved', async () => { 72 | const name = await screen.findByDisplayValue(mock_tokens[0].name) 73 | await user.clear(name) 74 | await user.type(name, 'T') 75 | await user.click(await screen.getAllByRole('button').find(e => /^Save$/.test(e.textContent))) 76 | expect(mocked_fetch).toHaveBeenCalledWith( 77 | mock_tokens[0].url, 78 | { 79 | body: JSON.stringify({ 80 | name: "T", 81 | type: mock_tokens[0].type, 82 | }), 83 | method: 'PATCH' 84 | } 85 | ) 86 | }); 87 | 88 | it('sends an API call when deleted', async () => { 89 | window.confirm = jest.fn(() => true); 90 | await act(async () => await user.click(screen.getAllByRole('button').find(e => /^Delete$/.test(e.textContent)))) 91 | expect(window.confirm).toHaveBeenCalledWith(`Delete token ${mock_tokens[0].name}?`); 92 | expect(mocked_fetch).toHaveBeenCalledWith( 93 | mock_tokens[0].url, 94 | { 95 | method: 'DELETE' 96 | } 97 | ) 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /frontend/src/test/UserProfile.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 6 | 7 | import React from 'react'; 8 | import { act, render, screen } from '@testing-library/react'; 9 | import userEvent from '@testing-library/user-event'; 10 | import Connection from "../APIConnection"; 11 | import mock_users from './fixtures/users.json'; 12 | const mock_user = mock_users[0] 13 | const UserProfile = jest.requireActual('../UserProfile').default; 14 | 15 | // Mock the APIConnection.fetch function from the APIConnection module 16 | // This is because we don't want to actually make API calls in our tests 17 | // We just want to check that the correct calls are made 18 | const mocked_update = jest.spyOn(Connection, 'update_user') 19 | const mocked_login = jest.spyOn(Connection, 'login') 20 | 21 | describe('Equipment', () => { 22 | let container; 23 | const user = userEvent.setup(); 24 | 25 | beforeEach(async () => { 26 | mocked_login.mockImplementation(() => Connection.user = mock_user) 27 | await Connection.login() 28 | // The mock implementation needs to occur here; if it's done outside it doesn't work! 29 | mocked_update.mockResolvedValue(mock_user); 30 | let container 31 | await act(async () => container = render().container); 32 | }) 33 | 34 | it('has appropriate values', async () => { 35 | expect(await screen.findByText(`${mock_user.username} profile`)).toBeInTheDocument(); 36 | expect(await screen.getAllByRole('textbox').find(e => /email/i.test(e.name))).toBeInTheDocument(); 37 | expect(await screen.findByDisplayValue(mock_user.email)).toBeInTheDocument(); 38 | expect(await screen.getAllByLabelText(/password/i).find(e => /password/i.test(e.name))).toBeInTheDocument(); 39 | expect(await screen.getAllByLabelText(/password/i).find(e => /currentPassword/i.test(e.name))).toBeInTheDocument(); 40 | }); 41 | 42 | it('sends an API call when updated', async () => { 43 | await user.clear(screen.getAllByRole('textbox').find(e => /email/i.test(e.name))) 44 | await user.type(screen.getAllByRole('textbox').find(e => /email/i.test(e.name)), 'x') 45 | await user.click(screen.getByRole('submit')); 46 | 47 | expect(mocked_update).toHaveBeenCalledWith('x', '', '') 48 | }, 10000) 49 | }); 50 | -------------------------------------------------------------------------------- /frontend/src/test/UserRoleSet.test.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BSD-2-Clause 2 | // Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | // of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 6 | 7 | import React from 'react'; 8 | import { act, render, screen } from '@testing-library/react'; 9 | import userEvent from '@testing-library/user-event'; 10 | import Connection from "../APIConnection"; 11 | import mock_harvesters from './fixtures/harvesters.json'; 12 | import mock_users from './fixtures/users.json'; 13 | const mock_user_sets = mock_harvesters[0].user_sets 14 | const UserRoleSet = jest.requireActual('../UserRoleSet').default; 15 | 16 | // Mock the APIConnection.fetch function from the APIConnection module 17 | // This is because we don't want to actually make API calls in our tests 18 | // We just want to check that the correct calls are made 19 | const mocked_fetch = jest.spyOn(Connection, 'fetch') 20 | const mocked_fetchMany = jest.spyOn(Connection, 'fetchMany') 21 | const mocked_login = jest.spyOn(Connection, 'login') 22 | 23 | describe('UserRoleSet', () => { 24 | let container; 25 | const user = userEvent.setup(); 26 | 27 | beforeEach(async () => { 28 | mocked_login.mockImplementation(() => Connection.user = {username: 'admin'}) 29 | await Connection.login() 30 | // The mock implementation needs to occur here; if it's done outside it doesn't work! 31 | mocked_fetchMany.mockResolvedValue(mock_users.map(h => ({content: h}))); 32 | mocked_fetch.mockImplementation(request => new Promise(resolve => ({content: mock_user_sets.find(h => h.url === request)}))); 33 | let container 34 | await act(async () => container = render( {}} />).container); 35 | }) 36 | 37 | it('has appropriate values', async () => { 38 | expect(mocked_fetchMany).toHaveBeenCalledWith('users/') 39 | 40 | expect(await screen.findByText(mock_user_sets[0].name)).toBeInTheDocument(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /frontend/src/test/fixtures/cell_families.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "http://api.localhost/cell_families/2/", 4 | "id": 2, 5 | "name": "Supercell v2", 6 | "form_factor": "Big", 7 | "link_to_datasheet": "No", 8 | "anode_chemistry": "Yes", 9 | "cathode_chemistry": "Yes", 10 | "nominal_capacity": 1200.0, 11 | "nominal_cell_weight": 20.0, 12 | "manufacturer": "SuperCorp", 13 | "cells": [], 14 | "in_use": false 15 | }, 16 | { 17 | "url": "http://api.localhost/cell_families/1/", 18 | "id": 1, 19 | "name": "Supercell", 20 | "form_factor": "Big", 21 | "link_to_datasheet": "No", 22 | "anode_chemistry": "Yes", 23 | "cathode_chemistry": "Yes", 24 | "nominal_capacity": 1000.0, 25 | "nominal_cell_weight": 20.0, 26 | "manufacturer": "SuperCorp", 27 | "cells": [ 28 | "http://api.localhost/cells/1/" 29 | ], 30 | "in_use": true 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /frontend/src/test/fixtures/cells.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "http://api.localhost/cells/1/", 4 | "id": 1, 5 | "uid": "abcd-0123-efgh-4567", 6 | "display_name": "Special cell", 7 | "family": "http://api.localhost/cell_families/1/", 8 | "datasets": [], 9 | "in_use": false 10 | } 11 | ] 12 | -------------------------------------------------------------------------------- /frontend/src/test/fixtures/equipment.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "http://api.localhost/equipment/1/", 4 | "id": 1, 5 | "name": "Truckload of kit", 6 | "type": "Multipurpose", 7 | "datasets": [], 8 | "in_use": false 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /frontend/src/test/fixtures/files.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "http://api.localhost/files/5/", 4 | "id": 5, 5 | "path": "http://api.localhost/monitored_paths/1/TPG1+-+Cell+15+-+002.txt", 6 | "state": "IMPORTED", 7 | "last_observed_time": "2023-03-07T17:27:59.917092Z", 8 | "last_observed_size": 140480746, 9 | "errors": [], 10 | "datasets": [ 11 | "http://api.localhost/datasets/5/" 12 | ], 13 | "upload_info": null 14 | }, 15 | { 16 | "url": "http://api.localhost/files/4/", 17 | "id": 4, 18 | "path": "http://api.localhost/monitored_paths/1/Jie_OCV1_C01.mpr", 19 | "state": "IMPORTED", 20 | "last_observed_time": "2023-03-07T17:27:59.583763Z", 21 | "last_observed_size": 561846, 22 | "errors": [], 23 | "datasets": [ 24 | "http://api.localhost/datasets/4/" 25 | ], 26 | "upload_info": null 27 | }, 28 | { 29 | "url": "http://api.localhost/files/3/", 30 | "id": 3, 31 | "path": "http://api.localhost/monitored_paths/1/Ivium_Cell+1.idf", 32 | "state": "IMPORTED", 33 | "last_observed_time": "2023-03-07T17:27:59.334608Z", 34 | "last_observed_size": 6655242, 35 | "errors": [], 36 | "datasets": [ 37 | "http://api.localhost/datasets/3/" 38 | ], 39 | "upload_info": null 40 | }, 41 | { 42 | "url": "http://api.localhost/files/2/", 43 | "id": 2, 44 | "path": "http://api.localhost/monitored_paths/1/CCCV -exp1_C01.mpr", 45 | "state": "IMPORTED", 46 | "last_observed_time": "2023-03-07T17:27:59.067177Z", 47 | "last_observed_size": 5804677, 48 | "errors": [], 49 | "datasets": [ 50 | "http://api.localhost/datasets/2/" 51 | ], 52 | "upload_info": null 53 | }, 54 | { 55 | "url": "http://api.localhost/files/1/", 56 | "id": 1, 57 | "path": "http://api.localhost/monitored_paths/1/adam_3_C05.mpr", 58 | "state": "IMPORTED", 59 | "last_observed_time": "2023-03-07T17:27:58.775698Z", 60 | "last_observed_size": 3420493, 61 | "errors": [], 62 | "datasets": [ 63 | "http://api.localhost/datasets/1/" 64 | ], 65 | "upload_info": null 66 | } 67 | ] 68 | -------------------------------------------------------------------------------- /frontend/src/test/fixtures/harvesters.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "http://api.localhost/harvesters/7/", 4 | "id": 7, 5 | "name": "h2", 6 | "sleep_time": 10, 7 | "last_check_in": null, 8 | "user_sets": [ 9 | { 10 | "url": "http://api.localhost/groups/15/", 11 | "id": 15, 12 | "name": "Admins", 13 | "description": "Administrators can change harvester properties, as well as any of the harvester's paths or datasets.", 14 | "is_admin": true, 15 | "users": [ 16 | { 17 | "username": "admin", 18 | "email": "", 19 | "first_name": "", 20 | "last_name": "", 21 | "url": "http://api.localhost/users/1/", 22 | "id": 1, 23 | "is_active": true, 24 | "is_staff": true, 25 | "is_superuser": true 26 | } 27 | ] 28 | }, 29 | { 30 | "url": "http://api.localhost/groups/16/", 31 | "id": 16, 32 | "name": "Users", 33 | "description": "Users can view harvester properties. They can also add monitored paths.", 34 | "is_admin": false, 35 | "users": [] 36 | } 37 | ], 38 | "environment_variables": {} 39 | }, 40 | { 41 | "url": "http://api.localhost/harvesters/6/", 42 | "id": 6, 43 | "name": "harvey", 44 | "sleep_time": 100, 45 | "last_check_in": "2023-04-25T11:59:20.370875Z", 46 | "user_sets": [ 47 | { 48 | "url": "http://api.localhost/groups/11/", 49 | "id": 11, 50 | "name": "Admins", 51 | "description": "Administrators can change harvester properties, as well as any of the harvester's paths or datasets.", 52 | "is_admin": true, 53 | "users": [ 54 | { 55 | "username": "admin", 56 | "email": "", 57 | "first_name": "", 58 | "last_name": "", 59 | "url": "http://api.localhost/users/1/", 60 | "id": 1, 61 | "is_active": true, 62 | "is_staff": true, 63 | "is_superuser": true 64 | } 65 | ] 66 | }, 67 | { 68 | "url": "http://api.localhost/groups/12/", 69 | "id": 12, 70 | "name": "Users", 71 | "description": "Users can view harvester properties. They can also add monitored paths.", 72 | "is_admin": false, 73 | "users": [] 74 | } 75 | ], 76 | "environment_variables": { 77 | "TMP_VAR": "tmp_val" 78 | } 79 | } 80 | ] -------------------------------------------------------------------------------- /frontend/src/test/fixtures/inactive_users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "username": "test_user", 4 | "email": "", 5 | "first_name": "", 6 | "last_name": "", 7 | "url": "http://api.localhost/inactive_users/2/", 8 | "id": 2, 9 | "is_active": false, 10 | "is_staff": false, 11 | "is_superuser": false 12 | } 13 | ] -------------------------------------------------------------------------------- /frontend/src/test/fixtures/monitored_paths.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "http://api.localhost/monitored_paths/2/", 4 | "id": 2, 5 | "path": "/usr/test_data", 6 | "regex": "^T", 7 | "stable_time": 60, 8 | "active": true, 9 | "harvester": "http://api.localhost/harvesters/1/", 10 | "user_sets": [ 11 | { 12 | "url": "http://api.localhost/groups/5/", 13 | "id": 5, 14 | "name": "Admins", 15 | "description": "Administrators can change paths and their datasets.", 16 | "is_admin": true, 17 | "users": [ 18 | { 19 | "username": "admin", 20 | "email": "", 21 | "first_name": "", 22 | "last_name": "", 23 | "url": "http://api.localhost/users/1/", 24 | "id": 1, 25 | "is_active": true, 26 | "is_staff": true, 27 | "is_superuser": true 28 | } 29 | ] 30 | }, 31 | { 32 | "url": "http://api.localhost/groups/6/", 33 | "id": 6, 34 | "name": "Users", 35 | "description": "Users can view monitored paths and edit their datasets.", 36 | "is_admin": false, 37 | "users": [] 38 | } 39 | ] 40 | }, 41 | { 42 | "url": "http://api.localhost/monitored_paths/1/", 43 | "id": 1, 44 | "path": "/usr/test_data", 45 | "regex": "^[^T]", 46 | "stable_time": 60, 47 | "active": true, 48 | "harvester": "http://api.localhost/harvesters/1/", 49 | "user_sets": [ 50 | { 51 | "url": "http://api.localhost/groups/3/", 52 | "id": 3, 53 | "name": "Admins", 54 | "description": "Administrators can change paths and their datasets.", 55 | "is_admin": true, 56 | "users": [ 57 | { 58 | "username": "admin", 59 | "email": "", 60 | "first_name": "", 61 | "last_name": "", 62 | "url": "http://api.localhost/users/1/", 63 | "id": 1, 64 | "is_active": true, 65 | "is_staff": true, 66 | "is_superuser": true 67 | } 68 | ] 69 | }, 70 | { 71 | "url": "http://api.localhost/groups/4/", 72 | "id": 4, 73 | "name": "Users", 74 | "description": "Users can view monitored paths and edit their datasets.", 75 | "is_admin": false, 76 | "users": [] 77 | } 78 | ] 79 | } 80 | ] -------------------------------------------------------------------------------- /frontend/src/test/fixtures/tokens.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "http://api.localhost/tokens/4/", 4 | "id": 4, 5 | "name": "Browser session [068ac2c0_1]", 6 | "created": "2023-05-09T13:19:56.507040Z", 7 | "expiry": "2023-05-10T00:20:42.496149Z" 8 | }, 9 | { 10 | "url": "http://api.localhost/tokens/3/", 11 | "id": 3, 12 | "name": "Browser session [16c2abc5_1]", 13 | "created": "2023-05-09T13:19:56.507519Z", 14 | "expiry": "2023-05-09T23:19:56.507289Z" 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /frontend/src/test/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "username": "admin", 4 | "email": "admin@example.com", 5 | "first_name": "", 6 | "last_name": "", 7 | "url": "http://api.localhost/users/1/", 8 | "id": 1, 9 | "is_active": true, 10 | "is_staff": true, 11 | "is_superuser": true 12 | } 13 | ] 14 | -------------------------------------------------------------------------------- /frontend/src/test/run_tests.sh: -------------------------------------------------------------------------------- 1 | cd frontend 2 | yarn install 3 | npm run test 4 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "target": "es5", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true 22 | }, 23 | "include": [ 24 | "src/*", 25 | "src/**/*" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /galv.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Galv service with docker compose 3 | Requires=docker.service 4 | After=docker.service 5 | 6 | [Service] 7 | Type=oneshot 8 | RemainAfterExit=yes 9 | StandardError=null 10 | StandardOutput=null 11 | WorkingDirectory= 12 | 13 | # Compose up 14 | ExecStart=/usr/local/bin/docker-compose up -d 15 | 16 | # Compose down 17 | ExecStop=/usr/local/bin/docker-compose down 18 | 19 | [Install] 20 | WantedBy=multi-user.target 21 | -------------------------------------------------------------------------------- /harvester/Dockerfile: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | FROM python:3.10.4-slim@sha256:a2e8240faa44748fe18c5b37f83e14101a38dd3f4a1425d18e9e0e913f89b562 6 | 7 | ENV PYTHONDONTWRITEBYTECODE=1 8 | ENV PYTHONUNBUFFERED=1 9 | 10 | # Install postgresql-client for healthchecking 11 | #RUN apt-get update && \ 12 | # apt-get install -y \ 13 | # postgresql-client \ 14 | # build-essential libssl-dev libffi-dev python3-dev python-dev && \ 15 | # apt-get autoremove && \ 16 | # apt-get autoclean 17 | 18 | RUN mkdir -p /usr/harvester 19 | WORKDIR /usr/harvester 20 | COPY requirements.txt /requirements.txt 21 | RUN pip install -r /requirements.txt 22 | COPY . /usr/harvester 23 | -------------------------------------------------------------------------------- /harvester/harvester/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/harvester/harvester/__init__.py -------------------------------------------------------------------------------- /harvester/harvester/api.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import os 6 | import json 7 | from .utils import NpEncoder 8 | import requests 9 | from .settings import get_setting, get_settings, get_settings_file, get_logger, update_envvars 10 | import time 11 | 12 | logger = get_logger(__file__) 13 | 14 | 15 | def report_harvest_result( 16 | path: os.PathLike|str, 17 | monitored_path_id: int, 18 | content=None, 19 | error: BaseException = None 20 | ): 21 | start = time.time() 22 | try: 23 | if error is not None: 24 | data = {'status': 'error', 'error': ";".join(error.args)} 25 | else: 26 | data = {'status': 'success', 'content': content} 27 | data['path'] = path 28 | data['monitored_path_id'] = monitored_path_id 29 | logger.debug(f"{get_setting('url')}report/; {json.dumps(data, cls=NpEncoder)}") 30 | out = requests.post( 31 | f"{get_setting('url')}report/", 32 | headers={ 33 | 'Authorization': f"Harvester {get_setting('api_key')}" 34 | }, 35 | # encode then decode to ensure np values are converted to standard types 36 | json=json.loads(json.dumps(data, cls=NpEncoder)) 37 | ) 38 | try: 39 | out.json() 40 | except json.JSONDecodeError: 41 | error_text = out.text[:100].replace("\n", "\\n") 42 | if len(out.text) > 100: 43 | error_text += "..." 44 | logger.error(f"Server returned invalid JSON (HTTP {out.status_code}): {error_text}") 45 | return None 46 | except BaseException as e: 47 | logger.error(e) 48 | out = None 49 | logger.info(f"API call finished in {time.time() - start}") 50 | return out 51 | 52 | 53 | def update_config(): 54 | logger.info("Updating configuration from API") 55 | try: 56 | url = get_setting('url') 57 | key = get_setting('api_key') 58 | result = requests.get(f"{url}config/", headers={'Authorization': f"Harvester {key}"}) 59 | if result.status_code == 200: 60 | dirty = False 61 | new = result.json() 62 | old = get_settings() 63 | if old is None: 64 | old = {} 65 | all_keys = [*new.keys(), *old.keys()] 66 | for key in all_keys: 67 | if key in old.keys() and key in new.keys(): 68 | if json.dumps(old[key], cls=NpEncoder) == json.dumps(new[key], cls=NpEncoder): 69 | continue 70 | logger.info(f"Updating value for setting '{key}'") 71 | logger.info(f"Old value: {json.dumps(old[key], cls=NpEncoder)}") 72 | logger.info(f"New value: {json.dumps(new[key], cls=NpEncoder)}") 73 | dirty = True 74 | if key in old.keys(): 75 | logger.info(f"Updating value for setting '{key}'") 76 | logger.info(f"Old value: {json.dumps(old[key], cls=NpEncoder)}") 77 | logger.info(f"New value: [not set]") 78 | dirty = True 79 | if key in new.keys(): 80 | logger.info(f"Updating value for setting '{key}'") 81 | logger.info(f"Old value: [not set]") 82 | logger.info(f"New value: {json.dumps(new[key], cls=NpEncoder)}") 83 | dirty = True 84 | 85 | if dirty: 86 | with open(get_settings_file(), 'w+') as f: 87 | json.dump(result.json(), f) 88 | update_envvars() 89 | else: 90 | logger.error(f"Unable to fetch {url}config/ -- received HTTP {result.status_code}") 91 | except BaseException as e: 92 | logger.error(e) 93 | -------------------------------------------------------------------------------- /harvester/harvester/parse/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/harvester/harvester/parse/__init__.py -------------------------------------------------------------------------------- /harvester/harvester/parse/exceptions.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | class UnsupportedFileTypeError(Exception): 6 | """ 7 | Exception indicating the file is unsupported 8 | """ 9 | 10 | pass 11 | 12 | 13 | class InvalidDataInFileError(Exception): 14 | """ 15 | Exception indicating the file has invalid data 16 | """ 17 | 18 | pass 19 | 20 | 21 | class EmptyFileError(Exception): 22 | """ 23 | Exception indicating the file has no data 24 | """ 25 | 26 | pass 27 | -------------------------------------------------------------------------------- /harvester/harvester/settings.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: BSD-2-Clause 2 | # Copyright (c) 2020-2023, The Chancellor, Masters and Scholars of the University 3 | # of Oxford, and the 'Galv' Developers. All rights reserved. 4 | 5 | import json 6 | import os 7 | import pathlib 8 | import logging 9 | import logging.handlers 10 | 11 | logging.basicConfig( 12 | format='%(asctime)s %(levelname)s %(message)s [%(name)s:%(lineno)d]', 13 | level=logging.INFO, 14 | datefmt='%Y-%m-%d %H:%M:%S' 15 | ) 16 | 17 | 18 | def get_logfile() -> pathlib.Path: 19 | return pathlib.Path(os.getenv('LOG_FILE', "/harvester_files/harvester.log")) 20 | 21 | 22 | def get_logger(name): 23 | logger = logging.getLogger(name) 24 | # stream_handler = logging.StreamHandler(sys.stdout) 25 | # stream_handler.setLevel(logging.INFO) 26 | # logger.addHandler(stream_handler) 27 | formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s [%(name)s]', datefmt='%Y-%m-%d %H:%M:%S') 28 | file_handler = logging.handlers.RotatingFileHandler(get_logfile(), maxBytes=5_000_000, backupCount=5) 29 | file_handler.setLevel(logging.INFO) 30 | file_handler.setFormatter(formatter) 31 | logger.addHandler(file_handler) 32 | return logger 33 | 34 | 35 | logger = get_logger(__file__) 36 | 37 | 38 | def get_settings_file() -> pathlib.Path: 39 | return pathlib.Path(os.getenv('SETTINGS_FILE', "/harvester_files/.harvester.json")) 40 | 41 | 42 | def get_settings(): 43 | try: 44 | with open(get_settings_file(), 'r') as f: 45 | try: 46 | return json.load(f) 47 | except json.JSONDecodeError as e: 48 | logger.error(f"Error decoding json file {f.name}", e) 49 | f.seek(0) 50 | logger.error(f.readlines()) 51 | except FileNotFoundError: 52 | logger.error(f'No config file at {get_settings_file()}') 53 | return None 54 | 55 | 56 | def get_setting(*args): 57 | settings = get_settings() 58 | if not settings: 59 | if len(args) == 1: 60 | return None 61 | return [None for _ in args] 62 | if len(args) == 1: 63 | return settings.get(args[0]) 64 | return [settings.get(arg) for arg in args] 65 | 66 | 67 | def get_standard_units(): 68 | return {u['name']: u['id'] for u in get_setting('standard_units')} 69 | 70 | 71 | def get_standard_columns(): 72 | return {u['name']: u['id'] for u in get_setting('standard_columns')} 73 | 74 | 75 | def update_envvars(): 76 | envvars = get_setting('environment_variables') or {} 77 | for k, v in envvars.items(): 78 | old = os.getenv(k) 79 | os.environ[k] = v 80 | if old != v: 81 | logger.info(f"Update envvar {k} from '{old}' to '{v}'") 82 | delvars = get_setting('deleted_environment_variables') or {} 83 | for k in delvars: 84 | old = os.getenv(k) 85 | if old is not None: 86 | logger.info(f"Unsetting envvar {k} (previous value: {old})") 87 | os.unsetenv(k) 88 | -------------------------------------------------------------------------------- /harvester/harvester/utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import json 3 | 4 | 5 | class NpEncoder(json.JSONEncoder): 6 | 7 | # https://stackoverflow.com/a/57915246 8 | def default(self, obj): 9 | if isinstance(obj, np.integer): 10 | return int(obj) 11 | if isinstance(obj, np.floating): 12 | return float(obj) 13 | if isinstance(obj, np.ndarray): 14 | return obj.tolist() 15 | return super(NpEncoder, self).default(obj) 16 | -------------------------------------------------------------------------------- /harvester/requirements.txt: -------------------------------------------------------------------------------- 1 | click==8.1.3 2 | requests==2.28.1 3 | 4 | # Filetype readers 5 | galvani==0.2.1 6 | maya==0.6.1 7 | xlrd==2.0.1 8 | psutil==5.9.4 -------------------------------------------------------------------------------- /harvester/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/harvester/test/__init__.py -------------------------------------------------------------------------------- /nginx-proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jwilder/nginx-proxy:1.3.1 2 | COPY ./default /etc/nginx/vhost.d/default 3 | RUN { \ 4 | echo 'client_max_body_size 100m;'; \ 5 | } > /etc/nginx/conf.d/custom.conf \ 6 | -------------------------------------------------------------------------------- /nginx-proxy/default: -------------------------------------------------------------------------------- 1 | # nginx.default 2 | 3 | 4 | location /django_static/ { 5 | alias /app/static/; 6 | add_header Access-Control-Allow-Origin *; 7 | } 8 | -------------------------------------------------------------------------------- /restructure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/restructure.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Battery-Intelligence-Lab/galv/118a8a1802cf368232a7df3b928c6fefafa4c77b/setup.py --------------------------------------------------------------------------------