diff --git a/.github/workflows/tethys-release.yml b/.github/workflows/tethys-release.yml index 20f9174cd..78a0ef038 100644 --- a/.github/workflows/tethys-release.yml +++ b/.github/workflows/tethys-release.yml @@ -173,3 +173,45 @@ jobs: conda activate tethys; echo "Publishing to dev channel..."; anaconda -t "${{ secrets.CONDA_UPLOAD_TOKEN }}" upload -u ${{ secrets.CONDA_UPLOAD_USER }} -l dev $CONDA_BLD_PATH/noarch/tethys-platform*.tar.bz2 --force; + + # BUILD micro-tethys-platform + + # Generate Conda Recipe With Constrained Dependencies + - name: Generate Conda Recipe - micro + run: | + cd .. + . ~/miniconda/etc/profile.d/conda.sh; + conda activate tethys; + tethys gen metayaml -p minor --micro --overwrite; + # Show Tethys Meta + - name: Show Tethys Meta - micro + run: | + cd .. + cat ./tethys/conda.recipe/meta.yaml + # Build Conda + - name: Build Conda - micro + run: | + cd .. + . ~/miniconda/etc/profile.d/conda.sh; + conda create -y -c conda-forge -n conda-build conda-build anaconda-client + conda activate conda-build + conda config --set anaconda_upload no + mkdir -p ~/conda-bld + conda-build -c tethysplatform -c conda-forge ./tethys/conda.recipe + # Upload to Anaconda Cloud + - name: Upload to Conda Release Channel - micro + if: ${{ steps.version.outputs.prerelease == '' }} + run: | + cd .. + . ~/miniconda/etc/profile.d/conda.sh; + conda activate tethys; + echo "Publishing to release channel..."; + anaconda -t "${{ secrets.CONDA_UPLOAD_TOKEN }}" upload -u ${{ secrets.CONDA_UPLOAD_USER }} $CONDA_BLD_PATH/noarch/micro-tethys-platform*.tar.bz2 --force; + - name: Upload to Conda Dev Channel - micro + if: ${{ steps.version.outputs.prerelease != '' }} + run: | + cd .. + . ~/miniconda/etc/profile.d/conda.sh; + conda activate tethys; + echo "Publishing to dev channel..."; + anaconda -t "${{ secrets.CONDA_UPLOAD_TOKEN }}" upload -u ${{ secrets.CONDA_UPLOAD_USER }} -l dev $CONDA_BLD_PATH/noarch/micro-tethys-platform*.tar.bz2 --force; diff --git a/.github/workflows/tethys.yml b/.github/workflows/tethys.yml index eddb9c422..e91e8bc45 100644 --- a/.github/workflows/tethys.yml +++ b/.github/workflows/tethys.yml @@ -217,7 +217,7 @@ jobs: conda activate conda-build conda config --set anaconda_upload no mkdir -p ~/conda-bld - conda-build -c tethysplatform -c conda-forge ./tethys/conda.recipe + conda-build -c conda-forge ./tethys/conda.recipe # Upload Conda No Pull Request No Tag - name: Upload Conda No Tag if: ${{ github.event_name != 'pull_request' }} @@ -232,3 +232,42 @@ jobs: if: ${{ github.event_name == 'pull_request' }} run: | echo "Uploading is skipped for pull requests." + + # BUILD micro-tethys-platform + + # Generate Conda Recipe Without Constrained Dependencies + - name: Generate Conda Recipe - micro + run: | + cd .. + . ~/miniconda/etc/profile.d/conda.sh; + conda activate tethys; + tethys gen metayaml --micro --overwrite; + # Show Tethys Meta + - name: Show Tethys Meta - micro + run: | + cd .. + cat ./tethys/conda.recipe/meta.yaml + # Build Conda + - name: Build Conda - micro + run: | + cd .. + . ~/miniconda/etc/profile.d/conda.sh; + conda create -y -c conda-forge -n conda-build conda-build anaconda-client + conda activate conda-build + conda config --set anaconda_upload no + mkdir -p ~/conda-bld + conda-build -c conda-forge ./tethys/conda.recipe + # Upload Conda No Pull Request No Tag + - name: Upload Conda No Tag - micro + if: ${{ github.event_name != 'pull_request' }} + run: | + cd .. + . ~/miniconda/etc/profile.d/conda.sh; + ls ~/conda-bld/noarch + conda activate conda-build + anaconda -t "${{ secrets.CONDA_UPLOAD_TOKEN }}" upload -u ${{ secrets.CONDA_UPLOAD_USER }} -l dev $CONDA_BLD_PATH/noarch/micro-tethys-platform*.tar.bz2 --force; + # No Upload if Pull Request + - name: No Upload - micro + if: ${{ github.event_name == 'pull_request' }} + run: | + echo "Uploading is skipped for pull requests." diff --git a/.gitignore b/.gitignore index 23dcfa07b..da65bfd81 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ build/ dist/ tethys_portal/_version.py # Required for docs build -git-lfs-*/* \ No newline at end of file +git-lfs-*/* +conda.recipe/meta.yaml diff --git a/conda.recipe/meta.yaml b/conda.recipe/meta.yaml deleted file mode 100644 index e1eccd748..000000000 --- a/conda.recipe/meta.yaml +++ /dev/null @@ -1,107 +0,0 @@ -# Conda Recipe for Tethys Platform -# WARNING: THIS IS A GENERATED FILE. DO NOT EDIT. -# TO CHANGE THIS FILE, SEE $TETHYS_SRC/tethys_cli/gen_templates/meta yaml - - -package: - name: tethys-platform - version: 3.4.1.dev24+gb092a0af.d20220421 - -source: - path: .. - -build: - number: 0 - string: {% if environ.get('GIT_DESCRIBE_NUMBER', 0)|int > 0 %}dev{{ GIT_BUILD_STR }}{% endif %} - noarch: python - script: python -m pip install --no-deps --ignore-installed . - entry_points: - - tethys = tethys_cli:tethys_command - -requirements: - build: - - python - - pbr - run: - - python - - pycrypto - - pyopenssl - - docker-py - - distro - - postgresql - - psycopg2 - - sqlalchemy - - geoalchemy2 - - plotly - - bokeh - - tethys_dataset_services>=2.0.0 - - hs_restclient - - owslib - - requests - - dask - - tethys_dask_scheduler>=1.0.2 - - service_identity - - condorpy - - siphon - - python-jose - - pyjwt<2.0.0 - - arrow - - isodate - - django=3.2.* - - channels=3.* - - daphne=3.* - - django-analytical - - django-axes - - django-filter - - djangorestframework - - django-bootstrap5 - - django-model-utils - - django-guardian - - django-gravatar2 - - django-mfa2 - - django-recaptcha2 - - django-simple-captcha - - django-session-security - - django-termsandconditions - - social-auth-app-django - - selenium - - coverage - - factory_boy - - pillow - - pip - - future - - flake8 - - git - - setuptools_scm - - openssl<3.0.0 - - conda - - -test: - imports: - - tethys_apps - - tethys_cli - - tethys_compute - - tethys_config - - tethys_gizmos - - tethys_portal - - tethys_quotas - - tethys_sdk - - tethys_services - -about: - license: BSD-2-Clause - license_family: BSD - license_file: LICENSE - summary: Primary Tethys Platform Django Site Project - description: | - Tethys Platform provides both a development environment - and a hosting environment for scientific web applications. - home: https://www.tethysplatform.org - doc_url: http://docs.tethysplatform.org - dev_url: https://github.com/tethysplatform/tethys - -extra: - recipe-maintainers: - - sdc50 - - swainn \ No newline at end of file diff --git a/docs/installation.rst b/docs/installation.rst index f41a3e6ad..cd4522ed4 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -19,11 +19,11 @@ Also, be sure that the system you are using meets the minimum :ref:`system_reqs` 1. Install the ``tethys-platform`` Conda Package ------------------------------------------------ -a. To install the ``tethys-platform`` into a new conda environment then run the following commands: +a. To install ``tethys-platform`` into a new conda environment then run the following commands: .. code-block:: bash - conda create -n tethys -c tethysplatform -c conda-forge tethys-platform + conda create -n tethys -c conda-forge tethys-platform .. tip:: @@ -31,14 +31,25 @@ a. To install the ``tethys-platform`` into a new conda environment then run the If conda is taking too long to solve the Tethys environment, try using the ``libmamba`` solver: :ref:`libmamba_solver`. + **Install Micro-Tethys** + + The ``micro-tethys-platform`` conda package is a minimal version of ``tethys-platform``. It has the exact same code base, but doesn't include any of the optional dependencies. As a result the environment is much smaller, but none of the optional features will be enabled. Any of the optional features can be enabled simply by installing the dependencies required by those features (see :ref:`optional_features`). + + .. code-block:: bash + + conda create -n tethys -c tethysplatform -c conda-forge micro-tethys-platform + **Install Development Build** - To install the latest development build of ``tethys-platform`` add the ``tethysplatform/label/dev`` channel to the list of conda channels:: + To install the latest development build of ``tethys-platform`` add the ``tethysplatform/label/dev`` channel to the list of conda channels: + + .. code-block:: bash - conda create -n tethys -c tethysplatform/label/dev -c tethysplatform -c conda-forge tethys-platform + conda create -n tethys -c tethysplatform/label/dev -c conda-forge tethys-platform Alternatively, to install from source refer to the :ref:`developer_installation` docs. + 2. Activate the Tethys Conda Environment ---------------------------------------- @@ -51,7 +62,9 @@ Anytime you want to work with Tethys Platform, you'll need to activate the ``tet 3. Create a :file:`portal_config.yml` File ------------------------------------------ -To add custom configurations such as the database and other local settings you will need to generate a :file:`portal_config.yml` file. To generate a new template :file:`portal_config.yml` run:: +To add custom configurations such as the database and other local settings you will need to generate a :file:`portal_config.yml` file. To generate a new template :file:`portal_config.yml` run: + +.. code-block:: bash tethys gen portal_config @@ -61,20 +74,24 @@ You can customize your settings in the :file:`portal_config.yml` file after you 4. Configure the Tethys Database -------------------------------- -Tethys Platform requires a PostgreSQL database server. There are several options for setting up a DB server: local, docker, or dedicated. For development environments you can use Tethys to create a local server:: +There are several options for setting up a DB server: local, docker, or remote. Tethys Platform uses a local SQLite database by default. For development environments you can use Tethys to create a local server: + +.. code-block:: bash tethys db configure .. note:: - The tethys db command (:ref:`tethys_db_cmd`) will create a local database server in the directory specified by the ``DIR`` setting in the ``DATABASES`` section of the :file:`portal_config.yml` file. If the value of ``DIR`` is a relative path then the database server will be created relative to directory specified by the ``TETHYS_HOME`` environment variable. By default ``TETHYS_HOME`` is at `~/.tethys`. + The tethys db command (:ref:`tethys_db_cmd`) will create a local database file in the location specified by the ``NAME`` setting in the ``DATABASES`` section of the :file:`portal_config.yml` file (by default ``tethys_platform.sqlite``). If the value of ``NAME`` is a relative path then the database file will be created relative to directory specified by the ``TETHYS_HOME`` environment variable. By default ``TETHYS_HOME`` is at `~/.tethys`. - As an alternative to creating a local database server you can also configure a Docker DB server (see :ref:`using_docker`). A local database server is only recommended for development environments. For production environments please refer to :ref:`production_installation`. +For additional options for configuring a database see :ref:`database_configuration` 5. Start the Development Server ------------------------------- -Once you have a database successfully configured you can run the Tethys development server:: +Once you have a database successfully configured you can run the Tethys development server: + +.. code-block:: bash tethys manage start @@ -84,7 +101,7 @@ This will start up a locally running web server. You can access the Tethys Porta You can customize the port that the server is running on by adding the ``-p`` option. - :: + .. code-block:: bash tethys manage start -p 8001 @@ -110,6 +127,7 @@ Related Docs installation/system_requirements tethys_portal/configuration + installation/database_configuration installation/conda installation/application installation/showcase_apps diff --git a/docs/installation/application.rst b/docs/installation/application.rst index d00481329..d3cf346b4 100644 --- a/docs/installation/application.rst +++ b/docs/installation/application.rst @@ -72,6 +72,18 @@ install.yml This file is generated with your application scaffold. Dependencies that are listed in the ``install.yml`` will be installed with conda and will honor the specified channel priority. If there are any dependencies listed in the ``setup.py`` that are not specified in the ``install.yml`` then these packages will be installed with pip as part of the setup process. This file should be committed with your application code in order to aid installation on a Tethys Portal. +.. important:: + + The ``conda`` sections of the ``install.yml`` file require the ``conda`` library and optionally the ``conda-libmamba-solver`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install these libraries using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge conda conda-libmamba-solver + + # pip + pip install conda + .. literalinclude:: resources/example-install.yml :language: yaml diff --git a/docs/installation/production/docker/docker_compose.rst b/docs/installation/production/docker/docker_compose.rst index d79e72b83..e038af829 100644 --- a/docs/installation/production/docker/docker_compose.rst +++ b/docs/installation/production/docker/docker_compose.rst @@ -305,7 +305,7 @@ c. Add the following contents to each ``.env`` file: **db.env** - .. code-block:: env + .. code-block:: docker # Password of the db admin account POSTGRES_PASSWORD=please_dont_use_default_passwords @@ -323,7 +323,7 @@ c. Add the following contents to each ``.env`` file: **thredds.env** - .. code-block:: env + .. code-block:: docker # Password of the TDM admin user TDM_PW=please_dont_use_default_passwords @@ -355,7 +355,7 @@ c. Add the following contents to each ``.env`` file: **web.env** - .. code-block:: env + .. code-block:: docker # Domain name of server should be first in the list if multiple entries added ALLOWED_HOSTS="\"[localhost]\"" @@ -471,7 +471,7 @@ a. Create a :file:`.gitignore` file: b. Add the following contents to the :file:`.gitignore` file to omit the contents of these directories from being tracked: - .. code-block:: gitignore + .. code-block:: text data/ keys/ diff --git a/docs/installation/production/manual/configuration/advanced/images/webanalytics.png b/docs/installation/production/manual/configuration/advanced/images/webanalytics.png deleted file mode 100644 index 38725750a..000000000 --- a/docs/installation/production/manual/configuration/advanced/images/webanalytics.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9e008b4396af02e9341686d28fd10d5dc981d9f049401b908097a8dcc4719e0d -size 34841 diff --git a/docs/installation/production/manual/configuration/advanced/lockout.rst b/docs/installation/production/manual/configuration/advanced/lockout.rst index 07eb0b4fe..a7b0267d7 100644 --- a/docs/installation/production/manual/configuration/advanced/lockout.rst +++ b/docs/installation/production/manual/configuration/advanced/lockout.rst @@ -6,6 +6,18 @@ Lockout (Optional) **Last Updated:** May 2020 +.. important:: + + This feature requires the ``django-axes`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``django-axes`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge django-axes + + # pip + pip install django-axes + Tethys Portal includes lockout capabilities to prevent brute-force login attempts. This capability is provided by the `Django Axes `_ add-on for Django. This document describes the different configuration options that are available for lockout capabilities in Tethys Portal. .. image:: ./images/locked_out.png diff --git a/docs/installation/production/manual/configuration/advanced/multi_factor_auth.rst b/docs/installation/production/manual/configuration/advanced/multi_factor_auth.rst index 0b6e0febc..99c562ba6 100644 --- a/docs/installation/production/manual/configuration/advanced/multi_factor_auth.rst +++ b/docs/installation/production/manual/configuration/advanced/multi_factor_auth.rst @@ -6,6 +6,19 @@ Multi Factor Authentication (Optional) **Last Updated:** September 2020 +.. important:: + + These settings require the ``django-mfa2``, ``arrow``, and ``isodate`` libraries to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install these libraries using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge django-mfa2 arrow isodate + + # pip + pip install django-mfa2 arrow isodate + + Tethys allows you to enable/enforce the use of multi factor authentication through apps such as LastPass Authenticator or Google Authenticator. This capability is provided by `Django MFA2 `_. This tutorial will show you how to enable that functionality. diff --git a/docs/installation/production/manual/configuration/advanced/social_auth.rst b/docs/installation/production/manual/configuration/advanced/social_auth.rst index e8cae6692..48642430f 100644 --- a/docs/installation/production/manual/configuration/advanced/social_auth.rst +++ b/docs/installation/production/manual/configuration/advanced/social_auth.rst @@ -6,6 +6,18 @@ Single Sign On (Optional) **Last Updated:** December 2020 +.. important:: + + This feature requires the ``social-auth-app-django`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``social-auth-app-django`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge social-auth-app-django + + # pip + pip install social-auth-app-django + Tethys Portal supports authenticating users with several social authentication and single sign on providers such as Google, Facebook, and LinkedIn via the OAuth 2.0 method. The social authentication and authorization features have been implemented using the `Python Social Auth `_ module. Social login is disabled by default, because enabling it requires registering your tethys portal instance with each provider. @@ -187,7 +199,7 @@ Facebook b. Press the ``Setup`` button on the tile (or ``Settings`` if setup previously). c. Specify the following for the Valid OAuth Redirect URIs field: - :: + .. code-block:: https:///oauth2/complete/facebook/ @@ -245,7 +257,7 @@ Google As a security precaution, Google will only accept authentication requests from the hosts listed in the ``Authorized JavaScript Origins`` box. Add the domain of your Tethys Portal to the list. Optionally, you may add a localhost domain to the list to be used during testing: - :: + .. code-block:: https:// http://localhost:8000 @@ -258,7 +270,7 @@ Google You also need to provide the callback URI for Google to call once it has authenticated the user. This follows the pattern ``http:///oauth2/complete/google-oauth2/``: - :: + .. code-block:: https:///oauth2/complete/google-oauth2/ https://localhost:8000/oauth2/complete/google-oauth2/ @@ -299,6 +311,18 @@ For more detailed information about using Google social authentication see the f HydroShare ---------- +.. important:: + + This feature requires the ``hs_restclient`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``hs_restclient`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge hs_restclient + + # pip + pip install hs_restclient + 1. Create a HydroShare Account You will need a HydroShare account to register your Tethys Portal with HydroShare. To create an account, visit `https://www.hydroshare.org `_. @@ -319,7 +343,7 @@ HydroShare g. Redirect uris: Add the call back URLs. The protocol (http or https) that matches your Tethys Portal settings should be included in this url. For example: - :: + .. code-block:: if your Tethys Portal was located at the domain ``https://www.my-tethys-portal.com``: https://www.my-tethys-portal.com/oauth2/complete/hydroshare/ @@ -509,7 +533,7 @@ LinkedIn a. Add the call back URLs under the **OAuth 2.0 settings** section: - :: + .. code-block:: https:///oauth2/complete/linkedin-oauth2/ http://localhost:8000/oauth2/complete/linkedin-oauth2/ diff --git a/docs/installation/production/manual/configuration/advanced/webanalytics.rst b/docs/installation/production/manual/configuration/advanced/webanalytics.rst index ecf19ba16..4f2974a5d 100644 --- a/docs/installation/production/manual/configuration/advanced/webanalytics.rst +++ b/docs/installation/production/manual/configuration/advanced/webanalytics.rst @@ -4,9 +4,17 @@ Web Analytics (Optional) **Last Updated:** May 2020 -.. image:: ./images/webanalytics.png - :width: 300px - :align: right +.. important:: + + This feature requires the ``django-analytical`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``django-analytical`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge django-analytical + + # pip + pip install django-analytical Tethys portals are configured to allow portal administrators to track how users interact with their portal and applications using web based analytical services. 24 services, including common services like Google Analytical and Optimizely, can be configured using the `Django-Analytical `_ package. diff --git a/docs/installation/production/manual/configuration/basic/database.rst b/docs/installation/production/manual/configuration/basic/database.rst index c229e2b85..399769874 100644 --- a/docs/installation/production/manual/configuration/basic/database.rst +++ b/docs/installation/production/manual/configuration/basic/database.rst @@ -4,11 +4,22 @@ Production Database ******************* -**Last Updated:** September 2022 +**Last Updated:** September 2023 In this part of the production deployment guide, you will learn how to initialize and configure the Tethys Portal database for production. -1. Set Database Settings +1. Install Python dependencies +============================== + +Using a PostgreSQL database for production requires the ``psycopg2`` Python package. Also, While we do not recommend having your database on the same server as Tethys Portal, the commands to automate setting up and configuring the database require that the PostgreSQL database and the ``psycopg2`` library be installed on the web server. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``postgresql`` and ``psycopg2`` using conda as follows: + + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge postgresql psycopg2 + +2. Set Database Settings ======================== Set the database settings in the :file:`portal_config.yml` using the ``tethys settings`` command: @@ -25,7 +36,7 @@ Set the database settings in the :file:`portal_config.yml` using the ``tethys se **DO NOT USE DEFAULT USERNAMES OR PASSWORDS FOR PRODUCTION DATABASE ACCOUNTS** -2. Create Tethys Database and Database Users +3. Create Tethys Database and Database Users ============================================ Use the ``tethys db create`` command to create the database users and tables required by Tethys Portal: @@ -42,7 +53,7 @@ Use the ``tethys db create`` command to create the database users and tables req **DO NOT USE DEFAULT USERNAMES OR PASSWORDS FOR PRODUCTION DATABASE ACCOUNTS** -3. Create Tethys Database Tables +4. Create Tethys Database Tables ================================ Run the following command to create the Tethys database tables: @@ -51,7 +62,7 @@ Run the following command to create the Tethys database tables: tethys db migrate -4. Create Portal Admin User +5. Create Portal Admin User =========================== You will need to create at least one Portal Admin account to allow you to login to your Tethys Portal. Create the account as follows: diff --git a/docs/installation/production/manual/installation.rst b/docs/installation/production/manual/installation.rst index cad9abf11..be45aa306 100644 --- a/docs/installation/production/manual/installation.rst +++ b/docs/installation/production/manual/installation.rst @@ -11,27 +11,81 @@ This article will provide an overview of how to install Tethys Portal in a produ Install Miniconda ================= - 1. As of version 3.0, Tethys Platform can be installed using `conda `_. We recommend installing `Miniconda `_ as it provides a minimal installation of conda that is appropriate for servers: +1. As of version 3.0, Tethys Platform can be installed using `conda `_. We recommend installing `Miniconda `_ as it provides a minimal installation of conda that is appropriate for servers: - .. code-block:: bash + .. code-block:: bash - cd /tmp - wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh - bash ./Miniconda3-latest-Linux-x86_64.sh + cd /tmp + wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh + bash ./Miniconda3-latest-Linux-x86_64.sh - 2. Read the license and accept when prompted. Install to the default location (:file:`~/miniconda3`) and configure the shell to start on startup. +2. Read the license and accept when prompted. Install to the default location (:file:`~/miniconda3`) and configure the shell to start on startup. Install Tethys Platform ======================= - 1. Create a new conda environment called ``tethys`` with the ``tethys-platform`` package installed: +1. Create a new conda environment called ``tethys`` with the ``tethys-platform`` package installed: - .. code-block:: bash + .. code-block:: bash - conda create -n tethys -c conda-forge -c tethysplatform tethys-platform + conda create -n tethys -c conda-forge tethys-platform - 2. Activate the ``tethys`` conda environment after it is created: +2. Activate the ``tethys`` conda environment after it is created: - .. code-block:: bash + .. code-block:: bash - conda activate tethys + conda activate tethys + +Install Optional Dependencies +============================= + +Beginning with Tethys v5.0 or if you are using ``micro-tethys-platform`` many features of Tethys that require additional dependencies are optional. This allows you to select only the dependencies that you need for the features required in your deployment and maintains a minimal environment size. To the the list of optional features and their required dependencies see :ref:`optional_features`. + +1. Gather the list of optional dependencies that you want to include in your portal. Refer to the :ref:`optional_features` documentation and ensure that you have any dependencies that are required by features used by the apps that you will install into your portal. + + +2. Ensure that your ``tethys`` conda environment is active: + + .. code-block:: bash + + conda activate tethys + +3. Install the optional dependencies: + + .. code-block:: bash + + conda install -c conda-forge < DEPENDENCY_1 > < DEPENDENCY_2 > ... + + For example, if the list of optional dependencies you wanted to install was: ``django-session-security``, ``django-axes``, ``django-gravatar2``, ``social-auth-app-django``, ``postgresql``, ``psycopg2``, ``sqlalchemy``, and ``tethys_dataset_services``, then you would install them with the following command: + + .. code-block:: bash + + conda install -c conda-forge django-session-security django-axes django-gravatar2 social-auth-app-django postgresql psycopg2 "sqlalchemy<2" tethys_dataset_services + +.. tip:: + + To simplify the process of installing ``tethys-platform`` and any optional dependencies, consider creating a conda environment YAML file (:file:`environment.yml`) for your portal. For example: + + .. code-block:: yaml + + name: tethys + + channels: + - conda-forge + + dependencies: + - tethys-platform + - django-session-security + - django-axes + - django-gravatar2 + - social-auth-app-django + - postgresql + - psycopg2 + - sqlalchemy<2 + - tethys_dataset_services + + Use the following command to create your environment from an environment YAML file: + + .. code-block:: bash + + conda env create -f environment.yml diff --git a/docs/installation/resources/example-portal-config.yml b/docs/installation/resources/example-portal-config.yml index 495307fe7..e13715396 100644 --- a/docs/installation/resources/example-portal-config.yml +++ b/docs/installation/resources/example-portal-config.yml @@ -36,6 +36,10 @@ settings: # STATIC_ROOT: '' # TETHYS_WORKSPACES_ROOT: '' # STATICFILES_USE_NPM: True + # ADDITIONAL_TEMPLATE_DIRS: + # - tethysapp.myapp.templates + # ADDITIONAL_URLPATTERNS: + # - tethysext.myextension.urls SESSION_CONFIG: EXPIRE_AT_BROWSER_CLOSE: True diff --git a/docs/supplementary.rst b/docs/supplementary.rst index 7a324fbbe..66144ed70 100644 --- a/docs/supplementary.rst +++ b/docs/supplementary.rst @@ -7,14 +7,15 @@ Supplemental This section provides a list of miscellaneous reference material that can be used to help you understand Tethys Platform and Tethys app development in more detail. .. toctree:: - :maxdepth: 1 + :maxdepth: 1 - supplementary/key_concepts - supplementary/app_project - supplementary/terminal_quick_guide - supplementary/install_ubuntu - supplementary/docker_testing - supplementary/pgadmin - ./glossary - ./summary + supplementary/key_concepts + supplementary/optional_features + supplementary/app_project + supplementary/terminal_quick_guide + supplementary/install_ubuntu + supplementary/docker_testing + supplementary/pgadmin + ./glossary + ./summary diff --git a/docs/supplementary/optional_features.rst b/docs/supplementary/optional_features.rst new file mode 100644 index 000000000..73478e5a7 --- /dev/null +++ b/docs/supplementary/optional_features.rst @@ -0,0 +1,271 @@ +.. _optional_features: + +***************** +Optional Features +***************** + +With the release of ``micro-tethys-platform`` and starting with Tethys v5.0 many features of Tethys Platform will become optional and require additional dependencies to be installed. This is done to limit the size of the environment and allow Tethys Portal administrators more flexibility in deciding what features are needed in their deployment. The following list of features are optional and will require the listed dependencies to be installed for that feature to be enabled: + +Security Features +================= + +Session Security +---------------- + +Session security enables setting timeouts for user sessions and automatically logging them out. + +**dependencies** + - ``django-session-security`` + + +Track Failed Login Attempts +--------------------------- + +Tracking failed logins allows Tethys to lock user accounts after a certain number of failed attempts, and alerts users of the number of failed attempts when they login. + +**dependencies** + - ``django-axes`` + + +Add CORS Headers +---------------- + +Adds CORS headers to enable Tethys resources to be accessed on other domains. + +**dependencies** + - ``django-cors-headers`` + +Login/Accounts +============== + +Gravatar +-------- + +Gravatar provides a user avatar image in the user's profile. + +**dependencies** + - ``django-gravatar2`` + +Captcha +------- + +Captcha requires users to type the code from an image during login. + +**dependencies** + - ``django-simple-captcha`` + +ReCaptcha +--------- + +ReCaptcha uses a Google provided service to verify that the user logging in is human. + +**dependencies** + - ``django-recaptcha2`` + +Multi-Factor Authentication +--------------------------- + +Allows users to enable multi-factor authentication for their Tethys Portal account. + +**dependencies** + - ``django-mfa2`` + - ``arrow`` + - ``isodate`` + +Single Sign On with Social Accounts +----------------------------------- + +Allow users to login to Tethys using 3rd party accounts (e.g. GitHub, Google, Facebook, etc.). + +**dependencies** + - ``social-auth-app-django`` + +SSO with HydroShare ++++++++++++++++++++ + +Allows configuration of HydroShare as an SSO + +**dependencies** + - ``hs_restclient`` + +SSO with OneLogin ++++++++++++++++++ + +Allows configuration of OneLogin as an SSO + +**dependencies** + - ``python-jose`` + +OAuth2 Provider +--------------- + +Enables a Tethys Portal to be a provider of OAuth2 authentication. + +**dependencies** + - ``django-oauth-toolkit`` + +Portal Enhancements +=================== + +Terms and Conditions +-------------------- + +Enables portal administrators to define terms and conditions that users must accept to use the portal. + +**dependencies** + - ``django-termsandconditions`` + +Web Analytics Tracking +---------------------- + +Gathers web analytics statistics from portal usage. + +**dependencies** + - ``django-analytical`` + +JSON Widget +----------- + +Enables a JSON widget in the admin pages for app settings. + +**dependencies** + - ``django-json-widget`` + +RESTful Framework +----------------- + +Provides a framework for defining REST APIs. + +**dependencies** + - ``djangorestframework`` + +Mapping +======= + +May Layout Shapefile Support +---------------------------- + +Enables converting geojson to shapefile. + + +**dependencies** + - ``PyShp`` + +Command Line Interface +====================== + +Docker +------ + +Enables the ``docker`` command on the ``tethys`` CLI. + +**dependencies** + - ``docker-py`` + +Conda Installer +--------------- + +Enables the `tethys install`` commands to install conda packages. + +**dependencies** + - ``conda`` + - ``conda-libmamba-solver`` + +Databases +========= + +PostgreSQL +---------- + +Enables ``tethys db`` commands to setup local or remote PostgreSQL databases. + +**dependencies** + - ``postgresql`` + - ``psycopg2`` + +Persistent Stores +----------------- + +Enables apps to define and use persistent stores. + +**dependencies** + - ``sqlalchemy<2`` + - ``psycopg2`` (or other DB driver for Persistent Store type) + +Spatial Persistent Stores +------------------------- + +Enables apps to define spatial persistent stores. + +**dependencies** + - ``sqlalchemy<2`` + - ``geoalchemy2`` + +Gizmos +====== + +Bokeh Plots +----------- + +Enables the Bokeh plotting gizmo. + +**dependencies** + - ``bokeh`` + +Plotly Plots +------------ + +Enables the Plotly plotting gizmo. + +**dependencies** + - ``plotly`` + +Tethys Compute +============== + +Dask Job Type +------------- + +Enables the Dask job type. + +**dependencies** + - ``dask`` + - ``tethys_dask_scheduler`` + +HTCondor Job Types +------------------ + +Enables the HTCondor job and workflow types + +**dependencies** + - ``condorpy`` + +External Services +================= + +Dataset Services +---------------- + +Enables the :term:`dataset services` APIs for CKAN and GeoServer. + +**dependencies** + - ``tethys_dataset_services`` + +THREDDS Spatial Dataset Service +------------------------------- + +Enables using THREDDS as a spatial dataset service. + +**dependencies** + - ``siphon`` + + +Web Processing Services (WPS) +----------------------------- + +Enables apps to define WPS endpoints. + +**dependencies** + - ``owslib`` + + diff --git a/docs/tethys_cli/db.rst b/docs/tethys_cli/db.rst index ef4f6950d..837c42510 100644 --- a/docs/tethys_cli/db.rst +++ b/docs/tethys_cli/db.rst @@ -5,6 +5,23 @@ db command Setup and manage a Tethys database. +.. important:: + + The default database configuration uses SQLite. Many of these commands are not applicable to SQLite databases and only support PostgreSQL databases. To use a PostgreSQL database be sure to set your settings accordingly. At the very least: + + .. code-block:: bash + + tethys settings --set DATABASES.default.ENGINE django.db.backends.postgresql + + For more details see :ref:`database_configuration` + + Additionally, the PostgreSQL database and the ``psycopg2`` library must be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``postgresql`` and ``psycopg2`` using conda as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge postgresql psycopg2 + .. argparse:: :module: tethys_cli :func: tethys_command_parser diff --git a/docs/tethys_cli/docker.rst b/docs/tethys_cli/docker.rst index 2b8926807..ac1bc2ba0 100644 --- a/docs/tethys_cli/docker.rst +++ b/docs/tethys_cli/docker.rst @@ -9,6 +9,14 @@ Manage Tethys-sponsored Docker containers. To learn more about Docker, see `What You must have Docker installed and add your user to the ``docker`` group to use the Tethys ``docker`` command (see: `Install Docker `_ and `Post-installation steps for Linux `_). + Additionally, this feature requires the ``docker-py`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``docker-py`` using conda as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge docker-py + + .. argparse:: :module: tethys_cli :func: tethys_command_parser diff --git a/docs/tethys_portal.rst b/docs/tethys_portal.rst index 20e6768b1..f0c4331c2 100644 --- a/docs/tethys_portal.rst +++ b/docs/tethys_portal.rst @@ -2,7 +2,7 @@ Tethys Portal ************* -**Last Updated:** April 29, 2019 +**Last Updated:** August 2023 Tethys Portal is the Django web site provided by Tethys Platform that acts as the runtime environment for apps. It leverages the capabilities of Django to provide the core website functionality that is often taken for granted in modern web applications. A description of the primary capabilities of Tethys Portal is provided in this section. @@ -12,7 +12,6 @@ Tethys Portal is the Django web site provided by Tethys Platform that acts as th tethys_portal/configuration tethys_portal/admin_pages tethys_portal/tethys_users - tethys_portal/developer_tools tethys_portal/feedback diff --git a/docs/tethys_portal/admin_pages.rst b/docs/tethys_portal/admin_pages.rst index e18197518..0882e4b0f 100644 --- a/docs/tethys_portal/admin_pages.rst +++ b/docs/tethys_portal/admin_pages.rst @@ -17,7 +17,7 @@ Tethys Portal includes administration pages that can be used to manage the websi If you did not create an administrator user during installation, run the following command in the terminal: - :: + .. code-block:: console tethys manage createsuperuser @@ -26,6 +26,18 @@ Tethys Portal includes administration pages that can be used to manage the websi Auth Token ========== +.. important:: + + This feature requires the ``djangorestframework`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``djangorestframework`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge djangorestframework + + # pip + pip install djangorestframework + Tethys REST API tokens for individual users can be managed using the ``Tokens`` link under the ``AUTH TOKEN`` heading (see Figure 2). .. figure:: ../images/site_admin/auth_token.png @@ -154,6 +166,18 @@ Tethys leverages the excellent `Python Social Auth /workspaces`. STATIC_ROOT the Django `STATIC_ROOT `_ setting. Defaults to :file:`/static`. STATICFILES_USE_NPM serves JavaScript dependencies through Tethys rather than using a content delivery network (CDN) when ``True``. Defaults to ``False``. When set to ``True`` then you must run ``tethys gen package_json`` to npm install the JS dependencies locally so they can be served by Tethys. +ADDITIONAL_TEMPLATE_DIRS a list of dot-paths to template directories. These will be prepended to Tethys's list of template directories so specific templates can be overriden. +ADDITIONAL_URLPATTERNS a list of dot-paths to list or tuples that define additional URL patterns to register in the portal. Additional URL patterns will precede default URL patterns so URLs will first match against user specified URL patterns. ================================================== ================================================================================ SESSION_CONFIG ++++++++++++++ +.. important:: + + These settings require the ``django-session-security`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``django-session-security`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge django-session-security + + # pip + pip install django-session-security + ================================================== ================================================================================ Setting Description ================================================== ================================================================================ @@ -94,6 +108,8 @@ SESSION_SECURITY_WARN_AFTER the Django Session Security ` SESSION_SECURITY_EXPIRE_AFTER the Django Session Security `EXPIRE_AFTER `_ setting. Defaults to 900 seconds. ================================================== ================================================================================ +.. _database_settings: + DATABASES +++++++++ @@ -116,7 +132,7 @@ See the Django `DATABASES `_ setting. Not used with SQLite. | +--+----------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| | DIR | name of psql directory for conda installation of PostgreSQL that ships with Tethys (if using the ``django.db.backends.postgresql`` ``ENGINE``). This directory will be created relative to the ``TETHYS_HOME`` directory when ``tethys db create`` is executed, unless an absolute path is provided. Defaults to ``psql``. If you are using the ``sqlite3`` ``ENGINE`` or an external database server then exclude this key or set it to `None`. | +| | DIR | name of psql directory for a local PostgreSQL database (if using the ``django.db.backends.postgresql`` ``ENGINE``). This directory will be created relative to the ``TETHYS_HOME`` directory when ``tethys db create`` is executed, unless an absolute path is provided. If you are using the ``sqlite3`` ``ENGINE`` or an external database server then exclude this key or set it to `None`. | +--+----------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ LOGGING @@ -153,9 +169,26 @@ LOGGING CAPTCHA_CONFIG ++++++++++++++ +.. important:: + + These Captcha feature requires either the ``django-simple-captcha`` library or the ``django-recaptcha2`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install one of these libraries using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge django-simple-captcha + # Or + conda install -c conda-forge django-recaptcha2 + + # pip + pip install django-simple-captcha + # Or + pip install django-recaptcha2 + ================================================== ================================================================================ Setting Description ================================================== ================================================================================ +ENABLE_CAPTCHA Boolean specifying if captcha should be enabled on the login screen. If using Google ReCaptcha then the following two settings are required. Default is ``False`` RECAPTCHA_PRIVATE_KEY Private key for Google ReCaptcha. Required to enable ReCaptcha on the login screen. See `Django Recaptcha 2 Installation `_. RECAPTCHA_PUBLIC_KEY Public key for Google ReCaptcha. Required to enable ReCaptcha on the login screen. See `Django Recaptcha 2 Installation `_. RECAPTCHA_PROXY_HOST Proxy host for Google ReCaptcha. Optional. See `Django Recaptcha 2 Installation `_. @@ -164,6 +197,38 @@ RECAPTCHA_PROXY_HOST Proxy host for Google ReCaptc OAUTH_CONFIG ++++++++++++ +.. important:: + + These settings require the ``social-auth-app-django`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``social-auth-app-django`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge social-auth-app-django + + # pip + pip install social-auth-app-django + + If using the OneLogin OIDC provider, you will also need to install the ``python-jose`` library: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge python-jose + + # pip + pip install python-jose + + If using the HydroShare provider, you will also need the ``hs_restclient`` library: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge hs_restclient + + # pip + pip install hs_restclient + ================================================== ================================================================================ Setting Description ================================================== ================================================================================ @@ -214,6 +279,18 @@ SOCIAL_AUTH_ONELOGIN_OIDC_SUBDOMAIN Your OneLogin Subdomain. See MFA_CONFIG ++++++++++ +.. important:: + + These settings require the ``django-mfa2``, ``arrow``, and ``isodate`` libraries to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install these libraries using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge django-mfa2 arrow isodate + + # pip + pip install django-mfa2 arrow isodate + ================================================== ================================================================================ Setting Description ================================================== ================================================================================ @@ -230,7 +307,20 @@ MFA_UNALLOWED_METHODS A list of MFA methods to be d ANALYTICS_CONFIG ++++++++++++++++ -the Django Analytical configuration settings for enabling analytics services on the Tethys Portal (see: `Enabling Services - Django Analytical `_. The following is a list of settings for some of the supported services that can be enabled. +The Django Analytical configuration settings for enabling analytics services on the Tethys Portal (see: `Enabling Services - Django Analytical `_. The following is a list of settings for some of the supported services that can be enabled. + + +.. important:: + + These settings require the ``django-analytical`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``django-analytical`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge django-analytical + + # pip + pip install django-analytical ================================================== ================================================================================ Setting Description @@ -278,14 +368,26 @@ EMAIL_FROM the email alias setting (e.g. LOCKOUT_CONFIG ++++++++++++++ -the Django Axes configuration settings for enabling lockout capabilities on Tethys Portal (see: :ref:`advanced_config_lockout`). The following is a list of the Django Axes settings that are configured for the default lockout capabilities in Tethys Portal. For a full list of Django Axes settings, see: `Django Axes Configuration Documentation `_. +The Django Axes configuration settings for enabling lockout capabilities on Tethys Portal (see: :ref:`advanced_config_lockout`). The following is a list of the Django Axes settings that are configured for the default lockout capabilities in Tethys Portal. For a full list of Django Axes settings, see: `Django Axes Configuration Documentation `_. + +.. important:: + + These settings require the ``django-axes`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``django-axes`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge django-axes + + # pip + pip install django-axes ================================================== ================================================================================ Setting Description ================================================== ================================================================================ AXES_FAILURE_LIMIT Number of failed login attempts to allow before locking. Default ``3``. AXES_COOLOFF_TIME Time to elapse before locked user is allowed to attempt logging in again. In the :file:`portal_config.yml` this setting accepts only integers or `ISO 8601 time duration formatted strings `_ (e.g.: ``"PT30M"``). Default is 30 minutes. -AXES_ONLY_USER_FAILURES Only lock based on username and do not lock based on IP when True. Defaults to ``True``. +AXES_LOCKOUT_PARAMETERS A list of parameters that Axes uses to lock out users. See `Django Axes - Customizing lockout parameters `_ for more details. Defaults to ``['username']``. AXES_ENABLE_ADMIN Enable the Django Axes admin interface. Defaults to ``True``. AXES_VERBOSE More logging for Axes when True. Defaults to ``True``. AXES_RESET_ON_SUCCESS Successful login (after the cooloff time has passed) will reset the number of failed logins when True. Defaults to ``True``. @@ -293,6 +395,61 @@ AXES_LOCKOUT_TEMPLATE Template to render when user AXES_LOGGER The logger for Django Axes to use. Defaults to ``'tethys.watch_login'``. ================================================== ================================================================================ +CORS_CONFIG ++++++++++++ + +These CORS settings are used to configure Cross-Origin Resource Sharing (CORS) for the Tethys Portal. See: `Django CORS Headers `_ for more information for the complete list of availalbe settings. + +.. important:: + + These settings require the ``django-cors-headers`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``django-cors-headers`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge django-cors-headers + + # pip + pip install django-cors-headers + +================================================== ================================================================================ +Setting Description +================================================== ================================================================================ +CORS_ALLOWED_ORIGINS A list of origins that are authorized to make cross-site HTTP requests. Defaults to ``[]``. +CORS_ALLOWED_ORIGIN_REGEXES A list of strings representing regexes that match Origins that are authorized to make cross-site HTTP requests. Defaults to ``[]``. +CORS_ALLOW_ALL_ORIGINS If ``True``, all origins will be allowed. Other settings restricting allowed origins will be ignored. Defaults to ``False``. +CORS_ALLOW_METHODS A list of HTTP verbs that are allowed for cross-site requests. Defaults to ``("DELETE", "GET", "OPTIONS", "PATCH", "POST", "PUT")``. +CORS_ALLOW_HEADERS The list of non-standard HTTP headers that you permit in requests from the browser. Sets the Access-Control-Allow-Headers header in responses to preflight requests. Defaults to ``("accept", "authorization", "content-type", "user-agent", "x-csrftoken", "x-requested-with")``. +================================================== ================================================================================ + +Gravatar Settings ++++++++++++++++++ + +The Gravatar settings are used to configure the Gravatar service user profile pictures for the Tethys Portal. See: `Django Gravatar 2 `_ for more information. + +.. important:: + + These settings require the ``django-gravatar2`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``django-gravatar2`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge django-gravatar2 + + # pip + pip install django-gravatar2 + +================================================== ================================================================================ +Setting Description +================================================== ================================================================================ +GRAVATAR_URL the Gravatar service endpoint. Defaults to ``"http://www.gravatar.com/"``. +GRAVATAR_SECURE_URL the secure Gravatar service endpoint. Defaults to ``"https://secure.gravatar.com/"``. +GRAVATAR_DEFAULT_SIZE the default size in pixels of the Gravatar image. Defaults to ``"80"``. +GRAVATAR_DEFAULT_IMAGE the default Gravatar image. Defaults to ``"retro"``. +GRAVATAR_DEFAULT_RATING the default allowable image rating. Defaults to ``"g"``. +GRAVATAR_DEFAULT_SECURE uses Gravatar secure endpoint when ``True``. Defaults to ``True``. +================================================== ================================================================================ + Other Settings ++++++++++++++ diff --git a/docs/tethys_portal/developer_tools.rst b/docs/tethys_portal/developer_tools.rst deleted file mode 100644 index 305ca6999..000000000 --- a/docs/tethys_portal/developer_tools.rst +++ /dev/null @@ -1,13 +0,0 @@ -*************** -Developer Tools -*************** - -**Last Updated:** August 4, 2015 - -Tethys provides a Developer Tools page that is accessible when you run Tethys in developer mode. Developer Tools contain documentation, code examples, and live demos of the features of various features of Tethys. Use it to learn how to add a map or a plot to your web app using Gizmos or browse the available geoprocessing capabilities and formulate geoprocessing requests interactively. - -.. figure:: ../images/features/developer_tools.png - :width: 500px - - -**Figure 4.** Use the Developer Tools page to assist you in development. \ No newline at end of file diff --git a/docs/tethys_portal/tethys_users.rst b/docs/tethys_portal/tethys_users.rst index 8e0175df1..40fe495cf 100644 --- a/docs/tethys_portal/tethys_users.rst +++ b/docs/tethys_portal/tethys_users.rst @@ -4,7 +4,7 @@ Tethys Users ************ -**Last Updated:** April 2019 +**Last Updated:** September 2023 User Settings ============= @@ -15,6 +15,18 @@ The User Settings page can be accessed through the drop-down menu located at the A non-editable view of the user's information can be accessed by clicking the user avatar icon to the left of the drop-down menu (see Figure 1). +.. note:: + + This icon next to the users name come from `Gravatar `_. This feature requires the ``django-gravatar2`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``django-gravatar2`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge django-gravatar2 + + # pip + pip install django-gravatar2 + .. figure:: ../images/tethys_portal/tethys_portal_user_profile.png :width: 675px @@ -39,4 +51,75 @@ Within a user's settings page there is a ``Workspace`` section that provides a s .. tip:: - See :ref:`tethys_quotas_workspace_manage` for information on how to pre/post process the user workspace when it is cleared. \ No newline at end of file + See :ref:`tethys_quotas_workspace_manage` for information on how to pre/post process the user workspace when it is cleared. + +Manage User OAuth2 Application Registrations +============================================ + +.. important:: + + This feature requires the ``django-oauth-toolkit`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``django-oauth-toolkit`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge django-oauth-toolkit + + # pip + pip install django-oauth-toolkit + +This section provides a link to the OAuth2 application management page for the user. This allows a user to register an external application that will use Tethys Portal as the OAuth2 provider. This enables users of the external application to authenticate using Tethys. + +Customization +============= + +The Tethys User Profile and Settings pages can be customized by overriding the template used to render them (see the ``Custom Templates`` section in :ref:`tethys_configuration`). + +When providing a custom template you may just want to extend the default template and override specific blocks. For example: + +.. code-block:: html+django + + {% extends "tethys_portal/user/profile.html" %} + + {% block api_key_override %} + {% endblock %} + + {% block custom_sections %} +
+
+

Custom Section

+
+
+
+
{{ custom_user_attribute }}
+
+
+
+
+ {% endblock %} + +The following blocks are defined in the ``profile.html`` file: + +- ``title`` +- ``back_button`` +- ``secondary_content`` + - ``profile_sections`` + - ``name_override`` + - ``name_parameters`` + - ``email_override`` + - ``email_parameters`` + - ``credentials_override`` + - ``credential_parameters`` + - ``sso_override`` + - ``social_parameters`` + - ``api_key_override`` + - ``account_override`` + - ``account_parameters`` + - ``workspace_override`` + - ``storage_parameters`` + - ``oauth2_provider_override`` + - ``custom_sections`` + +.. note:: + + The ``settings.html`` file is what is shown when the user selects the ``Edit`` button on the user profile page. It just extends the ``profile.html`` file and overrides the ``*_parameters`` blocks. diff --git a/docs/tethys_sdk/gizmos/bokeh_view.rst b/docs/tethys_sdk/gizmos/bokeh_view.rst index 9105d8a3a..4b1ab59c8 100644 --- a/docs/tethys_sdk/gizmos/bokeh_view.rst +++ b/docs/tethys_sdk/gizmos/bokeh_view.rst @@ -2,7 +2,24 @@ Bokeh View ********** -**Last Updated:** November 11, 2016 +**Last Updated:** August 2023 + +.. important:: + + This gizmo requires the ``bokeh`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``bokeh`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge "bokeh<3" + + # pip + pip install "bokeh<3" + + **Don't Forget**: If you end up using this gizmo in your app, add ``bokeh`` as a requirement to your :file:`install.yml`. + +Python +------ .. autoclass:: tethys_sdk.gizmos.BokehView @@ -21,21 +38,25 @@ to do so with Bokeh. in the ``import_gizmos`` block. For example: - :: + + .. code-block:: html+django {% block import_gizmos %} {% import_gizmo_dependency bokeh_view %} {% endblock %} -Four elements are required: +Three elements are required: 1) A controller for the AJAX call with a BokehView gizmo. -:: + + +.. code-block:: python from tethys_sdk.gizmos import BokehView + from tethys_sdk.routing import controller from bokeh.plotting import figure - @login_required() + @controller(name="bokeh_ajax", url="app-name/bokeh") def bokeh_ajax(request): """ Controller for the bokeh ajax request. @@ -49,23 +70,16 @@ Four elements are required: return render(request, 'app_name/bokeh_ajax.html', context) 2) A template for with the tethys gizmo (e.g. bokeh_ajax.html) -:: + +.. code-block:: html+django {% load tethys_gizmos %} {% gizmo bokeh_view_input %} -3) A url map to the controller in app.py -:: - - ... - UrlMap(name='bokeh_ajax', - url='app_name/bokeh', - controller='app_name.controllers.bokeh_ajax'), - ... +3) The AJAX call in the javascript -4) The AJAX call in the javascript -:: +.. code-block:: javascript $(function() { //wait for page to load diff --git a/docs/tethys_sdk/gizmos/cesium_map_view.rst b/docs/tethys_sdk/gizmos/cesium_map_view.rst index 0d8939365..8488eba07 100644 --- a/docs/tethys_sdk/gizmos/cesium_map_view.rst +++ b/docs/tethys_sdk/gizmos/cesium_map_view.rst @@ -48,12 +48,15 @@ This method is intended for initializing a map generated from an AJAX request. {% import_gizmo_dependency cesium_map_view %} {% endblock %} -Four elements are required: +Three elements are required: 1) A controller for the AJAX call with a Cesium map view gizmo. -:: - @login_required() +.. code-block:: python + + @controller( + url="dam-break/map/dam_break_map_ajax", + ) def dam_break_map_ajax(request): """ Controller for the dam_break_map ajax request. @@ -66,29 +69,22 @@ Four elements are required: cesium_map_view = CesiumMapView(...) - context = { 'cesium_map_view': cesium_map_view } + context = { "cesium_map_view": cesium_map_view } - return render(request, 'dam_break_map_ajax/map_ajax.html', context) + return render(request, "dam_break_map_ajax/map_ajax.html", context) -2) A url map to the controller in app.py -:: +2) A template for with the tethys gizmo (e.g. map_ajax.html) - ... - UrlMap(name='dam_break_map_ajax', - url='dam-break/map/dam_break_map_ajax', - controller='dam_break.controllers.dam_break_map_ajax'), - ... - -3) A template for with the tethys gizmo (e.g. map_ajax.html) -:: +.. code-block:: html+django {% load tethys_gizmos %} {% gizmo cesium_map_view %} -4) The AJAX call in the javascript -:: +3) The AJAX call in the javascript + +.. code-block:: javascript $(function() { //wait for page to load diff --git a/docs/tethys_sdk/gizmos/datatable_view.rst b/docs/tethys_sdk/gizmos/datatable_view.rst index 8677de431..a3e75f7c0 100644 --- a/docs/tethys_sdk/gizmos/datatable_view.rst +++ b/docs/tethys_sdk/gizmos/datatable_view.rst @@ -26,58 +26,51 @@ to do so with the DataTableView gizmo. {% import_gizmo_dependency datatable_view %} {% endblock %} -Four elements are required: +Three elements are required: 1) A controller for the AJAX call with a DataTableView gizmo. -:: + +.. code-block:: python import json - @login_required() + @controller def datatable_ajax(request): """ Controller for the datatable ajax request. """ searching = False - if request.GET.get('searching') is not None: - searching = json.loads(request.GET.get('searching')) + if request.GET.get("searching") is not None: + searching = json.loads(request.GET.get("searching")) if searching != True and searching != False: searching = False - datatable_default = DataTableView(column_names=('Name', 'Age', 'Job'), - rows=[('Bill', 30, 'contractor'), - ('Fred', 18, 'programmer'), - ('Bob', 26, 'boss')], + datatable_default = DataTableView(column_names=("Name", "Age", "Job"), + rows=[("Bill", 30, "contractor"), + ("Fred", 18, "programmer"), + ("Bob", 26, "boss")], searching=searching, orderClasses=False, lengthMenu=[ [10, 25, 50, -1], [10, 25, 50, "All"] ], ) - context = {'datatable_options': datatable_default} + context = {"datatable_options": datatable_default} - return render(request, 'app_name/datatable_ajax.html', context) + return render(request, "app_name/datatable_ajax.html", context) 2) A template for with the tethys gizmo (e.g. datatable_ajax.html) -:: + +.. code-block:: html+django {% load tethys_gizmos %} {% gizmo datatable_options %} -3) A url map to the controller in app.py -:: - - ... - UrlMap(name='datatable_ajax', - url='dam-break/datatable_ajax', - controller='dam_break.controllers.datatable_ajax'), - ... - +3) The AJAX call in the javascript -4) The AJAX call in the javascript -:: +.. code-block:: javascript $(function() { //wait for page to load diff --git a/docs/tethys_sdk/gizmos/map_view.rst b/docs/tethys_sdk/gizmos/map_view.rst index 5d28f1552..2662fdb68 100644 --- a/docs/tethys_sdk/gizmos/map_view.rst +++ b/docs/tethys_sdk/gizmos/map_view.rst @@ -247,12 +247,15 @@ This method is intended for initializing a map generated from an AJAX request. {% import_gizmo_dependency map_view %} {% endblock %} -Four elements are required: +Three elements are required: 1) A controller for the AJAX call with a map view gizmo. -:: - @login_required() +.. code-block:: python + + @controller( + url="dam-break/map/dam_break_map_ajax", + ) def dam_break_map_ajax(request): """ Controller for the dam_break_map ajax request. @@ -265,7 +268,7 @@ Four elements are required: # Define initial view for Map View view_options = MVView( - projection='EPSG:4326', + projection="EPSG:4326", center=[(bbox[0]+bbox[2])/2.0, (bbox[1]+bbox[3])/2.0], zoom=10, maxZoom=18, @@ -274,38 +277,31 @@ Four elements are required: # Configure the map map_options = MapView( - height='500px', - width='100%', + height="500px", + width="100%", layers=map_layer_list, - controls=['FullScreen'], + controls=["FullScreen"], view=view_options, - basemap=['OpenStreetMap'], + basemap=["OpenStreetMap"], legend=True, ) - context = { 'map_options': map_options } + context = { "map_options": map_options } - return render(request, 'dam_break_map_ajax/map_ajax.html', context) + return render(request, "dam_break_map_ajax/map_ajax.html", context) -2) A url map to the controller in app.py -:: +2) A template for with the tethys gizmo (e.g. map_ajax.html) - ... - UrlMap(name='dam_break_map_ajax', - url='dam-break/map/dam_break_map_ajax', - controller='dam_break.controllers.dam_break_map_ajax'), - ... - -3) A template for with the tethys gizmo (e.g. map_ajax.html) -:: +.. code-block:: html+django {% load tethys_gizmos %} {% gizmo map_options %} -4) The AJAX call in the javascript -:: +3) The AJAX call in the javascript + +.. code-block:: javascript $(function() { //wait for page to load diff --git a/docs/tethys_sdk/gizmos/plot_view.rst b/docs/tethys_sdk/gizmos/plot_view.rst index 2ab3f0b05..99be97da0 100644 --- a/docs/tethys_sdk/gizmos/plot_view.rst +++ b/docs/tethys_sdk/gizmos/plot_view.rst @@ -75,12 +75,15 @@ This method initializes a chart generated from an AJAX request. An example is de {% import_gizmo_dependency plot_view %} {% endblock %} -Four elements are required: +Three elements are required: 1) A controller for the AJAX call with a plot view gizmo. -:: - @login_required() +.. code-block:: python + + @controller( + url="dam-break/map/hydrograph", + ) def hydrograph_ajax(request): """ Controller for the hydrograph ajax request. @@ -91,30 +94,22 @@ Four elements are required: ... ) - context = {'flood_plot': flood_plot} + context = {"flood_plot": flood_plot} - return render(request, 'dam_break/hydrograph_ajax.html', context) + return render(request, "dam_break/hydrograph_ajax.html", context) 2) A template for with the tethys gizmo (e.g. hydrograph_ajax.html) -:: + +.. code-block:: html+django {% load tethys_gizmos %} {% gizmo flood_plot %} -3) A url map to the controller in app.py -:: - - ... - UrlMap(name='hydrograph_ajax', - url='dam-break/map/hydrograph', - controller='dam_break.controllers.hydrograph_ajax'), - ... +3) The AJAX call in the javascript - -4) The AJAX call in the javascript -:: +.. code-block:: javascript $(function() { //wait for page to load diff --git a/docs/tethys_sdk/gizmos/plotly_view.rst b/docs/tethys_sdk/gizmos/plotly_view.rst index b48fd52fe..afee113d2 100644 --- a/docs/tethys_sdk/gizmos/plotly_view.rst +++ b/docs/tethys_sdk/gizmos/plotly_view.rst @@ -4,7 +4,25 @@ Plotly View *********** -**Last Updated:** November 11, 2016 +**Last Updated:** August 2023 + + +.. important:: + + This gizmo requires the ``plotly`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``plotly`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge plotly + + # pip + pip install plotly + + **Don't Forget**: If you end up using this gizmo in your app, add ``plotly`` as a requirement to your :file:`install.yml`. + +Python +------ .. autoclass:: tethys_sdk.gizmos.PlotlyView @@ -28,16 +46,19 @@ to do so with PlotlyView. {% import_gizmo_dependency plotly_view %} {% endblock %} -Four elements are required: +Three elements are required: 1) A controller for the AJAX call with a PlotlyView gizmo. -:: + +.. code-block:: python from datetime import datetime import plotly.graph_objs as go from tethys_sdk.gizmos import PlotlyView + from tethys_sdk.routing import controller + - @login_required() + @controller(name='plotly_ajax', url='app-name/plotly') def plotly_ajax(request): """ Controller for the plotly ajax request. @@ -53,23 +74,16 @@ Four elements are required: return render(request, 'app_name/plotly_ajax.html', context) 2) A template for with the tethys gizmo (e.g. plotly_ajax.html) -:: + +.. code-block:: html+django {% load tethys_gizmos %} {% gizmo plotly_view_input %} -3) A url map to the controller in app.py -:: - - ... - UrlMap(name='plotly_ajax', - url='app_name/plotly', - controller='app_name.controllers.plotly_ajax'), - ... +3) The AJAX call in the javascript -4) The AJAX call in the javascript -:: +.. code-block:: javascript $(function() { //wait for page to load diff --git a/docs/tethys_sdk/gizmos/select_input.rst b/docs/tethys_sdk/gizmos/select_input.rst index b6e8eb51a..b3d9cc4f9 100644 --- a/docs/tethys_sdk/gizmos/select_input.rst +++ b/docs/tethys_sdk/gizmos/select_input.rst @@ -20,55 +20,51 @@ to do so with the SelectInput gizmo. in the ``import_gizmos`` block. For example: - :: + + .. code-block:: html+django {% block import_gizmos %} {% import_gizmo_dependency select_input %} {% endblock %} -Four elements are required: +Three elements are required: 1) A controller for the AJAX call with a SelectInput gizmo. -:: + +.. code-block:: python from tethys_sdk.gizmos import SelectInput - @login_required() + @controller( + url="app_name/select", + ) def select_input_ajax(request): """ Controller for the bokeh ajax request. """ - select_input2 = SelectInput(display_text='Select2', - name='select2', + select_input2 = SelectInput(display_text="Select2", + name="select2", multiple=False, - options=[('One', '1'), ('Two', '2'), ('Three', '3')], - initial=['Three']) + options=[("One", "1"), ("Two", "2"), ("Three", "3")], + initial=["Three"]) - context = {'select_input2': select_input2} + context = {"select_input2": select_input2} - return render(request, 'app_name/select_input_ajax.html', context) + return render(request, "app_name/select_input_ajax.html", context) 2) A template for with the tethys gizmo (e.g. select_input_ajax.html) -:: + +.. code-block:: html+django {% load tethys_gizmos %} {% gizmo select_input2 %} -3) A url map to the controller in app.py -:: - - ... - UrlMap(name='select_input_ajax', - url='app_name/select', - controller='app_name.controllers.select_input_ajax'), - ... - -4) The AJAX call in the javascript +3) The AJAX call in the javascript .. note:: You only need to call the init function if you are using select2. -:: +.. code-block:: javascript $(function() { //wait for page to load diff --git a/docs/tethys_sdk/gizmos/toggle_switch.rst b/docs/tethys_sdk/gizmos/toggle_switch.rst index cb5c3338c..b624650a7 100644 --- a/docs/tethys_sdk/gizmos/toggle_switch.rst +++ b/docs/tethys_sdk/gizmos/toggle_switch.rst @@ -26,46 +26,38 @@ to do so with the ToggleSwitch gizmo. {% import_gizmo_dependency toggle_switch %} {% endblock %} -Four elements are required: +Three elements are required: 1) A controller for the AJAX call with a ToggleSwitch gizmo. -:: + +.. code-block:: python import json - @login_required() + @controller def toggle_ajax(request): """ Controller for the datatable ajax request. """ - toggle_switch = ToggleSwitch(display_text='Defualt Toggle', - name='toggle1') + toggle_switch = ToggleSwitch(display_text="Defualt Toggle", + name="toggle1") - context = {'toggle_switch': toggle_switch} + context = {"toggle_switch": toggle_switch} - return render(request, 'app_name/toggle_ajax.html', context) + return render(request, "app_name/toggle_ajax.html", context) 2) A template for with the tethys gizmo (e.g. toggle_ajax.html) -:: + +.. code-block:: html+django {% load tethys_gizmos %} {% gizmo toggle_switch %} -3) A url map to the controller in app.py -:: - - ... - UrlMap(name='toggle_ajax', - url='app-name/toggle_ajax', - controller='app_name.controllers.toggle_ajax'), - ... - - +3) The AJAX call in the javascript -4) The AJAX call in the javascript -:: +.. code-block:: javascript $(function() { //wait for page to load diff --git a/docs/tethys_sdk/jobs/condor_job_type.rst b/docs/tethys_sdk/jobs/condor_job_type.rst index a95f51fc3..157d91df5 100644 --- a/docs/tethys_sdk/jobs/condor_job_type.rst +++ b/docs/tethys_sdk/jobs/condor_job_type.rst @@ -4,6 +4,18 @@ Condor Job Type **Last Updated:** January 2022 +.. important:: + + This feature requires the ``condorpy`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``condorpy`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge condorpy + + # pip + pip install condorpy + The :doc:`condor_job_type` (and :doc:`condor_workflow_type`) are used to create jobs to be run by a pool of cluster resources managed by HTCondor. HTCondor makes it possible for jobs to be offloaded from the main web server to a scalable computing cluster, which in turn enables very large scale jobs to be processed. diff --git a/docs/tethys_sdk/jobs/condor_workflow_type.rst b/docs/tethys_sdk/jobs/condor_workflow_type.rst index e950c1d5d..006e319ea 100644 --- a/docs/tethys_sdk/jobs/condor_workflow_type.rst +++ b/docs/tethys_sdk/jobs/condor_workflow_type.rst @@ -4,6 +4,18 @@ Condor Workflow Job Type **Last Updated:** January 2022 +.. important:: + + This feature requires the ``condorpy`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``condorpy`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge condorpy + + # pip + pip install condorpy + A Condor Workflow provides a way to run a group of jobs (which can have hierarchical relationships) as a single (Tethys) job. The hierarchical relationships are defined as parent-child relationships. For example, suppose a workflow is defined with three jobs: ``JobA``, ``JobB``, and ``JobC``, which must be run in that order. These jobs would be defined with the following relationships: ``JobA`` is the parent of ``JobB``, and ``JobB`` is the parent of ``JobC``. .. seealso:: diff --git a/docs/tethys_sdk/jobs/dask_job_type.rst b/docs/tethys_sdk/jobs/dask_job_type.rst index e0544c299..aac29db87 100644 --- a/docs/tethys_sdk/jobs/dask_job_type.rst +++ b/docs/tethys_sdk/jobs/dask_job_type.rst @@ -4,6 +4,18 @@ Dask Job Type **Last Updated:** January 2022 +.. important:: + + This feature requires the ``dask`` and ``tethys_dask_scheduler`` libraries to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install these libraries using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge dask tethys_dask_scheduler + + # pip + pip install dask tethys_dask_scheduler + A Dask Job Type wraps Dask functionality in a Tethys Jobs interface. The Tethys Dask Job type supports two different Dask APIs for creating Dask Tasks: ``dask.delayed`` and ``dask.distributed``. Dask Delayed diff --git a/docs/tethys_sdk/layouts/map_layout.rst b/docs/tethys_sdk/layouts/map_layout.rst index a0e09e784..a1a0da9d6 100644 --- a/docs/tethys_sdk/layouts/map_layout.rst +++ b/docs/tethys_sdk/layouts/map_layout.rst @@ -862,6 +862,11 @@ build_param_string .. automethod:: tethys_layouts.views.map_layout.MapLayout.build_param_string +convert_geojson_to_shapefile +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. automethod:: tethys_layouts.views.map_layout.MapLayout.convert_geojson_to_shapefile + JavaScript API Documentation ============================ diff --git a/docs/tethys_sdk/rest_api.rst b/docs/tethys_sdk/rest_api.rst index 5ced46055..1b11c02a9 100644 --- a/docs/tethys_sdk/rest_api.rst +++ b/docs/tethys_sdk/rest_api.rst @@ -4,40 +4,50 @@ REST API ******** -REST API's in Tethys Platform use token authentication -(see: http://www.django-rest-framework.org/api-guide/authentication/#tokenauthentication). +.. important:: -You can find the API token for your user on the user management page -(http://[HOST_Portal]/user/[username]/). + This feature requires the ``djangorestframework`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``djangorestframework`` using conda or pip as follows: -Example Url Map (app.py):: + .. code-block:: bash - UrlMap(name='api_get_data', - url='[your_app_name]/api/get_data', - controller='[your_app_name].api.get_data') + # conda: conda-forge channel strongly recommended + conda install -c conda-forge djangorestframework + # pip + pip install djangorestframework -Example API Controller (api.py):: + **Don't Forget**: If you end up using this feature in your app, add ``djangorestframework`` as a requirement to your :file:`install.yml`. + +REST API's in Tethys Platform use token authentication (see: http://www.django-rest-framework.org/api-guide/authentication/#tokenauthentication). + +You can find the API token for your user on the user management page (http://[HOST_Portal]/user/[username]/). + +Example API Controller (api.py): + +.. code-block:: python from django.http import JsonResponse from rest_framework.authentication import TokenAuthentication from rest_framework.decorators import api_view, authentication_classes + from tethys_sdk.routing import controller - @api_view(['GET']) + @controller(url='api/get-data') + @api_view(['GET', 'POST']) @authentication_classes((TokenAuthentication,)) - def get_data(request): - ''' - API Controller for getting data - ''' - name = request.GET.get('name') - data = {"name": name} - return JsonResponse(data) + def get_time_series(request): + """ + Controller for the get-time-series REST endpoint. + """ + name = request.GET.get('name', None) + response_data = {'name': name} + return JsonResponse(response_data) +Example Accessing Data: -Example Accessing Data:: +.. code-block:: python >>> import requests - >>> res = requests.get('http://[HOST_Portal]/apps/[your_app_name]/api/get_data?name=oscar', + >>> res = requests.get('http://[HOST_Portal]/apps/[your_app_name]/api/get-data?name=oscar', headers={'Authorization': 'Token asdfqwer1234'}) - >>> da.text - '{"name": "oscar"}' + >>> da.text + '{"name": "oscar"}' diff --git a/docs/tethys_sdk/routing.rst b/docs/tethys_sdk/routing.rst index deed522f4..09560170b 100644 --- a/docs/tethys_sdk/routing.rst +++ b/docs/tethys_sdk/routing.rst @@ -1,4 +1,4 @@ -.. _url_maps_api: +.. _routing_api: *********** Routing API @@ -126,9 +126,11 @@ A ``Bokeh Document`` comes with a ``Bokeh Request``. This request contains most .. important:: - To use the ``handler`` decorator you will need the ``bokeh_django`` package which is not installed by default. It can be installed with:: + To use the ``handler`` decorator you will need the ``bokeh`` and ``bokeh-django`` packages which may not be installed by default. They can be installed with: - conda install -c conda-forge -c erdc/label/dev bokeh-django openssl=1.1.1q + .. code-block:: bash + + conda install -c conda-forge -c erdc/label/dev bokeh bokeh-django .. tip:: For more information regarding Bokeh Server and available models visit the `Bokeh Server Documentation `_ and the `Bokeh model widgets reference guide `_. diff --git a/docs/tethys_sdk/templating.rst b/docs/tethys_sdk/templating.rst index 5cebc7823..3d754ceab 100644 --- a/docs/tethys_sdk/templating.rst +++ b/docs/tethys_sdk/templating.rst @@ -91,6 +91,17 @@ Examples: See the `Django Tag Reference `_ for a complete list of tags that Django provides. +Tethys Tags ++++++++++++ + +In addition to Django's library of template tags, Tethys also defines a few additional template tags that can be used in your templates. + +.. automodule:: tethys_apps.templatetags.humanize + :members: human_duration + +.. automodule:: tethys_apps.templatetags.app_theme + :members: lighten + Template Inheritance -------------------- diff --git a/docs/tethys_sdk/testing.rst b/docs/tethys_sdk/testing.rst index b29319b7e..8073e7ea7 100644 --- a/docs/tethys_sdk/testing.rst +++ b/docs/tethys_sdk/testing.rst @@ -49,6 +49,18 @@ https://docs.python.org/2.7/library/unittest.html#module-unittest Testing Controllers that Use OAuth2 Authentication ++++++++++++++++++++++++++++++++++++++++++++++++++ +.. important:: + + This feature requires the ``social-auth-app-django`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``social-auth-app-django`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge social-auth-app-django + + # pip + pip install social-auth-app-django + Using the ``force_login`` method above works great for testing controllers where login is required. However, additional steps are required to test controllers that must be authenticated with a specific OAuth2 provider (i.e. specify the ``ensure_oauth_provider`` argument to the ``controller`` decorator). For example, if you have a controller like this: .. code-block:: python diff --git a/docs/tethys_sdk/tethys_services/dataset_services.rst b/docs/tethys_sdk/tethys_services/dataset_services.rst index dda23082a..854e23eaa 100644 --- a/docs/tethys_sdk/tethys_services/dataset_services.rst +++ b/docs/tethys_sdk/tethys_services/dataset_services.rst @@ -4,6 +4,18 @@ Dataset Services API **Last Updated**: May 2017 +.. important:: + + This feature requires the ``tethys_dataset_services`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ```tethys_dataset_services`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge tethys_dataset_services + + # pip + pip install tethys_dataset_services + :term:`Dataset services` are web services external to Tethys Platform that can be used to store and publish file-based :term:`datasets` (e.g.: text files, Excel files, zip archives, other model files). Tethys app developers can use the Dataset Services API to access :term:`datasets` for use in their apps and publish any resulting :term:`datasets` their apps may produce. Supported options include `CKAN `_ and `HydroShare `_. Key Concepts @@ -119,13 +131,17 @@ After dataset services have been properly configured, you can use the services t 1. Get a Dataset Service Engine ------------------------------- -Call the ``get_dataset_service()`` method of the app class to get a ``DatasetEngine``:: +Call the ``get_dataset_service()`` method of the app class to get a ``DatasetEngine``: + +.. code-block:: python from my_first_app.app import MyFirstApp as app ckan_engine = app.get_dataset_service('primary_ckan', as_engine=True) -You can also create a ``DatasetEngine`` object directly. This can be useful if you want to vary the credentials for dataset access frequently (e.g.: using user specific credentials):: +You can also create a ``DatasetEngine`` object directly. This can be useful if you want to vary the credentials for dataset access frequently (e.g.: using user specific credentials): + +.. code-block:: python from tethys_dataset_services.engines import CkanDatasetEngine @@ -139,7 +155,9 @@ You can also create a ``DatasetEngine`` object directly. This can be useful if y 2. Use the Dataset Service Engine --------------------------------- -After you have a ``DatasetEngine``, simply call the desired method on it. All ``DatasetEngine`` methods return a dictionary with an item named ``'success'`` that contains a boolean. If the operation was successful, the value of ``'success'`` will be ``True``, otherwise it will be ``False``. If the value of ``'success'`` is ``True``, the dictionary will also contain an item named ``'result'`` that will contain the results. If it is ``False``, the dictionary will contain an item named ``'error'`` that will contain information about the error that occurred. This can be used for debugging purposes as illustrated in the following example:: +After you have a ``DatasetEngine``, simply call the desired method on it. All ``DatasetEngine`` methods return a dictionary with an item named ``'success'`` that contains a boolean. If the operation was successful, the value of ``'success'`` will be ``True``, otherwise it will be ``False``. If the value of ``'success'`` is ``True``, the dictionary will also contain an item named ``'result'`` that will contain the results. If it is ``False``, the dictionary will contain an item named ``'error'`` that will contain information about the error that occurred. This can be used for debugging purposes as illustrated in the following example: + +.. code-block:: python from my_first_app.app import MyFirstApp as app @@ -162,7 +180,7 @@ Use the dataset service engines references above for descriptions of the methods The HydroShare dataset engine uses OAuth 2.0 to authenticate and authorize interactions with the HydroShare via the REST API. This requires passing the ``request`` object as one of the arguments in ``get_dataset_engine()`` method call. Also, to ensure the user is connected to HydroShare, app developers must use the ``ensure_oauth2()`` decorator on any controllers that use the HydroShare dataset engine. For example: - :: + .. code-block:: python from tethys_sdk.services import get_dataset_engine, ensure_oauth2 from .app import MyFirstApp as app diff --git a/docs/tethys_sdk/tethys_services/persistent_store.rst b/docs/tethys_sdk/tethys_services/persistent_store.rst index b196fc55e..9f254956c 100644 --- a/docs/tethys_sdk/tethys_services/persistent_store.rst +++ b/docs/tethys_sdk/tethys_services/persistent_store.rst @@ -4,6 +4,18 @@ Persistent Stores API **Last Updated:** May 2017 +.. important:: + + This feature requires the ``psycopg2`` and ``sqlalchmey`` libraries to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install these libraries using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge psycopg2 "sqlalchemy<2" + + # pip + pip install psycopg2 "sqlalchemy<2" + The Persistent Store API streamlines the use of SQL databases in Tethys apps. Using this API, you can provision SQL databases for your app. The databases that will be created are `PostgreSQL `_ databases. Currently, no other databases are supported. The process of creating a new persistent database can be summarized in the following steps: diff --git a/docs/tethys_sdk/tethys_services/spatial_dataset_service/geoserver_reference.rst b/docs/tethys_sdk/tethys_services/spatial_dataset_service/geoserver_reference.rst index d18d6b335..7648148df 100644 --- a/docs/tethys_sdk/tethys_services/spatial_dataset_service/geoserver_reference.rst +++ b/docs/tethys_sdk/tethys_services/spatial_dataset_service/geoserver_reference.rst @@ -114,7 +114,7 @@ These links can be passed to a web mapping client like OpenLayers or Google Maps When you are learning how to use the spatial dataset engine methods, run the commands with the debug parameter set to true. This will automatically pretty print the result dictionary to the console so that you can inspect its contents: - :: + .. code-block:: python # Example method with debug option engine.list_layers(debug=True) diff --git a/docs/tethys_sdk/tethys_services/spatial_dataset_service/thredds_reference.rst b/docs/tethys_sdk/tethys_services/spatial_dataset_service/thredds_reference.rst index 2e6d301c6..57d7e6c58 100644 --- a/docs/tethys_sdk/tethys_services/spatial_dataset_service/thredds_reference.rst +++ b/docs/tethys_sdk/tethys_services/spatial_dataset_service/thredds_reference.rst @@ -6,6 +6,18 @@ THREDDS Engine (Siphon) Reference **Last Updated**: December 2019 +.. important:: + + This feature requires the ``siphon`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``siphon`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge siphon + + # pip + pip install siphon + This guide introduces `Siphon `_, the which is used as the engine for the THREDDS spatial dataset service. Siphon is a 3rd-party library developed by Unidata for interacting with data on remote services, currently focused on THREDDS services. Siphon does not implement the ``SpatialDatasetEngine`` pattern. Example Usage diff --git a/docs/tethys_sdk/tethys_services/spatial_dataset_services.rst b/docs/tethys_sdk/tethys_services/spatial_dataset_services.rst index 162b596b6..e25c059f4 100644 --- a/docs/tethys_sdk/tethys_services/spatial_dataset_services.rst +++ b/docs/tethys_sdk/tethys_services/spatial_dataset_services.rst @@ -7,6 +7,18 @@ Spatial Dataset Services API **Last Updated:** December 2019 +.. important:: + + This feature requires the ``tethys_dataset_services`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``tethys_dataset_services`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge tethys_dataset_services + + # pip + pip install tethys_dataset_services + Spatial dataset services are web services that can be used to store and publish file-based :term:`spatial datasets` (e.g.: Shapefile, GeoTiff, NetCDF). The spatial datasets published using spatial dataset services are made available in a variety of formats, many of which or more web friendly than the native format (e.g.: PNG, JPEG, GeoJSON, OGC Services). One example of a spatial dataset service is `GeoServer `_, which is capable of storing and serving vector and raster datasets in several popular formats including Shapefiles, GeoTiff, ArcGrid and others. GeoServer serves the data in a variety of formats via the `Open Geospatial Consortium (OGC) `_ standards including `Web Feature Service (WFS) `_, `Web Map Service (WMS) `_, and `Web Coverage Service (WCS) `_. @@ -123,13 +135,17 @@ After spatial dataset services have been properly configured, you can use the se 1. Get an Engine for the Spatial Dataset Service ------------------------------------------------ -Call the ``get_spatial_dataset_service()`` method of the app class to get the engine for the Spatial Dataset Service:: +Call the ``get_spatial_dataset_service()`` method of the app class to get the engine for the Spatial Dataset Service: + +.. code-block:: python from my_first_app.app import MyFirstApp as app geoserver_engine = app.get_spatial_dataset_service('primary_geoserver', as_engine=True) -You can also create a ``SpatialDatasetEngine`` object directly. This can be useful if you want to vary the credentials for dataset access frequently (e.g.: using user specific credentials):: +You can also create a ``SpatialDatasetEngine`` object directly. This can be useful if you want to vary the credentials for dataset access frequently (e.g.: using user specific credentials): + +.. code-block:: python from tethys_dataset_services.engines import GeoServerSpatialDatasetEngine diff --git a/docs/tethys_sdk/tethys_services/spatial_persistent_store.rst b/docs/tethys_sdk/tethys_services/spatial_persistent_store.rst index 028682102..51cfbaad9 100644 --- a/docs/tethys_sdk/tethys_services/spatial_persistent_store.rst +++ b/docs/tethys_sdk/tethys_services/spatial_persistent_store.rst @@ -4,6 +4,18 @@ Spatial Persistent Stores API **Last Updated:** May 2017 +.. important:: + + This feature requires the ``psycopg2``, ``sqlalchmey``, and ``geoalchemy2`` libraries to be installed. Starting with Tethys 5.0 or if you are using ```micro-tethys-platform``, you will need to install these libraries using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge psycopg2 "sqlalchemy<2" geoalchemy2 + + # pip + pip install psycopg2 "sqlalchemy<2" geoalchemy2 + Persistent store databases can support spatial data types. The spatial capabilities are provided by the `PostGIS `_ extension for the `PostgreSQL `_ database. PostGIS extends the column types of PostgreSQL databases by adding ``geometry``, ``geography``, and ``raster`` types. PostGIS also provides hundreds of database functions that can be used to perform spatial operations on data stored in spatial columns. For more information on PostGIS, see ``_. The following article details the the spatial capabilities of persistent stores in Tethys Platform. This article builds on the concepts and ideas introduced in the :doc:`./persistent_store` documentation. Please review it before continuing. diff --git a/docs/tethys_sdk/tethys_services/web_processing_services.rst b/docs/tethys_sdk/tethys_services/web_processing_services.rst index 05eb481a7..88561947a 100644 --- a/docs/tethys_sdk/tethys_services/web_processing_services.rst +++ b/docs/tethys_sdk/tethys_services/web_processing_services.rst @@ -9,7 +9,7 @@ Web Processing Services (WPS) are web services that can be used perform geoproce Web Processing Service Settings =============================== -Using web processing services in your app is accomplised by adding the ``web_processing_service_settings()`` method to your :term:`app class`, which is located in your :term:`app configuration file` (:file:`app.py`). This method should return a list or tuple of ``WebProcessingServiceSetting`` objects. For example: +Using web processing services in your app is accomplished by adding the ``web_processing_service_settings()`` method to your :term:`app class`, which is located in your :term:`app configuration file` (:file:`app.py`). This method should return a list or tuple of ``WebProcessingServiceSetting`` objects. For example: :: @@ -87,6 +87,18 @@ The ``WebProcessingServiceSetting`` can be thought of as a socket for a connecti Working with WPS Services in Apps ================================= +.. important:: + + This feature requires the ``owslib`` library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``owslib`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge owslib + + # pip + pip install owslib + The Web Processing Service API is powered by `OWSLib `_, a Python client that can be used to interact with OGC web services. For detailed explanations the WPS client provided by OWSLib, refer to the `OWSLib WPS Documentation `_. This article only provides a basic introduction to working with the OWSLib WPS client. Get a WPS Engine @@ -130,34 +142,3 @@ After you have retrieved a valid ``owslib.wps.WebProcessingService`` engine obje It is also possible to perform requests using data that are hosted on WFS servers, such as the GeoServer that is provided as part of the Tethys Platform software suite. See the `OWSLib WPS Documentation `_ for more details on how this is to be done. - -Web Processing Service Developer Tool -===================================== - -Tethys Platform provides a developer tool that can be used to browse the sitewide WPS services and the processes that they provide. This tool is useful for formulating new process requests. To use the tool: - -1. Browse to the Developer Tools page of your Tethys Platform by selecting the "Developer" link from the menu at the top of the page. - -2. Select the tool titled "Web Processing Services". - - .. figure:: ../../images/wps_tool/developer_tools_wps.png - :width: 600px - :align: center - -3. Select a WPS service from the list of services that are linked with your Tethys Instance. If no WPS services are linked to your Tethys instance, follow the steps in Sitewide Configuration, above, to setup a WPS service. - - .. figure:: ../../images/wps_tool/wps_tool_services.png - :width: 600px - :align: center - -4. Select the process you wish to view. - - .. figure:: ../../images/wps_tool/wps_tool_processes.png - :width: 600px - :align: center - -A description of the process and the inputs and outputs will be displayed. - - .. figure:: ../../images/wps_tool/wps_tool_buffer.png - :width: 600px - :align: center \ No newline at end of file diff --git a/docs/tutorials/bokeh.rst b/docs/tutorials/bokeh.rst index 67775948e..2ab56a74d 100644 --- a/docs/tutorials/bokeh.rst +++ b/docs/tutorials/bokeh.rst @@ -12,11 +12,11 @@ This tutorial introduces ``Bokeh Server`` integration concepts for Tethys develo * Handler functions using Bokeh Widgets * Handler functions using Param and Panel -Create a and install a new Tethys app named bokeh_tutorial. +Create and install a new Tethys app named bokeh_tutorial. :: - t + conda activate tethys tethys scaffold bokeh_tutorial cd tethysapp-bokeh_tutorial tethys install -d @@ -24,7 +24,42 @@ Create a and install a new Tethys app named bokeh_tutorial. 1. Bokeh Server =============== -``Bokeh`` is an interactive visualization library for Python. ``Bokeh Server`` is a component of the ``Bokeh`` architecture. It provides a way to sync model objects in Python on the backend to JavaScript model objects on the client. This is done by levering the ``Websocket`` protocol. With the addition of ``Django Channels`` to Tethys, this ability to sync backend python objects and frontend plots has also been integrated without the need of other components such as a ``Tornado`` server (see `Tethys Bokeh Integration documentation <../../tethys_sdk/url_maps.html#bokeh-integration>`_). This integration facilitates the linking of objects and ``Bokeh`` widgets as well as the creation of the necessary ``websocket`` and ``http`` ``consumers``. +``Bokeh`` is an interactive visualization library for Python. ``Bokeh Server`` is a component of the ``Bokeh`` architecture. It provides a way to sync model objects in Python on the backend to JavaScript model objects on the client. This is done by levering the ``Websocket`` protocol. With the addition of ``Django Channels`` to Tethys, this ability to sync backend python objects and frontend plots has also been integrated without the need of other components such as a ``Tornado`` server (see the Tethys Bokeh Integration documentation :ref:`bokeh_integration`). This integration facilitates the linking of objects and ``Bokeh`` widgets as well as the creation of the necessary ``websocket`` and ``http`` ``consumers``. + +To leverage the Bokeh integration with Tethys you will need the ``bokeh`` and ``bokeh-django`` libraries. + +1. Install the ``bokeh`` and ``bokeh-django`` libraries by running one of the following commands with your Tethys environment activated: + +.. code-block:: bash + + # conda: conda-forge channel strongly recommended for bokeh (the erdc/label/dev channel is currently needed for bokeh-django) + conda install -c conda-forge -c erdc/label/dev bokeh bokeh-django + + # pip + pip install bokeh bokeh-django + +2. Add the new dependencies to your :file:`install.yml` as follows so that the app will work when installed in a new environment: + +.. code-block:: yaml + + # This file should be committed to your app code. + version: 1.0 + # This should match the app - package name in your setup.py + name: bokeh_tutorial + + requirements: + # Putting in a skip true param will skip the entire section. Ignoring the option will assume it be set to False + skip: false + conda: + channels: + - conda-forge + packages: + - bokeh + - bokeh_django + + pip: + + post: The logic for creating a Bokeh widget along with other related functionality is provided in a ``handler function``. This handler will be associated to a specific ``controller function`` where the resulting Bokeh widget will be displayed in a later step. @@ -33,7 +68,7 @@ The logic for creating a Bokeh widget along with other related functionality is Let's use Bokeh's sea temperature sample data to create a time series plot and link it to a slider that will provide the value to perform a rolling-window analysis on the time series. This example is based on a similar example in Bokeh's main documentation. -1. Create a ``handler function`` by adding the following imports and logic to ``controller.py``. +1. Create a ``handler function`` by adding the following imports and logic to ``handlers.py``. .. code-block:: Python @@ -41,9 +76,13 @@ Let's use Bokeh's sea temperature sample data to create a time series plot and l from bokeh.models import ColumnDataSource from bokeh.sampledata.sea_surface_temperature import sea_surface_temperature - ... + from tethys_sdk.routing import handler - def home_handler(document): + + @handler( + template="bokeh_tutorial/home.html", + ) + def home(document): df = sea_surface_temperature.copy() source = ColumnDataSource(data=df) @@ -53,65 +92,31 @@ Let's use Bokeh's sea temperature sample data to create a time series plot and l document.add_root(plot) -This simple handler contains the logic for a time series plot of the sea surface temperature sample data provided by ``Bokeh``. - -2. Clear the default home function in ``controller.py`` and add the following code to it. - -.. code-block:: Python - - from bokeh.embed import server_document - - @login_required() - def home(request): - script = server_document(request.build_absolute_uri()) - context = {'script': script} - return render(request, 'bokeh_tutorial/home.html', context) - -The home controller can now load the time series plot from (a) using the Bokeh ``server_document`` function. However, we still need to link the ``handler`` and the ``controller`` in the ``app.py``, and add the script context variable to the template as with any other variable. - -3. Modify ``app.py`` by adding a dot-formatted path to the handler function created in (1) to the ``handler`` parameter and providing a ``handler_type`` with a value equal to 'bokeh' as shown in the code below. - -.. code-block:: Python - - from tethys_sdk.base import TethysAppBase +This simple handler contains the logic for a time series plot of the sea surface temperature sample data provided by ``Bokeh``. The ``handler`` decorator marks this function as a handler. It auto generates a default ``controller function`` that is linked to the handler. A default template can also be used, but we specified a custom template using the ``template`` argument to the ``handler`` decorator. The ``handler`` decorator also sets up the routing. By default the route name and the URL are derived from the ``handler function`` name (in this case ``home``). For more information about the ``handler`` decorator and additional arguments that can be passed see :ref:`handler-decorator`. Since this default controller is sufficient, we don't need to create a custom controller and can just delete the ``controller.py`` file. +2. Delete the ``controller.py`` file. - class BokehTutorial(TethysAppBase): - """ - Tethys app class for Bokeh Tutorial. - """ - - name = 'Bokeh Tutorial' - index = 'bokeh_tutorial:home' - icon = 'bokeh_tutorial/images/icon.gif' - package = 'bokeh_tutorial' - root_url = 'bokeh-tutorial' - color = '#2980b9' - description = '' - tags = '' - enable_feedback = False - feedback_emails = [] - -4. Clear the default ``home.html`` template and add the following code to it. +3. Clear the default ``home.html`` template and add the following code to it. .. code-block:: html+django {% extends "bokeh_tutorial/base.html" %} - {% load tethys_gizmos %} {% block app_content %}

Bokeh Integration Example

{{ script|safe }} {% endblock %} -As you can see, the script context variable has been added to the app_content block. If you start tethys and go to the home page of this app you should see something like this: +As you can see, a ``script`` context variable has been added to the app_content block. The default ``controller function`` defines this script which handles loading the content specified in the ``handler function``. We customized the template by adding in a heading which will render above the content from the ``handler function``. + +If you start tethys and go to the home page of this app you should see something like this: .. figure:: ../images/tutorial/bokeh_integration/bokeh_integration_1.png :width: 650px This is a simple Bokeh plot. We will now add the rest of the logic to make it an interactive plot. We will add a ``Slider`` widget. Then, we will create a callback function to modify the time-series plot based on the slider. Finally, we will add both our plot and slider to the document tree using a ``Column`` layout. -5. Modify the ``handler function`` from ``controller.py`` to look like this. +5. Modify the ``handler function`` from ``handlers.py`` to look like this. .. code-block:: python @@ -156,7 +161,7 @@ The ``Slider`` and ``Plot`` will appear in the order they were added to the ``Co In this example we will build on top of the ``bokeh_tutorial`` app to demonstrate how to use ``Param`` and ``Panel`` in combination with ``bokeh Server``. This same example can be found in `Panel's documentation `_. -1. Install the ``param`` library by running the following with your Tethys environment activated: +1. Install the ``param`` and ``panel`` libraries by running the following with your Tethys environment activated: .. code-block:: bash @@ -178,6 +183,8 @@ In this example we will build on top of the ``bokeh_tutorial`` app to demonstrat channels: - conda-forge packages: + - bokeh + - bokeh_django - panel - param @@ -210,6 +217,8 @@ In this example we will build on top of the ``bokeh_tutorial`` app to demonstrat return [], [] def view(self): + if not self.figure.renderers: + self.__init__(name=self.name) return self.figure @@ -253,82 +262,45 @@ In this example we will build on top of the ``bokeh_tutorial`` app to demonstrat def title(self): return '## %s (radius=%.1f)' % (type(self.shape).__name__, self.shape.radius) + @param.depends('shape') + def controls(self): + return pn.Param(self.shape) + def panel(self): - return pn.Column(self.title, self.view) + expand_layout = pn.Column() + + return pn.Column( + pn.pane.HTML('

Bokeh Integration Example using Param and Panel

'), + pn.Row( + pn.Column( + pn.panel(self.param, expand_button=False, expand=True, expand_layout=expand_layout), + "#### Subobject parameters:", + expand_layout), + pn.Column(self.title, self.view) + ), + sizing_mode='stretch_width', + ) The added classes depend on ``Bokeh``. The `Circle` and `NGon` classes depend on the `Shape` class, while the `ShapeViewer` allows the user to pick one of the two available shapes. -4. Add a ``handler function`` that uses the classes created in the previous step by adding the following code to ``controller.py``. +4. Add a ``handler function`` that uses the classes created in the previous step by adding the following code to ``handlers.py``. .. code-block:: python - import panel as pn from .param_model import ShapeViewer ... - def shapes_handler(document): - viewer = ShapeViewer() - panel = pn.Row(viewer.param, viewer.panel()) - panel.server_doc(document) - -5. Add a ``controller function`` to pass the ``Panel`` object to a template and to link it with the ``handler`` created in the previous step. + @handler( + app_package='bokeh_tutorial', + ) + def shapes(document): + viewer = ShapeViewer().panel() + viewer.server_doc(document) -.. code-block:: python - - def shapes_with_panel(request): - script = server_document(request.build_absolute_uri()) - context = {'script': script} - return render(request, "bokeh_tutorial/shapes.html", context) - -6. Create a new ``UrlMap`` in ``app.py`` to link the new ``handler-controller pair`` to an endpoint. - -.. code-block:: python - - def url_maps(self): - """ - Add controllers - """ - UrlMap = url_map_maker(self.root_url) - - url_maps = ( - UrlMap( - name='home', - url='bokeh-tutorial', - controller='bokeh_tutorial.controllers.home', - handler='bokeh_tutorial.controllers.home_handler', - handler_type='bokeh' - ), - UrlMap( - name='shapes', - url='bokeh-tutorial/shapes', - controller='bokeh_tutorial.controllers.shapes_with_panel', - handler='bokeh_tutorial.controllers.shapes_handler', - handler_type='bokeh' - ), - ) - - return url_maps - -7. Add a new template to match the path rendered in the new ``controller`` from (c) (`bokeh_tutorial/shapes.html`). - -.. code-block:: html+django - - {% extends "bokeh_tutorial/base.html" %} - {% load tethys_gizmos %} - - {% block header_buttons %} -
- -
- {% endblock %} - - {% block app_content %} -

Bokeh Integration Example using Param and Panel

- {{ script|safe }} - {% endblock %} +Note that in this case we are not using a custom template, but we add the ``app_package`` argument to the the ``handler`` decorator so that the default template that Tethys uses will inherit from the ``base.html`` template from our app. -8. To add the new endpoint to the app navigation bar, go to the ``base.html`` template and replace the ``app_navigation`` block content with the code below. +5. To add the new endpoint to the app navigation bar, go to the ``base.html`` template and replace the ``app_navigation`` block content with the code below. .. code-block:: html+django diff --git a/docs/tutorials/dask/new_app_project.rst b/docs/tutorials/dask/new_app_project.rst index 73781c024..76718e378 100644 --- a/docs/tutorials/dask/new_app_project.rst +++ b/docs/tutorials/dask/new_app_project.rst @@ -4,8 +4,8 @@ New Tethys App Project **Last Updated:** May 2022 -1. Setting up the scaffold -========================== +1. Generate Scaffold +==================== Tethys Platform provides an easy way to create new app projects called a scaffold. The scaffold generates a Tethys app project with the minimum files and the folder structure that is required (see :doc:`../../supplementary/app_project`). @@ -23,20 +23,73 @@ b. Scaffold a new app named ``dask_tutorial``: tethys scaffold dask_tutorial -c. Install the app in development mode: +2. Add App Dependencies to :file:`install.yml` +============================================== + +App dependencies should be managed using the :file:`install.yml` instead of the :file:`setup.py`. This app will require the ``dask`` and ``tethys_dask_scheduler`` packages. Both packages are available on ``conda-forge``, which is the preferred Conda channel for Tethys. Open :file:`tethysapp-dask_tutorial/install.yml` and add these dependencies to the ``requirements.conda`` section of the file: + +.. code-block:: yaml + + # This file should be committed to your app code. + version: 1.0 + # This should match the app - package name in your setup.py + name: dask_tutorial + + requirements: + # Putting in a skip true param will skip the entire section. Ignoring the option will assume it be set to False + skip: false + conda: + channels: + - conda-forge + packages: + - dask + - tethys_dask_scheduler + + pip: + + post: + +3. Development Installation +=========================== + +Install the app and it's dependencies into your development Tethys Portal. In a terminal, change into the :file:`tethysapp-dask_tutorial` directory and execute the :command:`tethys install -d` command. + +.. code-block:: bash + + cd tethysapp-dask_tutorial + tethys install -d + +5. View Your New App +==================== + +1. Start up the development server to view the new app: + +.. code-block:: bash + + tethys manage start + +.. tip:: + + To stop the development server press :kbd:`CTRL-C`. + + If you get errors related to Tethys not being able to connect to the database, start the database by running: .. code-block:: bash - cd tethysapp-dask_tutorial - tethys install -d + tethys db start -d. Start the Tethys development server: + You can also stop the Tethys database by running: .. code-block:: bash - tethys manage start + tethys db stop + +2. Browse to ``_ in a web browser and login. The default portal user is: + +* **username**: admin +* **password**: pass -2. Dask +6. Dask ======= Documentation for Dask may be found at ``_ diff --git a/docs/tutorials/google_earth_engine/part_1/new_app_project.rst b/docs/tutorials/google_earth_engine/part_1/new_app_project.rst index e8dfc87c8..15edef36a 100644 --- a/docs/tutorials/google_earth_engine/part_1/new_app_project.rst +++ b/docs/tutorials/google_earth_engine/part_1/new_app_project.rst @@ -56,17 +56,17 @@ App dependencies should be managed using the :file:`install.yml` instead of the name: earth_engine requirements: - # Putting in a skip true param will skip the entire section. Ignoring the option will assume it be set to False - skip: false - conda: - channels: - - conda-forge - packages: - - earthengine-api - - oauth2client - pip: - - npm: + # Putting in a skip true param will skip the entire section. Ignoring the option will assume it be set to False + skip: false + conda: + channels: + - conda-forge + packages: + - earthengine-api + - oauth2client + pip: + + npm: post: diff --git a/docs/tutorials/google_earth_engine/part_2/file_upload.rst b/docs/tutorials/google_earth_engine/part_2/file_upload.rst index f9ec615b6..9d1f32461 100644 --- a/docs/tutorials/google_earth_engine/part_2/file_upload.rst +++ b/docs/tutorials/google_earth_engine/part_2/file_upload.rst @@ -395,12 +395,16 @@ Now that the file is written to disk, use the built-in ``zipfile`` module to ver In this step you will add the logic to validate that the file contained in the ZIP archive is a shapefile. You will use the ``pyshp`` library to do this, which will introduce a new dependency for the app. -1. Install ``pyshp`` library into your Tethys conda environment. Run the following command in the terminal with your Tethys environment activated: +1. Install ``pyshp`` library into your Tethys conda environment using conda or pip. Run the following command in the terminal with your Tethys environment activated: .. code-block:: bash + # conda: conda-forge channel highly recommended conda install -c conda-forge pyshp + # pip + pip install pyshp + 2. Add ``pyshp`` as a new dependency in the ``install.yml``: .. code-block:: yaml diff --git a/docs/tutorials/google_earth_engine/part_2/rest_api.rst b/docs/tutorials/google_earth_engine/part_2/rest_api.rst index 288896242..4658ff874 100644 --- a/docs/tutorials/google_earth_engine/part_2/rest_api.rst +++ b/docs/tutorials/google_earth_engine/part_2/rest_api.rst @@ -27,7 +27,52 @@ If you wish to use the previous solution as a starting point: cd tethysapp-earth_engine git checkout -b clip-by-asset-solution clip-by-asset-solution-|version| -1. Reorganize Controller Functions into Separate Files +1. Install dependencies +======================= + +The REST API capability requires ``djangorestframework`` to be installed. Install it using conda or pip as follows: + +.. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge djangorestframework + + # pip + pip install djangorestframework + +2. Add dependencies to install.yml +================================== + +Add ``djangorestframework`` to the ``install.yml`` file to ensure it is installed when your app is installed as follows: + +.. code-block:: yaml + + # This file should be committed to your app code. + version: 1.0 + # This should be greater or equal to your tethys-platform in your environment + tethys_version: ">=4.0.0" + # This should match the app - package name in your setup.py + name: earth_engine + + requirements: + # Putting in a skip true param will skip the entire section. Ignoring the option will assume it be set to False + skip: false + conda: + channels: + - conda-forge + packages: + - earthengine-api + - oauth2client + - geojson + - pyshp + - djangorestframework + pip: + + npm: + + post: + +3. Reorganize Controller Functions into Separate Files ====================================================== The :file:`controllers.py` file is beginning to get quite long. To make the controller code more manageable, in this step you will refactor the controllers into several files. @@ -132,7 +177,7 @@ The :file:`controllers.py` file is beginning to get quite long. To make the cont 6. Navigate to ``_ and verify that the app functions as it did before the change. -2. Create New Controller for REST API Endpoint +4. Create New Controller for REST API Endpoint ============================================== REST endpoints are similar to normal controllers. The primary difference is that they typically return data using JSON or XML format instead of HTML. In this step you will create a new controller function for the REST endpoint. @@ -168,7 +213,7 @@ REST endpoints are similar to normal controllers. The primary difference is that 3. Navigate to ``_. You should see an API page that is auto generated by the `Django REST Framework `_ titled **Get Time Series**. The page should display an *HTTP 401 Unauthorized* error and display a result object with detail "Authentication credentials were not provided." -3. Test with Postman Application +5. Test with Postman Application ================================ Most web browsers are surprisingly limited when it comes to testing REST APIs. The reason the test in the previous step resulted in a *401 Unauthorized* is because we sent a request without an authentication token. To more easily test this, you'll want to get a REST client that will allow you to set request headers and parameters. In this tutorial you will use the Postman client to test the REST API as you develop it. @@ -191,7 +236,7 @@ Most web browsers are surprisingly limited when it comes to testing REST APIs. T 9. Press the **Send** button. You should see the same response object as before with the "Authentication credentials were not provided." message. -4. Add Token Authorization Headers to Postman Request +6. Add Token Authorization Headers to Postman Request ===================================================== In this step you will retrieve the API token for your user account and set authentication headers on the request. @@ -210,7 +255,7 @@ In this step you will retrieve the API token for your user account and set authe 7. Press the **Save** button to save your changes to the Postman request. -5. Define Parameters for REST API +7. Define Parameters for REST API ================================= In this step you'll define the parameters that the REST endpoint will accept. If you think of the REST endpoint as a function, then the parameters are like the arguments to the function. The controller will be configured to work with both the ``GET`` and ``POST`` methods for illustration purposes. @@ -292,7 +337,7 @@ In this step you'll define the parameters that the REST endpoint will accept. If 6. Press the **Save** button to save your changes to the Postman request. -6. Validate Platform, Sensor, Product, and Index +8. Validate Platform, Sensor, Product, and Index ================================================ In this step you'll add the validation logic for the ``platform``, ``sensor``, ``product``, and ``index`` parameters. The REST endpoint is like a function shared publicly on the internet--anyone can call it with whatever parameters they want. This includes bots that may try to exploit your website through its REST endpoints. Be sure to only allow valid values through and provide helpful feedback for users of the REST API. @@ -396,7 +441,7 @@ In this step you'll add the validation logic for the ``platform``, ``sensor``, ` 9. Repeat this process, adding first the ``sensor`` parameter, then the ``product`` parameter to confirm that the validation logic is working as expected. -7. Validate Dates +9. Validate Dates ================= In this step you'll add the validation logic for the ``start_date`` and ``end_date`` parameters. There is logic that already exists in the ``viewer`` controller that you can use to validate the date parameters in our REST API function. However, you should avoid copying code to prevent duplicating bugs and make the app easier to maintain. Instead, you will generalize the bit of code from the ``viewer`` controller into a helper function and then use that function in both the ``viewer`` controller and the ``get_time_series`` controller. @@ -581,8 +626,8 @@ In this step you'll add the validation logic for the ``start_date`` and ``end_da * ``start_date`` and ``end_date`` outside of valid range of selected product (see :file:`gee/products.py`) * Incorrect date format given for either date parameter -8. Validate Reducer, Orient, and Scale -====================================== +10. Validate Reducer, Orient, and Scale +======================================= In this step you'll add the validation logic for the ``reducer``, ``orient``, and ``scale`` parameters. The ``reducer`` and ``orient`` parameters each have a short list of valid options and the ``scale`` parameter needs to be a number. @@ -648,8 +693,8 @@ In this step you'll add the validation logic for the ``reducer``, ``orient``, an 8. Change ``scale`` to a valid value other than the default (e.g.: ``150``). Verify this value is returned. -9. Validate Geometry -==================== +11. Validate Geometry +===================== In this step you'll add the logic to validate the ``geometry`` parameter, which should be valid GeoJSON. An optimistic strategy will be used in which an attempt will be made to convert the string into a GeoJSON object. If it fails, then the given string is not valid GeoJSON and an error will be returned. @@ -703,7 +748,7 @@ In this step you'll add the logic to validate the ``geometry`` parameter, which When pasting the ``geometry`` value from above, ensure that there are no new lines / returns after (i.e. press Backspace after pasting). -10. Reuse Existing Helper Function to Get Time Series +12. Reuse Existing Helper Function to Get Time Series ===================================================== With the parameters properly vetted, you are now ready to call the ``get_time_series_from_image_collection`` function. It should be a fairly straightforward call of the function, mapping the REST parameters to the arguments of the function. You will need to make a few minor changes to the function, however, to accommodate the new ``orient`` option. @@ -832,7 +877,7 @@ With the parameters properly vetted, you are now ready to call the ``get_time_se 4. Press the **Send** button to submit the request and verify that the time series is included in the response object. -11. Test & Verify +13. Test & Verify ================= 1. Use Postman to try different values for each of the parameters. Use some that are valid and others that are not to ensure the validation is working. @@ -941,7 +986,7 @@ With the parameters properly vetted, you are now ready to call the ``get_time_se } } -12. Solution +14. Solution ============ This concludes this portion of the GEE Tutorial. You can view the solution on GitHub at ``_ or clone it as follows: diff --git a/docs/tutorials/key_concepts/advanced.rst b/docs/tutorials/key_concepts/advanced.rst index c66bf3ff0..e17b9c3ce 100644 --- a/docs/tutorials/key_concepts/advanced.rst +++ b/docs/tutorials/key_concepts/advanced.rst @@ -33,7 +33,43 @@ If you wish to use the intermediate solution as a starting point: In the :doc:`./intermediate` tutorial we implemented a file-based database as the persisting mechanism for the app. However, simple file based databases typically don't perform well in a web application environment, because of the possibility of many concurrent requests trying to access the file. In this section we'll refactor the Model to use an SQL database, rather than files. -a. Open the ``app.py`` and define a new ``PersistentStoreDatabaseSetting`` by adding the ``persistent_store_settings`` method to your app class: +a. Add necessary dependencies: + +Persistent stores is an optional feature in Tethys, and requires that the ``sqlalchemy<2`` and ``psycopg2`` libraries are installed. Install these libraries using one of the following commands: + +.. code-block:: console + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge "sqlalchemy<2" psycopg2 + + # pip + pip install "sqlalchemy<2" psycopg2 + +Now add the new dependencies to your :file:`install.yml` as follows so that the app will work when installed in a new environment: + +.. code-block:: yaml + + # This file should be committed to your app code. + version: 1.0 + # This should match the app - package name in your setup.py + name: dam_inventory + + requirements: + # Putting in a skip true param will skip the entire section. Ignoring the option will assume it be set to False + skip: false + conda: + channels: + - conda-forge + packages: + - sqlalchemy<2 + - psycopg2 + + pip: + + post: + + +b. Open the ``app.py`` and define a new ``PersistentStoreDatabaseSetting`` by adding the ``persistent_store_settings`` method to your app class: .. code-block:: python @@ -62,7 +98,7 @@ a. Open the ``app.py`` and define a new ``PersistentStoreDatabaseSetting`` by ad Tethys provides the library SQLAlchemy as an interface with SQL databases. SQLAlchemy provides an Object Relational Mapper (ORM) API, which allows data models to be defined using Python and an object-oriented approach. With SQLAlchemy, you can harness the power of SQL databases without writing SQL. As a primer to SQLAlchemy ORM, we highly recommend you complete the `Object Relational Tutorial `_. -b. Define a table called ``dams`` by creating a new class in ``model.py`` called ``Dam``: +c. Define a table called ``dams`` by creating a new class in ``model.py`` called ``Dam``: .. code-block:: python @@ -102,7 +138,7 @@ b. Define a table called ``dams`` by creating a new class in ``model.py`` called For more information on Persistent Stores, see: :doc:`../../tethys_sdk/tethys_services/persistent_store`. -c. Replace the ``add_new_dam`` and ``get_all_dams`` functions in ``model.py`` with versions that use the SQL database instead of the files: +d. Replace the ``add_new_dam`` and ``get_all_dams`` functions in ``model.py`` with versions that use the SQL database instead of the files: .. code-block:: python @@ -156,7 +192,7 @@ c. Replace the ``add_new_dam`` and ``get_all_dams`` functions in ``model.py`` wi Don't forget to close your ``session`` objects when you are done. Eventually you will run out of connections to the database if you don't, which will cause unsightly errors. -d. Create a new function called ``init_primary_db`` at the bottom of ``model.py``. This function is used to initialize the database by creating the tables and adding any initial data. +e. Create a new function called ``init_primary_db`` at the bottom of ``model.py``. This function is used to initialize the database by creating the tables and adding any initial data. .. code-block:: python @@ -198,7 +234,7 @@ d. Create a new function called ``init_primary_db`` at the bottom of ``model.py` session.commit() session.close() -e. Refactor ``home`` controller in ``controllers.py`` to use the updated model methods: +f. Refactor ``home`` controller in ``controllers.py`` to use the updated model methods: .. code-block:: python :emphasize-lines: 1-2, 7, 13-14, 19-20, 23-27 @@ -236,7 +272,7 @@ e. Refactor ``home`` controller in ``controllers.py`` to use the updated model m ... -f. Refactor the ``add_dam`` controller to use the updated model methods: +g. Refactor the ``add_dam`` controller to use the updated model methods: .. code-block:: python :emphasize-lines: 1-2, 52 @@ -299,7 +335,7 @@ f. Refactor the ``add_dam`` controller to use the updated model methods: ... -g. Refactor the ``list_dams`` controller to use updated model methods: +h. Refactor the ``list_dams`` controller to use updated model methods: .. code-block:: python :emphasize-lines: 1-2, 6, 12-13 @@ -322,7 +358,7 @@ g. Refactor the ``list_dams`` controller to use updated model methods: ... -h. Add a **Persistent Store Service** to Tethys Portal: +i. Add a **Persistent Store Service** to Tethys Portal: a. Go to Tethys Portal Home in a web browser (e.g. http://localhost:8000/apps/) b. Select **Site Admin** from the drop down next to your username. @@ -341,7 +377,7 @@ h. Add a **Persistent Store Service** to Tethys Portal: The username and password for the persistent store service must be a superuser to use spatial persistent stores. Note that the default installation of Tethys Portal includes a superuser named "tethys_super", password: "pass". -9. Assign the new **Persistent Store Service** to the Dam Inventory App: +j. Assign the new **Persistent Store Service** to the Dam Inventory App: a. Go to Tethys Portal Home in a web browser (e.g. http://localhost:8000/apps/) b. Select **Site Admin** from the drop down next to your username. @@ -355,7 +391,7 @@ h. Add a **Persistent Store Service** to Tethys Portal: :width: 100% :align: center -j. Execute the **syncstores** command to create the tables in the Persistent Store database: +k. Execute the **syncstores** command to create the tables in the Persistent Store database: .. code-block:: bash diff --git a/docs/tutorials/key_concepts/beginner.rst b/docs/tutorials/key_concepts/beginner.rst index 19e697d86..e3e1a39e5 100644 --- a/docs/tutorials/key_concepts/beginner.rst +++ b/docs/tutorials/key_concepts/beginner.rst @@ -103,7 +103,7 @@ Tethys apps are developed using the :term:`Model View Controller` (MVC) software 4. Views ======== -Views for Tethys apps are constructed using the standard web programming tools: HTML, JavaScript, and CSS. Additionally, HTML templates can use the Django Template Language, because Tethys Platform is build on Django. This allows you to insert Python code into your HTML documents making the web pages of your app dynamic and reusable. +Views for Tethys apps are constructed using the standard web programming tools: HTML, JavaScript, and CSS. Additionally, HTML templates can use the Django Template Language, because Tethys Platform is build on Django. This allows you to coding logic into your HTML documents, using template tags, making the web pages of your app dynamic and reusable. a. Open ``/templates/dam_inventory/home.html`` and replace it's contents with the following: @@ -223,7 +223,7 @@ c. Save your changes to ``map.css`` and ``home.html`` and refresh the page to vi 7. Create a New Page ==================== -Creating a new page in your app consists of three steps: (1) create a new template, (2) add a new controller to ``controllers.py``, and (3) add a new ``UrlMap`` to the ``app.py``. +Creating a new page in your app consists of three steps: (1) create a new template, (2) add a new controller to ``controllers.py``, and (3) define the routing using the ``controller`` decorator. a. Create a new file ``/templates/dam_inventory/add_dam.html`` and add the following contents: @@ -248,7 +248,7 @@ b. Create a new controller function called ``add_dam`` at the bottom of the ``co This is the most basic controller function you can write: a function that accepts an argument called ``request`` and a return value that is the result of the ``render`` function. The ``render`` function renders the Django template into valid HTML using the ``request`` and ``context`` provided. - Notice the use of the ``url`` argument in the ``controller`` decorator. The default URL that would have been generated without this argument would have been ``'add-dam'``. The ``url`` argument is used to provide a custom URL for a controller. URLs are defined relative to the root URL of the app. The full URL for the ``add_dam`` controller as shown above is ``'/apps/dam-inventory/dams/add/'``. + Notice the use of the ``url`` argument in the ``controller`` decorator. The ``controller`` decorator creates a route that maps a URL to this controller function. The default URL that would have been generated without this argument would have been ``'add-dam'``. The ``url`` argument is used to provide a custom URL for a controller. URLs are defined relative to the root URL of the app. The full URL for the ``add_dam`` controller as shown above is ``'/apps/dam-inventory/dams/add/'``. Also note that the name of the route created by the ``controller`` decorator is, by default, the same as the function name (``add_dam``). The name of the route will be important when we need to reference it in a template. c. At this point you should be able to access the new page by entering its URL (``_) into the address bar of your browser. It is not a very exciting page, because it is blank. @@ -363,7 +363,7 @@ b. Modify ``app_navigation_items`` block in ``/templates/dam_inventory/base.html {% endblock %} - The ``url`` tag is used in templates to lookup URLs using the name of the UrlMap, namespaced by the app package name (i.e.: ``namespace:url_map_name``). We assign the urls to two variables, ``home_url`` and ``add_dam_url``, using the ``as`` operator in the ``url`` tag. Then we wrap the ``active`` class of each navigation link in an ``if`` tag. If the expression given to an ``if`` tag evaluates to true, then the content of the ``if`` tag is rendered, otherwise it is left blank. In this case the result is that the ``active`` class is only added to link of the page we are visiting. + The ``url`` tag is used in templates to lookup URLs using the name of the route (as defined in by the ``controller`` decorator), namespaced by the app package name (i.e.: ``namespace:url_map_name``). We assign the URLs to two variables, ``home_url`` and ``add_dam_url``, using the ``as`` operator in the ``url`` tag. Then we wrap the ``active`` class of each navigation link in an ``if`` tag. If the expression given to an ``if`` tag evaluates to true, then the content of the ``if`` tag is rendered, otherwise it is left blank. In this case the result is that the ``active`` class is only added to link of the page we are visiting. 11. Solution ============ diff --git a/docs/tutorials/key_concepts/intermediate.rst b/docs/tutorials/key_concepts/intermediate.rst index 84740e0d1..7265531ae 100644 --- a/docs/tutorials/key_concepts/intermediate.rst +++ b/docs/tutorials/key_concepts/intermediate.rst @@ -557,7 +557,7 @@ c. Create a new controller function in ``controllers.py`` called ``list_dams``: .. note:: - The ``name`` argument can be used to set a custom name for the URL of a controller as shown above. The default name is the same name as the controller function. This name is used to look up the URL of the controller using either the ``url`` tag in templates (see next step) or the ``reverse`` function in Python code. + The ``name`` argument can be used to set a custom name for the route that maps a URL to a controller as shown above. The default name is the same name as the controller function. This name is used to look up the URL of the controller using either the ``url`` tag in templates (see next step) or the ``reverse`` function in Python code. d. Open ``/templates/dam_inventory/base.html`` and add navigation links for the List View page: diff --git a/docs/tutorials/map_layout/data_prep.rst b/docs/tutorials/map_layout/data_prep.rst index 9ac157d2f..3d0a9b1e8 100644 --- a/docs/tutorials/map_layout/data_prep.rst +++ b/docs/tutorials/map_layout/data_prep.rst @@ -73,7 +73,7 @@ This file will define the conda environment required to run the reproject script 4. Paste the following into the file: -.. code-block:: yml +.. code-block:: yaml name: reproject channels: diff --git a/docs/tutorials/map_layout/new_app_project.rst b/docs/tutorials/map_layout/new_app_project.rst index 6033d174b..9b1179758 100644 --- a/docs/tutorials/map_layout/new_app_project.rst +++ b/docs/tutorials/map_layout/new_app_project.rst @@ -55,17 +55,17 @@ App dependencies should be managed using the :file:`install.yml` instead of the name: map_layout_tutorial requirements: - # Putting in a skip true param will skip the entire section. Ignoring the option will assume it be set to False - skip: false - conda: - channels: - - conda-forge - packages: - - pandas + # Putting in a skip true param will skip the entire section. Ignoring the option will assume it be set to False + skip: false + conda: + channels: + - conda-forge + packages: + - pandas - pip: + pip: - npm: + npm: post: diff --git a/docs/tutorials/thredds/plot_at_location.rst b/docs/tutorials/thredds/plot_at_location.rst index d765d013c..542d84696 100644 --- a/docs/tutorials/thredds/plot_at_location.rst +++ b/docs/tutorials/thredds/plot_at_location.rst @@ -2,7 +2,7 @@ Plot Time Series at Location **************************** -**Last Updated:** June 2022 +**Last Updated:** August 2023 In this tutorial you will add a tool for querying the active THREDDS dataset for time series data at a location and display it on a plot. Topics covered in this tutorial include: @@ -121,7 +121,29 @@ In this step you'll learn to use another Leaflet plugin: `Leaflet.Draw `_ in a web browser and login if necessary. A single tool for drawing markers/points should appear near the top left-hand corner of the map, just below the zoom controls. -2. Create New Plot Controller +2. Install Plotly +================= + +In this step you will create a new controller that will query the dataset at the given location using the NCSS service and then build a plotly plot with the results. + +1. The Plotly View gizmo requires the `plotly` Python package. Install `plotly` as follows running the following command in the terminal: + +.. code-block:: + + # with conda + conda install plotly + + # with pip + pip install plotly + +2. The app now depends on `plotly`, so add it to the `install.yml` file: + +.. code-block:: yaml + + dependencies: + - plotly + +3. Create New Plot Controller ============================= In this step you will create a new controller that will query the dataset at the given location using the NCSS service and then build a plotly plot with the results. @@ -386,7 +408,7 @@ In this step you will create a new controller that will query the dataset at the {% endif %} -3. Load Plot Using JQuery Load +4. Load Plot Using JQuery Load ============================== The `JQuery.load() `_ method is used to call a URL and load the returned HTML into the target element. In this step, you'll use ``jQuery.load()`` to call the ``get-time-series-plot`` endpoint and load the markup for the plot that is returned into a modal for display to the user. This pattern allows you to render the plot dynamically with minimal JavaScript, because the plot is parameterized using Python on the server. @@ -644,7 +666,7 @@ The `JQuery.load() `_ method is used to call a URL update_legend(); }; -4. Test and Verify +5. Test and Verify ================== Browse to ``_ in a web browser and login if necessary. Verify the following: @@ -655,7 +677,7 @@ Browse to ``_ in a web browser and 4. Verify that the plot dialog appears automatically after dropping the marker with the loading image showing. 5. Verify that the plot appears after the data has been queried. -5. Solution +6. Solution =========== This concludes the New App Project portion of the THREDDS Tutorial. You can view the solution on GitHub at ``_ or clone it as follows: diff --git a/docs/whats_new/app_migration.rst b/docs/whats_new/app_migration.rst index 6bf3be342..e341acecf 100644 --- a/docs/whats_new/app_migration.rst +++ b/docs/whats_new/app_migration.rst @@ -36,13 +36,13 @@ should be changed to this: Controller Decorators ===================== -The ``url_maps()`` method is being deprecated in favor of the simpler ``controller`` decorator method introduced in Tethys 4. The ``url_maps`` method is temporarily avilable in Tethys 4 to allow for easier app migration, but **support for the ``url_maps`` method will be dropped in Tethys 4.1.0**. +The ``url_maps()`` method is being deprecated in favor of the simpler ``controller`` decorator method introduced in Tethys 4. The ``url_maps`` method is temporarily available in Tethys 4 to allow for easier app migration, but **support for the ``url_maps`` method will be dropped in Tethys 4.1.0**. It is strongly recommended to migrate apps to use the new ``controller`` decorator approach and remove the ``url_maps()`` method from the :file:`app.py`. If you still wish to declare ``UrlMaps`` in the :file:`app.py`, use the new ``register_url_maps()`` method and then remove the ``url_maps()`` method (see: :ref:`register-url-maps-method`). The console will display warnings for apps still using the ``url_maps`` method in Tethys 4 to encourage migration to one of the new methods described above as soon as possible. **Don't wait for Tethys 4.1 to migrate**. Use the following tips to help you migrate: -1. Review the :ref:`url_maps_api` documentation to become familiar with the ``controller`` decorator. +1. Review the :ref:`routing_api` documentation to become familiar with the ``controller`` decorator. 2. If your app has a lot of controllers, use the ``url_maps()`` in :file:`app.py` to make a list of them. There should be one controller function or class for each ``UrlMap`` listed. 3. Add the ``controller`` decorator to each controller function or class in your app. 4. If the default URL or name generated by the ``controller`` decorator don't match what is set in the ``UrlMap``, override it by setting the ``url`` and ``name`` arguments of the controller decorator. @@ -51,7 +51,7 @@ Use the following tips to help you migrate: .. note:: - Tethys 4 also introduces the ``consumer`` and ``handler`` decorators that function equivalently for consumers and handler functions. See the :ref:`url_maps_api` documentation for more details. + Tethys 4 also introduces the ``consumer`` and ``handler`` decorators that function equivalently for consumers and handler functions. See the :ref:`routing_api` documentation for more details. Search Path ----------- diff --git a/docs/whats_new/prior_releases.rst b/docs/whats_new/prior_releases.rst index fa964a83f..be0a12671 100644 --- a/docs/whats_new/prior_releases.rst +++ b/docs/whats_new/prior_releases.rst @@ -39,7 +39,7 @@ Controller Decorators * UrlMaps in app.py are no longer needed and the ``url_maps`` method is deprecated in favor of the new ``register_url_maps`` method. * IMPORTANT: The ``url_maps`` method is temporarily supported in Tethys Platform 4.0.0 to make migration of apps from Tethys 3 to 4 easier, but will be removed in 4.1.0. -See: :ref:`url_maps_api` +See: :ref:`routing_api` WebSocket URLs -------------- diff --git a/environment.yml b/environment.yml index df603b59d..aac7fea48 100644 --- a/environment.yml +++ b/environment.yml @@ -1,92 +1,107 @@ # environment.yml # Configuration file for creating a Conda Environment with dependencies needed for Tethys Platform. -# Create the environment by running the following command (after installing Miniconda): -# $ conda env create -f environment.yml +# Create the environment by running the following command (after installing Mambaforge): +# $ mamba env create -f environment.yml +# OR +# Create the environment with conda by running the following command +# (after installing Miniconda or similar and conda-libmamba-solver): +# $ conda env create --solver libmamba -f environment.yml name: tethys channels: - conda-forge - - tethysplatform - - defaults dependencies: - python - # encryption dependencies - - bcrypt + # system dependencies + - pyopenssl + - openssl + + # core dependencies + - django=3.2.* + - channels=3.* + - daphne=3.* + - setuptools_scm + - pip + - requests # required by lots of things + - bcrypt # also required by channels, docker, daphne, condorpy + + # Gen CLI commands + - pyyaml + - jinja2 + + # django plugin dependencies + - django-bootstrap5 + - django-model-utils + - django-guardian + +###################################### +# Optional Dependencies +###################################### - # spatial dependencies + # Security Plugins + - django-cors-headers # enable cors? + - django-session-security # session timeouts + - django-axes # tracked failed login attempts + + # Login/Account Plugins + - django-gravatar2 + - django-simple-captcha + - django-mfa2 + - django-recaptcha2 + - social-auth-app-django + - hs_restclient # Used with HydroShare Socail backend + - python-jose # required by django-mfa2 - used for onelogin backend + - django-oauth-toolkit + + # datetime dependencies for "humanize" template filter (used with MFA2) + - arrow + - isodate + + # Misc Plugins + - django-termsandconditions # require users to accept terms and conditions + - django-analytical # track usage analytics + - django-json-widget # enable json widget for app settings + - djangorestframework # enable REST API framework + + # Map Layout - PyShp - # system dependencies - - pyopenssl + # Docker CLI - docker-py - - distro + + # Conda to allow Python API access to Conda Install + - conda + - conda-libmamba-solver # database dependencies - postgresql - - psycopg2 - - sqlalchemy=1.* # TODO: what will it take to support sqlalchemy 2.0? - - geoalchemy2 + - psycopg2 # required by tethys_dataset_services + - sqlalchemy=1.* # TODO: what will it take to support sqlalchemy 2.0? + - geoalchemy2 # requires sqlalchemy - # plotting dependencies + # plotting Gizmo dependencies - plotly - - bokeh<3 + - bokeh - # external services dependencies - - tethys_dataset_services>=2.0.0 - - hs_restclient - - owslib - - requests + # TethysJob Types - dask - - tethys_dask_scheduler>=1.0.2 - - service_identity - condorpy - - siphon - - python-jose - - pyjwt + - tethys_dask_scheduler>=1.0.2 - # datetime dependencies - - arrow - - isodate + # external services dependencies + - tethys_dataset_services>=2.0.0 # used with all data services + - owslib # used for creating WPS services + - siphon # used with Threads - # django/plugin dependencies - - django=3.2.* - - channels=3.* - - daphne=3.* - - django-analytical - - django-axes - - django-filter - - djangorestframework - - django-bootstrap5 - - django-cors-headers - - django-model-utils - - django-guardian - - django-gravatar2 - - django-json-widget - - django-mfa2 - - django-recaptcha2 - - django-simple-captcha - - django-session-security - - django-termsandconditions - - social-auth-app-django + # Docs + - git - # tests dependencies + # tests/style dependencies - selenium - coverage - factory_boy - - # for now - - pillow - - pip - - future - flake8 - flake8-bugbear - - git - - setuptools_scm - - openssl - - # Conda to allow Python API access to Conda Install - - conda - - conda-libmamba-solver diff --git a/micro_environment.yml b/micro_environment.yml new file mode 100644 index 000000000..18e59b2f3 --- /dev/null +++ b/micro_environment.yml @@ -0,0 +1,38 @@ +# environment.yml +# Configuration file for creating a Conda Environment with dependencies needed for Tethys Platform. +# Create the environment by running the following command (after installing Mambaforge): +# $ mamba env create -f micro_environment.yml +# OR +# Create the environment with conda by running the following command (after installing Miniconda or similar): +# $ conda env create -f micro_environment.yml + +name: tethys + +channels: + - conda-forge + +dependencies: + - python + + # system dependencies + - pyopenssl + - openssl + + # core dependencies + - django=3.2.* + - channels=3.* + - daphne=3.* + - setuptools_scm + - pip + - requests # required by lots of things + - bcrypt # also required by channels, docker, daphne, condorpy + + # Gen CLI commands + - pyyaml + - jinja2 + + + # django plugin dependencies + - django-bootstrap5 + - django-model-utils + - django-guardian diff --git a/tests/unit_tests/test_tethys_apps/test_admin.py b/tests/unit_tests/test_tethys_apps/test_admin.py index 3a9cd7e1c..eff3546c2 100644 --- a/tests/unit_tests/test_tethys_apps/test_admin.py +++ b/tests/unit_tests/test_tethys_apps/test_admin.py @@ -512,7 +512,9 @@ def test_gop_form_save_edit_apps( ret.save() - mock_remove_perm.called_with("test_app:access_app", mock_obj, self.app_model) + mock_remove_perm.assert_called_with( + "test_app:access_app", mock_obj, self.app_model + ) self.assertEqual( mock_assign_perm.call_args_list[0].args, ("test_app:access_app", mock_obj, self.app_model), diff --git a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_collectworkspaces.py b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_collectworkspaces.py index f97f00e5d..7042ecbb9 100644 --- a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_collectworkspaces.py +++ b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_collectworkspaces.py @@ -197,8 +197,13 @@ def test_collectworkspaces_handle_exists_no_force( mock_shutil_rmtree, mock_print, ): - mock_settings.TETHYS_WORKSPACES_ROOT = "/foo/workspace" - mock_get_apps.return_value = {"foo_app": "/foo/testing/tests/foo_app"} + app_name = "foo_app" + app_path = f"/foo/testing/tests/{app_name}" + app_ws_path = f"{app_path}/workspaces" + tethys_workspaces_root = "/foo/workspace" + app_workspaces_root = f"{tethys_workspaces_root}/{app_name}" + mock_settings.TETHYS_WORKSPACES_ROOT = tethys_workspaces_root + mock_get_apps.return_value = {app_name: app_path} mock_os_path_isdir.side_effect = [True, True] mock_os_path_islink.return_value = False mock_os_path_exists.return_value = True @@ -210,16 +215,12 @@ def test_collectworkspaces_handle_exists_no_force( cmd.handle(force=False) mock_get_apps.assert_called_once() - mock_os_path_isdir.assert_any_call("/foo/testing/tests/foo_app/workspaces") - mock_os_path_isdir.assert_called_with("/foo/workspace/foo_app") - mock_os_path_islink.assert_called_once_with( - "/foo/testing/tests/foo_app/workspaces" - ) - mock_os_path_exists.assert_called_once_with("/foo/workspace/foo_app") + mock_os_path_isdir.assert_any_call(app_ws_path) + mock_os_path_isdir.assert_called_with(app_workspaces_root) + mock_os_path_islink.assert_called_once_with(app_ws_path) + mock_os_path_exists.assert_called_once_with(app_workspaces_root) mock_shutil_move.assert_not_called() - mock_shutil_rmtree.called_once_with( - "/foo/workspace/foo_app", ignore_errors=True - ) + mock_shutil_rmtree.assert_called_once_with(app_ws_path, ignore_errors=True) msg_first_info = 'INFO: Moving workspace directories of apps to "/foo/workspace" and linking back.' @@ -266,8 +267,13 @@ def test_collectworkspaces_handle_exists_force_exception( mock_os_remove, mock_print, ): - mock_settings.TETHYS_WORKSPACES_ROOT = "/foo/workspace" - mock_get_apps.return_value = {"foo_app": "/foo/testing/tests/foo_app"} + app_name = "foo_app" + app_path = f"/foo/testing/tests/{app_name}" + app_ws_path = f"{app_path}/workspaces" + tethys_workspaces_root = "/foo/workspace" + app_workspaces_root = f"{tethys_workspaces_root}/{app_name}" + mock_settings.TETHYS_WORKSPACES_ROOT = tethys_workspaces_root + mock_get_apps.return_value = {app_name: app_path} mock_os_path_isdir.side_effect = [True, True] mock_os_path_islink.return_value = False mock_os_path_exists.return_value = True @@ -280,19 +286,15 @@ def test_collectworkspaces_handle_exists_force_exception( cmd.handle(force=True) mock_get_apps.assert_called_once() - mock_os_path_isdir.assert_any_call("/foo/testing/tests/foo_app/workspaces") - mock_os_path_isdir.assert_called_with("/foo/workspace/foo_app") - mock_os_path_islink.assert_called_once_with( - "/foo/testing/tests/foo_app/workspaces" - ) - mock_os_path_exists.assert_called_once_with("/foo/workspace/foo_app") - mock_shutil_move.assert_called_once_with( - "/foo/testing/tests/foo_app/workspaces", "/foo/workspace/foo_app" - ) - mock_shutil_rmtree.called_once_with( - "/foo/testing/tests/foo_app/workspaces", ignore_errors=True + mock_os_path_isdir.assert_any_call(app_ws_path) + mock_os_path_isdir.assert_called_with(app_workspaces_root) + mock_os_path_islink.assert_called_once_with(app_ws_path) + mock_os_path_exists.assert_called_once_with(app_workspaces_root) + mock_shutil_move.assert_called_once_with(app_ws_path, app_workspaces_root) + mock_shutil_rmtree.assert_called_once_with( + app_workspaces_root, ignore_errors=True ) - mock_os_remove.assert_called_once_with("/foo/workspace/foo_app") + mock_os_remove.assert_called_once_with(app_workspaces_root) msg_first_info = 'INFO: Moving workspace directories of apps to "/foo/workspace" and linking back.' diff --git a/tests/unit_tests/test_tethys_apps/test_views.py b/tests/unit_tests/test_tethys_apps/test_views.py index 15e4b6af2..250178ce4 100644 --- a/tests/unit_tests/test_tethys_apps/test_views.py +++ b/tests/unit_tests/test_tethys_apps/test_views.py @@ -299,11 +299,10 @@ def test_update_job_status(self, mock_tethysjob, mock_json_response): mock_job_id = mock.MagicMock() mock_job1 = mock.MagicMock() mock_job1.status = True - mock_job2 = mock.MagicMock() - mock_tethysjob.objects.filter.return_value = [mock_job1, mock_job2] + mock_tethysjob.objects.get_subclass.return_value = mock_job1 update_job_status(mock_request, mock_job_id) - mock_tethysjob.objects.filter.assert_called_once_with(id=mock_job_id) + mock_tethysjob.objects.get_subclass.assert_called_once_with(id=mock_job_id) mock_json_response.assert_called_once_with({"success": True}) @mock.patch("tethys_apps.views.JsonResponse") @@ -311,10 +310,10 @@ def test_update_job_status(self, mock_tethysjob, mock_json_response): def test_update_job_statusException(self, mock_tethysjob, mock_json_response): mock_request = mock.MagicMock() mock_job_id = mock.MagicMock() - mock_tethysjob.objects.filter.side_effect = Exception + mock_tethysjob.objects.get_subclass.side_effect = Exception update_job_status(mock_request, mock_job_id) - mock_tethysjob.objects.filter.assert_called_once_with(id=mock_job_id) + mock_tethysjob.objects.get_subclass.assert_called_once_with(id=mock_job_id) mock_json_response.assert_called_once_with({"success": False}) @mock.patch("tethys_apps.views.JsonResponse") diff --git a/tests/unit_tests/test_tethys_cli/test_app_settings_command.py b/tests/unit_tests/test_tethys_cli/test_app_settings_command.py index 8d3056620..d52bb524c 100644 --- a/tests/unit_tests/test_tethys_cli/test_app_settings_command.py +++ b/tests/unit_tests/test_tethys_cli/test_app_settings_command.py @@ -8,9 +8,11 @@ class TestCliAppSettingsCommand(unittest.TestCase): def setUp(self): - load_apps_patcher = mock.patch("tethys_cli.app_settings_commands.load_apps") - load_apps_patcher.start() - self.addCleanup(load_apps_patcher.stop) + setup_django_patcher = mock.patch( + "tethys_cli.app_settings_commands.setup_django" + ) + setup_django_patcher.start() + self.addCleanup(setup_django_patcher.stop) def tearDown(self): pass @@ -462,7 +464,7 @@ def test_app_settings_set_str( ) mock_write_error.assert_not_called() mock_write_success.assert_called() - mock_exit.called_with(0) + mock_exit.assert_called_with(0) @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") @@ -479,7 +481,7 @@ def test_app_settings_set_int( ) mock_write_error.assert_not_called() mock_write_success.assert_called() - mock_exit.called_with(0) + mock_exit.assert_called_with(0) @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") @@ -500,7 +502,7 @@ def test_app_settings_set_float( ) mock_write_error.assert_not_called() mock_write_success.assert_called() - mock_exit.called_with(0) + mock_exit.assert_called_with(0) @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") @@ -521,7 +523,7 @@ def test_app_settings_set_bool( ) mock_write_error.assert_not_called() mock_write_success.assert_called() - mock_exit.called_with(0) + mock_exit.assert_called_with(0) @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") @@ -558,7 +560,7 @@ def test_app_settings_set_json_with_variable( ) mock_write_error.assert_not_called() mock_write_success.assert_called() - mock_exit.called_with(0) + mock_exit.assert_called_with(0) @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") @@ -582,7 +584,7 @@ def test_app_settings_set_json_with_variable_error( mock_write_error.assert_called_with("Please enclose the JSON in single quotes") mock_write_success.assert_not_called() - mock_exit.called_with(1) + mock_exit.assert_called_with(1) @mock.patch( "tethys_cli.app_settings_commands.open", @@ -622,7 +624,7 @@ def test_app_settings_set_json_with_file( mock_write_error.assert_not_called() po_call_args = mock_pretty_output().__enter__().write.call_args_list self.assertIn("File found, extracting JSON data", po_call_args[0][0][0]) - mock_exit.called_with(0) + mock_exit.assert_called_with(0) @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") @@ -643,7 +645,7 @@ def test_app_settings_set_secret( ) mock_write_error.assert_not_called() mock_write_success.assert_called() - mock_exit.called_with(0) + mock_exit.assert_called_with(0) @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") @@ -787,7 +789,7 @@ def test_app_settings_set_bad_value_json_with_file( mock_write_success.assert_not_called() po_call_args = mock_pretty_output().__enter__().write.call_args_list self.assertIn("File found, extracting JSON data", po_call_args[0][0][0]) - mock_exit.called_with(1) + mock_exit.assert_called_with(1) @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") @@ -853,7 +855,7 @@ def test_app_settings_reset_str( ) mock_write_error.assert_not_called() mock_write_success.assert_called() - mock_exit.called_with(0) + mock_exit.assert_called_with(0) @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") @@ -875,7 +877,7 @@ def test_app_settings_reset_int( ) mock_write_error.assert_not_called() mock_write_success.assert_called() - mock_exit.called_with(0) + mock_exit.assert_called_with(0) @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") @@ -897,7 +899,7 @@ def test_app_settings_reset_float( ) mock_write_error.assert_not_called() mock_write_success.assert_called() - mock_exit.called_with(0) + mock_exit.assert_called_with(0) @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") @@ -919,7 +921,7 @@ def test_app_settings_reset_bool( ) mock_write_error.assert_not_called() mock_write_success.assert_called() - mock_exit.called_with(0) + mock_exit.assert_called_with(0) @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") diff --git a/tests/unit_tests/test_tethys_cli/test_cli_helper.py b/tests/unit_tests/test_tethys_cli/test_cli_helper.py index 966029120..852044950 100644 --- a/tests/unit_tests/test_tethys_cli/test_cli_helper.py +++ b/tests/unit_tests/test_tethys_cli/test_cli_helper.py @@ -67,8 +67,13 @@ def test_run_process_keyboardinterrupt(self, mock_te_call, mock_subprocess_call) mock_te_call.assert_called_once() @mock.patch("tethys_cli.cli_helpers.django.setup") - def test_load_apps(self, mock_django_setup): - cli_helper.load_apps() + def test_setup_django(self, mock_django_setup): + cli_helper.setup_django() + mock_django_setup.assert_called() + + @mock.patch("tethys_cli.cli_helpers.django.setup") + def test_setup_django_supress_output(self, mock_django_setup): + cli_helper.setup_django(supress_output=True) mock_django_setup.assert_called() @mock.patch("tethys_cli.cli_helpers.bcrypt.gensalt") diff --git a/tests/unit_tests/test_tethys_cli/test_db_commands.py b/tests/unit_tests/test_tethys_cli/test_db_commands.py index 34cf8f128..a877962a1 100644 --- a/tests/unit_tests/test_tethys_cli/test_db_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_db_commands.py @@ -362,9 +362,9 @@ def test_db_command_migrate(self, mock_get_manage_path): @mock.patch("tethys_cli.db_commands.write_info") @mock.patch("tethys_cli.db_commands.write_error") @mock.patch("django.contrib.auth.models.User.objects.create_superuser") - @mock.patch("tethys_cli.db_commands.load_apps") + @mock.patch("tethys_cli.db_commands.setup_django") def test_db_command_createsuperuser( - self, mock_load_apps, mock_create_superuser, mock_write_error, _ + self, mock_setup_django, mock_create_superuser, mock_write_error, _ ): from django.db.utils import IntegrityError @@ -372,17 +372,32 @@ def test_db_command_createsuperuser( mock_args.command = "createsuperuser" mock_create_superuser.side_effect = IntegrityError db_command(mock_args) - mock_load_apps.assert_called() + mock_setup_django.assert_called() mock_create_superuser.assert_called_with("PFoo", "PEmail", "PBar") portal_superuser = self.options["portal_superuser_name"] mock_write_error.assert_called_with( f'Tethys Portal Superuser "{portal_superuser}" already exists.' ) + @mock.patch("tethys_cli.db_commands.create_portal_superuser") + @mock.patch("tethys_cli.db_commands.migrate_tethys_db") + @mock.patch("tethys_cli.db_commands.Path") + def test_db_command_configure_sqlite( + self, mock_Path, mock_migrate, mock_createsuperuser + ): + mock_args = mock.MagicMock() + mock_args.command = "configure" + self.mock_process_args.return_value["db_engine"] = "sqlite" + db_command(mock_args) + kwargs = self._get_kwargs() + mock_Path.assert_called_with(kwargs["db_name"]) + mock_migrate.assert_called_with(**self.options) + mock_createsuperuser.assert_called_with(**self.options) + @mock.patch("tethys_cli.db_commands.create_portal_superuser") @mock.patch("tethys_cli.db_commands.migrate_tethys_db") @mock.patch("tethys_cli.db_commands._prompt_if_error") - def test_db_command_configure( + def test_db_command_configure_postgres( self, mock_prompt_err, mock_migrate, mock_createsuperuser ): mock_args = mock.MagicMock() diff --git a/tests/unit_tests/test_tethys_cli/test_docker_commands.py b/tests/unit_tests/test_tethys_cli/test_docker_commands.py index 84c736434..2c841f9fb 100644 --- a/tests/unit_tests/test_tethys_cli/test_docker_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_docker_commands.py @@ -1,4 +1,3 @@ -import importlib import unittest from unittest import mock import tethys_cli.docker_commands as cli_docker_commands @@ -38,10 +37,6 @@ def make_args( ) return args - def test_curses_import_error(self): - with mock.patch.dict("sys.modules", {"curses": None}): - importlib.reload(cli_docker_commands) - def test_get_docker_client(self): dc = cli_docker_commands.ContainerMetadata.get_docker_client() self.assertIs(dc, self.mock_dc) @@ -1256,14 +1251,14 @@ def test_uih_get_valid_directory_input_oserror( @mock.patch("tethys_cli.docker_commands.write_pretty_output") @mock.patch("tethys_cli.docker_commands.curses") - @mock.patch("tethys_cli.docker_commands.platform.system") + @mock.patch("tethys_cli.docker_commands.has_module") def test_log_pull_stream_linux_with_id_bad_status( - self, mock_platform_system, mock_curses, mock_pretty_output + self, mock_has_module, mock_curses, mock_pretty_output ): mock_stream = [ b'{ "id":"358464", "status":"foo", "progress":"bar" }' ] - mock_platform_system.return_value = "Linux" + mock_has_module.return_value = True mock_curses.initscr().getmaxyx.return_value = 1, 80 cli_docker_commands.log_pull_stream(mock_stream) @@ -1293,14 +1288,14 @@ def test_log_pull_stream_linux_with_id_bad_status( @mock.patch("tethys_cli.docker_commands.write_pretty_output") @mock.patch("tethys_cli.docker_commands.curses") - @mock.patch("tethys_cli.docker_commands.platform.system") + @mock.patch("tethys_cli.docker_commands.has_module") def test_log_pull_stream_linux_with_id_progress_status( - self, mock_platform_system, mock_curses, mock_pretty_output + self, mock_has_module, mock_curses, mock_pretty_output ): mock_stream = [ b'{ "id":"358464", "status":"Downloading", "progress":"bar" }' ] - mock_platform_system.return_value = "Linux" + mock_has_module.return_value = True mock_curses.initscr().getmaxyx.return_value = 1, 80 cli_docker_commands.log_pull_stream(mock_stream) @@ -1324,15 +1319,15 @@ def test_log_pull_stream_linux_with_id_progress_status( @mock.patch("tethys_cli.docker_commands.write_pretty_output") @mock.patch("tethys_cli.docker_commands.curses") - @mock.patch("tethys_cli.docker_commands.platform.system") + @mock.patch("tethys_cli.docker_commands.has_module") def test_log_pull_stream_linux_with_id_status( - self, mock_platform_system, mock_curses, mock_pretty_output + self, mock_has_module, mock_curses, mock_pretty_output ): mock_stream = [ b'{ "id":"358464", "status":"Downloading", "progress":"bar" }\r\n' b'{ "id":"358464", "status":"Pulling fs layer", "progress":"baz" }' ] - mock_platform_system.return_value = "Linux" + mock_has_module.return_value = True mock_curses.initscr().getmaxyx.return_value = 1, 80 cli_docker_commands.log_pull_stream(mock_stream) @@ -1356,12 +1351,12 @@ def test_log_pull_stream_linux_with_id_status( @mock.patch("tethys_cli.docker_commands.write_pretty_output") @mock.patch("tethys_cli.docker_commands.curses") - @mock.patch("tethys_cli.docker_commands.platform.system") + @mock.patch("tethys_cli.docker_commands.has_module") def test_log_pull_stream_linux_with_no_id( - self, mock_platform_system, mock_curses, mock_pretty_output + self, mock_has_module, mock_curses, mock_pretty_output ): mock_stream = [b'{ "status":"foo", "progress":"bar" }'] - mock_platform_system.return_value = "Linux" + mock_has_module.return_value = True mock_curses.initscr().getmaxyx.return_value = 1, 80 cli_docker_commands.log_pull_stream(mock_stream) @@ -1380,16 +1375,16 @@ def test_log_pull_stream_linux_with_no_id( @mock.patch("tethys_cli.docker_commands.write_pretty_output") @mock.patch("tethys_cli.docker_commands.curses") - @mock.patch("tethys_cli.docker_commands.platform.system") + @mock.patch("tethys_cli.docker_commands.has_module") def test_log_pull_stream_linux_with_curses_error( - self, mock_platform_system, mock_curses, mock_pretty_output + self, mock_has_module, mock_curses, mock_pretty_output ): import curses mock_stream = [ b'{ "id":"358464", "status":"Downloading", "progress":"bar" }\r\n' ] - mock_platform_system.return_value = "Linux" + mock_has_module.return_value = True mock_curses.initscr().getmaxyx.return_value = 1, 80 mock_curses.error = ( curses.error @@ -1416,12 +1411,12 @@ def test_log_pull_stream_linux_with_curses_error( self.assertEqual("", po_call_args[0][0][0]) @mock.patch("tethys_cli.docker_commands.write_pretty_output") - @mock.patch("tethys_cli.docker_commands.platform.system") - def test_log_pull_stream_windows(self, mock_platform_system, mock_pretty_output): + @mock.patch("tethys_cli.docker_commands.has_module") + def test_log_pull_stream_windows(self, mock_has_module, mock_pretty_output): mock_stream = [ b'{ "id":"358464", "status":"Downloading", "progress":"bar" }' ] - mock_platform_system.return_value = "Windows" + mock_has_module.return_value = False cli_docker_commands.log_pull_stream(mock_stream) diff --git a/tests/unit_tests/test_tethys_cli/test_gen_commands.py b/tests/unit_tests/test_tethys_cli/test_gen_commands.py index 5b7f8a3f2..9697aead5 100644 --- a/tests/unit_tests/test_tethys_cli/test_gen_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_gen_commands.py @@ -2,7 +2,6 @@ from unittest import mock from pathlib import Path -import tethys_cli.gen_commands as tethys_gen_commands from tethys_cli.gen_commands import ( get_environment_value, get_settings_value, @@ -39,26 +38,6 @@ def setUp(self): def tearDown(self): pass - @mock.patch("tethys_cli.cli_colors.write_warning") - def test_no_conda(self, mock_warn): - import tethys_cli.gen_commands as tethys_gen_commands - from importlib import reload - import builtins - - real_import = builtins.__import__ - - def mock_import(name, *args): - if name == "conda.cli.python_api": - raise ModuleNotFoundError - else: - return real_import(name, *args) - - builtins.__import__ = mock_import - reload(tethys_gen_commands) - builtins.__import__ = real_import - self.assertEqual(tethys_gen_commands.has_conda, False) - mock_warn.assert_called_once() - def test_get_environment_value(self): result = get_environment_value(value_name="DJANGO_SETTINGS_MODULE") @@ -191,94 +170,16 @@ def test_generate_command_portal_yaml__tethys_home_not_exists( @mock.patch("tethys_cli.gen_commands.write_info") @mock.patch("tethys_cli.gen_commands.render_template") - @mock.patch("tethys_cli.gen_commands.linux_distribution") - @mock.patch("tethys_cli.gen_commands.os.path.exists") - @mock.patch("tethys_cli.gen_commands.get_environment_value") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") - def test_generate_command_asgi_service_option_nginx_conf_redhat( - self, - mock_os_path_isfile, - mock_file, - mock_env, - mock_os_path_exists, - mock_linux_distribution, - mock_render_template, - mock_write_info, - ): - mock_args = mock.MagicMock(conda_prefix=False) - mock_args.type = GEN_ASGI_SERVICE_OPTION - mock_args.directory = None - mock_os_path_isfile.return_value = False - mock_env.side_effect = ["/foo/conda", "conda_env"] - mock_os_path_exists.return_value = True - mock_linux_distribution.return_value = ["redhat"] - mock_file.return_value = mock.mock_open(read_data="user foo_user").return_value - - generate_command(args=mock_args) - - mock_os_path_isfile.assert_called_once() - mock_file.assert_called() - mock_env.assert_called_with("CONDA_PREFIX") - mock_os_path_exists.assert_any_call("/etc/nginx/nginx.conf") - context = mock_render_template.call_args.args[1] - self.assertEqual("http-", context["user_option_prefix"]) - self.assertEqual("foo_user", context["nginx_user"]) - - mock_write_info.assert_called() - - @mock.patch("tethys_cli.gen_commands.write_info") - @mock.patch("tethys_cli.gen_commands.render_template") - @mock.patch("tethys_cli.gen_commands.linux_distribution") - @mock.patch("tethys_cli.gen_commands.os.path.exists") - @mock.patch("tethys_cli.gen_commands.get_environment_value") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") - def test_generate_command_asgi_service_option_nginx_conf_ubuntu( - self, - mock_os_path_isfile, - mock_file, - mock_env, - mock_os_path_exists, - mock_linux_distribution, - mock_render_template, - mock_write_info, - ): - mock_args = mock.MagicMock(conda_prefix=False) - mock_args.type = GEN_ASGI_SERVICE_OPTION - mock_args.directory = None - mock_os_path_isfile.return_value = False - mock_env.side_effect = ["/foo/conda", "conda_env"] - mock_os_path_exists.return_value = True - mock_linux_distribution.return_value = "ubuntu" - mock_file.return_value = mock.mock_open(read_data="user foo_user").return_value - - generate_command(args=mock_args) - - mock_os_path_isfile.assert_called_once() - mock_file.assert_called() - mock_env.assert_called_with("CONDA_PREFIX") - mock_os_path_exists.assert_any_call("/etc/nginx/nginx.conf") - context = mock_render_template.call_args.args[1] - self.assertEqual("", context["user_option_prefix"]) - self.assertEqual("foo_user", context["nginx_user"]) - - mock_write_info.assert_called() - - @mock.patch("tethys_cli.gen_commands.write_info") - @mock.patch("tethys_cli.gen_commands.render_template") - @mock.patch("tethys_cli.gen_commands.linux_distribution") @mock.patch("tethys_cli.gen_commands.os.path.exists") @mock.patch("tethys_cli.gen_commands.get_environment_value") @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) @mock.patch("tethys_cli.gen_commands.os.path.isfile") - def test_generate_command_asgi_service_option_nginx_conf_not_linux( + def test_generate_command_asgi_service_option_nginx_conf( self, mock_os_path_isfile, mock_file, mock_env, mock_os_path_exists, - mock_linux_distribution, mock_render_template, mock_write_info, ): @@ -288,7 +189,6 @@ def test_generate_command_asgi_service_option_nginx_conf_not_linux( mock_os_path_isfile.return_value = False mock_env.side_effect = ["/foo/conda", "conda_env"] mock_os_path_exists.return_value = True - mock_linux_distribution.side_effect = Exception mock_file.return_value = mock.mock_open(read_data="user foo_user").return_value generate_command(args=mock_args) @@ -298,7 +198,6 @@ def test_generate_command_asgi_service_option_nginx_conf_not_linux( mock_env.assert_called_with("CONDA_PREFIX") mock_os_path_exists.assert_any_call("/etc/nginx/nginx.conf") context = mock_render_template.call_args.args[1] - self.assertEqual("", context["user_option_prefix"]) self.assertEqual("foo_user", context["nginx_user"]) mock_write_info.assert_called() @@ -325,7 +224,6 @@ def test_generate_command_asgi_service_option( mock_write_info.assert_called() @mock.patch("tethys_cli.gen_commands.write_info") - @mock.patch("tethys_cli.gen_commands.linux_distribution") @mock.patch("tethys_cli.gen_commands.get_environment_value") @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) @mock.patch("tethys_cli.gen_commands.os.path.isfile") @@ -334,7 +232,6 @@ def test_generate_command_asgi_service_option_distro( mock_os_path_isfile, mock_file, mock_env, - mock_distribution, mock_write_info, ): mock_args = mock.MagicMock(conda_prefix=False) @@ -342,7 +239,6 @@ def test_generate_command_asgi_service_option_distro( mock_args.directory = None mock_os_path_isfile.return_value = False mock_env.side_effect = ["/foo/conda", "conda_env"] - mock_distribution.return_value = ("redhat", "linux", "") generate_command(args=mock_args) @@ -540,7 +436,7 @@ def test_generate_requirements_option( @mock.patch("tethys_cli.gen_commands.os.path.join") @mock.patch("tethys_cli.gen_commands.write_info") @mock.patch("tethys_cli.gen_commands.Template") - @mock.patch("tethys_cli.gen_commands.safe_load") + @mock.patch("tethys_cli.gen_commands.yaml.safe_load") @mock.patch("tethys_cli.gen_commands.run_command") @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) @mock.patch("tethys_cli.gen_commands.os.path.isfile") @@ -556,7 +452,7 @@ def test_generate_command_metayaml( _, mock_os_path_join, ): - mock_args = mock.MagicMock() + mock_args = mock.MagicMock(micro=False) mock_args.type = GEN_META_YAML_OPTION mock_args.directory = None mock_args.pin_level = "minor" @@ -582,6 +478,7 @@ def test_generate_command_metayaml( render_context = mock_Template().render.call_args.args[0] expected_context = { + "package_name": "tethys-platform", "run_requirements": ["foo=1.2.*", "bar=4.5", "goo=7.8"], "tethys_version": mock.ANY, } @@ -591,12 +488,12 @@ def test_generate_command_metayaml( @mock.patch("tethys_cli.gen_commands.write_info") @mock.patch("tethys_cli.gen_commands.derive_version_from_conda_environment") - @mock.patch("tethys_cli.gen_commands.safe_load") + @mock.patch("tethys_cli.gen_commands.yaml.safe_load") @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) def test_gen_meta_yaml_overriding_dependencies( self, _, mock_load, mock_dvfce, mock_write_info ): - mock_args = mock.MagicMock() + mock_args = mock.MagicMock(micro=False) mock_args.type = GEN_META_YAML_OPTION mock_args.directory = None mock_args.pin_level = "minor" @@ -618,6 +515,7 @@ def test_gen_meta_yaml_overriding_dependencies( mock_dvfce.assert_called_with("foo", level="minor") expected_context = { + "package_name": "tethys-platform", "run_requirements": [ mock_dvfce(), "foo=1.2.3", @@ -883,14 +781,14 @@ def test_download_vendor_static_files_no_npm(self, mock_call, mock_error): mock_call.assert_called_once() mock_error.assert_called_once() - @mock.patch.object(tethys_gen_commands, "has_conda") + @mock.patch("tethys_cli.gen_commands.has_module") @mock.patch("tethys_cli.gen_commands.write_error") @mock.patch("tethys_cli.gen_commands.call") def test_download_vendor_static_files_no_npm_no_conda( - self, mock_call, mock_error, mock_has_conda + self, mock_call, mock_error, mock_has_module ): mock_call.side_effect = FileNotFoundError - mock_has_conda.__bool__ = lambda self: False + mock_has_module.return_value = False download_vendor_static_files(mock.MagicMock()) mock_call.assert_called_once() mock_error.assert_called_once() diff --git a/tests/unit_tests/test_tethys_cli/test_install_commands.py b/tests/unit_tests/test_tethys_cli/test_install_commands.py index c8b033366..aedcb9d1e 100644 --- a/tests/unit_tests/test_tethys_cli/test_install_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_install_commands.py @@ -22,25 +22,6 @@ def setUp(self): ) self.app.save() - @mock.patch("tethys_cli.cli_colors.write_warning") - def test_no_conda(self, mock_warn): - from importlib import reload - import builtins - - real_import = builtins.__import__ - - def mock_import(name, *args): - if name == "conda.cli.python_api": - raise ModuleNotFoundError - else: - return real_import(name, *args) - - builtins.__import__ = mock_import - reload(install_commands) - builtins.__import__ = real_import - self.assertEqual(install_commands.has_conda, False) - mock_warn.assert_called_once() - @mock.patch("tethys_cli.install_commands.exit") @mock.patch("tethys_cli.cli_colors.pretty_output") def test_open_file_error(self, mock_pretty_output, mock_exit): @@ -1005,13 +986,13 @@ def test_conda_and_pip_package_install( mock_exit.assert_called_with(0) @mock.patch("tethys_cli.install_commands.write_warning") - @mock.patch.object(install_commands, "has_conda") + @mock.patch("tethys_cli.install_commands.has_module") @mock.patch("tethys_cli.install_commands.run_services") @mock.patch("tethys_cli.install_commands.call") @mock.patch("tethys_cli.install_commands.exit") @mock.patch("tethys_cli.cli_colors.pretty_output") def test_conda_install_no_conda( - self, mock_pretty_output, mock_exit, mock_call, _, mock_has_conda, mock_warn + self, mock_pretty_output, mock_exit, mock_call, _, mock_has_module, mock_warn ): file_path = self.root_app_path / "install-dep.yml" args = mock.MagicMock( @@ -1025,7 +1006,7 @@ def test_conda_install_no_conda( without_dependencies=False, ) mock_exit.side_effect = SystemExit - mock_has_conda.__bool__ = lambda self: False + mock_has_module.return_value = False self.assertRaises(SystemExit, install_commands.install_command, args) @@ -1048,13 +1029,13 @@ def test_conda_install_no_conda( mock_exit.assert_called_with(0) @mock.patch("tethys_cli.install_commands.write_warning") - @mock.patch.object(install_commands, "has_conda") + @mock.patch("tethys_cli.install_commands.has_module") @mock.patch("tethys_cli.install_commands.run_services") @mock.patch("tethys_cli.install_commands.call") @mock.patch("tethys_cli.install_commands.exit") @mock.patch("tethys_cli.cli_colors.pretty_output") def test_conda_install_no_conda_error( - self, mock_pretty_output, mock_exit, mock_call, _, mock_has_conda, mock_warn + self, mock_pretty_output, mock_exit, mock_call, _, mock_has_module, mock_warn ): file_path = self.root_app_path / "install-dep.yml" args = mock.MagicMock( @@ -1068,7 +1049,7 @@ def test_conda_install_no_conda_error( without_dependencies=False, ) mock_exit.side_effect = SystemExit - mock_has_conda.__bool__ = lambda self: False + mock_has_module.return_value = False mock_call.side_effect = [Exception, None, None, None] self.assertRaises(SystemExit, install_commands.install_command, args) diff --git a/tests/unit_tests/test_tethys_cli/test_scheduler_commands.py b/tests/unit_tests/test_tethys_cli/test_scheduler_commands.py index eafbde430..867d776eb 100644 --- a/tests/unit_tests/test_tethys_cli/test_scheduler_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_scheduler_commands.py @@ -13,9 +13,9 @@ class SchedulerCommandsTest(unittest.TestCase): def setUp(self): - load_apps_patcher = mock.patch("tethys_cli.scheduler_commands.load_apps") - load_apps_patcher.start() - self.addCleanup(load_apps_patcher.stop) + setup_django_patcher = mock.patch("tethys_cli.scheduler_commands.setup_django") + setup_django_patcher.start() + self.addCleanup(setup_django_patcher.stop) def tearDown(self): pass diff --git a/tests/unit_tests/test_tethys_cli/test_services_commands.py b/tests/unit_tests/test_tethys_cli/test_services_commands.py index 842146ee9..cc8da58d0 100644 --- a/tests/unit_tests/test_tethys_cli/test_services_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_services_commands.py @@ -37,9 +37,9 @@ class ServicesCommandsTest(unittest.TestCase): } def setUp(self): - load_apps_patcher = mock.patch("tethys_cli.services_commands.load_apps") - load_apps_patcher.start() - self.addCleanup(load_apps_patcher.stop) + setup_django_patcher = mock.patch("tethys_cli.services_commands.setup_django") + setup_django_patcher.start() + self.addCleanup(setup_django_patcher.stop) def tearDown(self): pass diff --git a/tests/unit_tests/test_tethys_cli/test_site_commands.py b/tests/unit_tests/test_tethys_cli/test_site_commands.py index 63e71dd66..71ba0c541 100644 --- a/tests/unit_tests/test_tethys_cli/test_site_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_site_commands.py @@ -18,25 +18,25 @@ def tearDown(self): pass @mock.patch("tethys_cli.site_commands.update_site_settings_content") - @mock.patch("tethys_cli.site_commands.load_apps") - def test_gen_site_content(self, mock_load_apps, mock_update_settings): + @mock.patch("tethys_cli.site_commands.setup_django") + def test_gen_site_content(self, mock_setup_django, mock_update_settings): mock_args = mock.MagicMock( brand_text="New Title", restore_defaults=False, from_file=False ) gen_site_content(mock_args) - mock_load_apps.assert_called() + mock_setup_django.assert_called() mock_update_settings.assert_called_once_with(vars(mock_args)) @mock.patch("tethys_cli.site_commands.update_site_settings_content") @mock.patch("tethys_config.init.setting_defaults") @mock.patch("tethys_config.models.SettingsCategory.objects.get") @mock.patch("tethys_config.models.Setting.objects.all") - @mock.patch("tethys_cli.site_commands.load_apps") + @mock.patch("tethys_cli.site_commands.setup_django") def test_gen_site_content_restore_defaults( self, - mock_load_apps, + mock_setup_django, mock_all, mock_get, mock_setting_defaults, @@ -46,7 +46,7 @@ def test_gen_site_content_restore_defaults( gen_site_content(mock_args) - mock_load_apps.assert_called() + mock_setup_django.assert_called() mock_all().delete.assert_called() @@ -61,9 +61,9 @@ def test_gen_site_content_restore_defaults( @mock.patch("tethys_cli.site_commands.update_site_settings_content") @mock.patch("tethys_cli.site_commands.Path") - @mock.patch("tethys_cli.site_commands.load_apps") + @mock.patch("tethys_cli.site_commands.setup_django") def test_gen_site_content_with_yaml( - self, mock_load_apps, mock_path, mock_update_settings + self, mock_setup_django, mock_path, mock_update_settings ): file_path = os.path.join(self.root_app_path, "test-portal_config.yml") mock_file_path = mock.MagicMock() @@ -75,7 +75,7 @@ def test_gen_site_content_with_yaml( gen_site_content(mock_args) - mock_load_apps.assert_called() + mock_setup_django.assert_called() self.assertEqual(5, mock_update_settings.call_count) mock_update_settings.assert_called_with(vars(mock_args)) @@ -84,9 +84,14 @@ def test_gen_site_content_with_yaml( @mock.patch("tethys_cli.site_commands.yaml.safe_load") @mock.patch("tethys_cli.site_commands.update_site_settings_content") @mock.patch("tethys_cli.site_commands.Path") - @mock.patch("tethys_cli.site_commands.load_apps") + @mock.patch("tethys_cli.site_commands.setup_django") def test_gen_site_content_with_yaml_invalid_category( - self, mock_load_apps, mock_path, mock_update_settings, mock_load_yaml, mock_warn + self, + mock_setup_django, + mock_path, + mock_update_settings, + mock_load_yaml, + mock_warn, ): mock_file_path = mock.MagicMock() mock_path.return_value = mock_file_path @@ -97,7 +102,7 @@ def test_gen_site_content_with_yaml_invalid_category( gen_site_content(mock_args) - mock_load_apps.assert_called() + mock_setup_django.assert_called() self.assertEqual(5, mock_update_settings.call_count) mock_update_settings.assert_called_with(vars(mock_args)) diff --git a/tests/unit_tests/test_tethys_config/test_init.py b/tests/unit_tests/test_tethys_config/test_init.py index d4c10bed2..1f6ba9c82 100644 --- a/tests/unit_tests/test_tethys_config/test_init.py +++ b/tests/unit_tests/test_tethys_config/test_init.py @@ -46,7 +46,7 @@ def test_custom_settings(self, mock_settings, mock_defaults, mock_init_settings) custom_settings(apps=mock_apps, schema_editor=mock_schema_editor) - mock_init_settings.called_with(mock_apps, mock_schema_editor) + mock_init_settings.assert_called_with(mock_apps, mock_schema_editor) mock_settings.assert_has_calls( [mock.call(name="Custom Styles"), mock.call(name="Custom Templates")], any_order=True, diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_bokeh_view.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_bokeh_view.py index 566e01e56..a2404bcc8 100644 --- a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_bokeh_view.py +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_bokeh_view.py @@ -12,7 +12,7 @@ def tearDown(self): pass def test_BokehView(self): - plot = figure(plot_height=300) + plot = figure(height=300) plot.circle([1, 2], [3, 4]) attr = {"title": "test title", "description": "test attributes"} result = bokeh_view.BokehView(plot, attributes=attr) @@ -43,3 +43,13 @@ def test_get_bokeh_resources_server(self, mock_resources): files = bokeh_view.BokehView._get_bokeh_resources("js") for f in files: self.assertNotIn("/static", f) + + @mock.patch("tethys_gizmos.gizmo_options.bokeh_view.bokeh") + @mock.patch("tethys_gizmos.gizmo_options.bokeh_view.bk_settings") + def test_bokeh_resources_inline(self, mock_bk_settings, mock_bokeh): + mock_bokeh.__version__ = "3." + mock_bk_settings.resources.return_value = "inline" + bokeh_view.BokehView._bk_resources = None + bokeh_resources = bokeh_view.BokehView.bk_resources + self.assertEqual(bokeh_resources.mode, "server") + self.assertEqual(bokeh_resources.root_url, "/") diff --git a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_jobs_table.py b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_jobs_table.py index e54bbb795..a734e7228 100644 --- a/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_jobs_table.py +++ b/tests/unit_tests/test_tethys_gizmos/test_gizmo_options/test_jobs_table.py @@ -12,6 +12,7 @@ def __init__(self, id, name, description, creation_time, run_time): self.run_time = run_time self.extended_properties = {"processing_results": True} self.status = "Pending" + self.cached_status = self.status def __lt__(self, other): return self.id < other.id diff --git a/tests/unit_tests/test_tethys_portal/test_optional_dependencies.py b/tests/unit_tests/test_tethys_portal/test_optional_dependencies.py new file mode 100644 index 000000000..02b61405a --- /dev/null +++ b/tests/unit_tests/test_tethys_portal/test_optional_dependencies.py @@ -0,0 +1,41 @@ +import unittest +from tethys_portal import optional_dependencies + + +class TestStaticDependency(unittest.TestCase): + def setUp(self): + self.module = "test_module" + self.import_error = "error" + self.failed_import = optional_dependencies.FailedImport( + self.module, self.import_error + ) + + def tearDown(self): + pass + + def test_failed_import_init(self): + module = "test_module" + import_error = "error" + failed_import = optional_dependencies.FailedImport(module, import_error) + self.assertEqual(failed_import.module_name, module) + self.assertEqual(failed_import.error, import_error) + + def test_failed_import_call(self): + self.assertRaises(ImportError, lambda: self.failed_import.test()) + + def test_failed_import_getattr(self): + self.assertRaises(ImportError, lambda: self.failed_import.test) + + def test_failed_import_getitem(self): + self.assertRaises(ImportError, lambda: self.failed_import["test"]) + + def test__attempt_import_error(self): + module = optional_dependencies._attempt_import( + self.module, from_module=None, error_message=None + ) + self.assertIsInstance(module, optional_dependencies.FailedImport) + + def test_verify_import(self): + self.assertRaises( + ImportError, optional_dependencies.verify_import, self.failed_import + ) diff --git a/tests/unit_tests/test_tethys_portal/test_settings.py b/tests/unit_tests/test_tethys_portal/test_settings.py index cd92dfda6..a6ea5fb7a 100644 --- a/tests/unit_tests/test_tethys_portal/test_settings.py +++ b/tests/unit_tests/test_tethys_portal/test_settings.py @@ -253,3 +253,10 @@ def test_prefix_to_path_settings(self, _): self.assertEqual(settings.PREFIX_URL, "test") self.assertEqual(settings.STATIC_URL, "/test/static/") self.assertEqual(settings.LOGIN_URL, "/test/accounts/login/") + + @mock.patch("tethys_portal.optional_dependencies.has_module", return_value=True) + def test_bokeh_django_staticfiles_finder(self, _): + reload(settings) + self.assertIn( + "bokeh_django.static.BokehExtensionFinder", settings.STATICFILES_FINDERS + ) diff --git a/tests/unit_tests/test_tethys_portal/test_urls.py b/tests/unit_tests/test_tethys_portal/test_urls.py index 836a8eba4..fd3479cdd 100644 --- a/tests/unit_tests/test_tethys_portal/test_urls.py +++ b/tests/unit_tests/test_tethys_portal/test_urls.py @@ -411,3 +411,35 @@ def test_custom_register_controller_not_class_based_view( self.assertEqual(tethys_portal.urls.register_controller_setting, "test") self.assertEqual(tethys_portal.urls.register_controller, mock_controller) mock_func_extractor.assert_called_once() + + @override_settings( + ADDITIONAL_URLPATTERNS=["my.test.urlpatterns"], + PREFIX_URL=None, + LOGIN_URL=None, + ) + @mock.patch( + "importlib.import_module", return_value=mock.MagicMock(urlpatterns=["re_path"]) + ) + def test_additional_urls( + self, + mock_import_module, + ): + import tethys_portal.urls + from importlib import reload + + reload(tethys_portal.urls) + mock_import_module.assert_called_with("my.test") + self.assertEqual(tethys_portal.urls.additional_url_patterns, ["re_path"]) + self.assertEqual(tethys_portal.urls.urlpatterns[0], "re_path") + + @override_settings(ADDITIONAL_URLPATTERNS=["my.test.urlpatterns"]) + @mock.patch("tethys_portal.urls.logging.getLogger") + def test_additional_urls_exception( + self, + mock_logger, + ): + import tethys_portal.urls + from importlib import reload + + reload(tethys_portal.urls) + self.assertEqual(mock_logger().exception.call_count, 2) diff --git a/tethys_apps/admin.py b/tethys_apps/admin.py index 8bdf107e2..b48d828c3 100644 --- a/tethys_apps/admin.py +++ b/tethys_apps/admin.py @@ -18,12 +18,10 @@ from django.utils.html import format_html from django.shortcuts import reverse from django.db import models -from django_json_widget.widgets import JSONEditorWidget from tethys_quotas.admin import TethysAppQuotasSettingInline, UserQuotasSettingInline from guardian.admin import GuardedModelAdmin from guardian.shortcuts import assign_perm, remove_perm from guardian.models import GroupObjectPermission -from mfa.models import User_Keys from tethys_quotas.utilities import get_quota, _convert_storage_units from tethys_quotas.handlers.workspace import WorkspaceQuotaHandler from tethys_apps.models import ( @@ -40,7 +38,17 @@ PersistentStoreDatabaseSetting, ProxyApp, ) +from tethys_portal.optional_dependencies import ( + optional_import, + has_module, + MissingOptionalDependency, +) +# optional imports +User_Keys = optional_import("User_Keys", from_module="mfa.models") +JSONEditorWidget = optional_import( + "JSONEditorWidget", from_module="django_json_widget.widgets" +) tethys_log = logging.getLogger("tethys." + __name__) @@ -95,13 +103,14 @@ class JSONCustomSettingInline(TethysAppSettingInline): width_default = "100%" height_default = "300px" - formfield_overrides = { - models.JSONField: { - "widget": JSONEditorWidget( - width=width_default, height=height_default, options=options_default - ) - }, - } + if has_module(JSONEditorWidget): + formfield_overrides = { + models.JSONField: { + "widget": JSONEditorWidget( + width=width_default, height=height_default, options=options_default + ) + }, + } class DatasetServiceSettingInline(TethysAppSettingInline): @@ -529,7 +538,7 @@ def register_user_keys_admin(): User_Keys._meta.verbose_name = "Users MFA Key" User_Keys._meta.verbose_name_plural = "Users MFA Keys" admin.site.register(User_Keys, UserKeyAdmin) - except ProgrammingError: + except (ProgrammingError, MissingOptionalDependency): tethys_log.warning("Unable to register UserKeys.") @@ -540,7 +549,8 @@ class ProxyAppAdmin(GuardedModelAdmin): register_custom_group() admin.site.unregister(User) admin.site.register(User, CustomUser) -register_user_keys_admin() +if has_module(User_Keys): + register_user_keys_admin() admin.site.register(ProxyApp, ProxyAppAdmin) admin.site.register(TethysApp, TethysAppAdmin) admin.site.register(TethysExtension, TethysExtensionAdmin) diff --git a/tethys_apps/base/bokeh_handler.py b/tethys_apps/base/bokeh_handler.py index 9c94da996..cbe0c4433 100644 --- a/tethys_apps/base/bokeh_handler.py +++ b/tethys_apps/base/bokeh_handler.py @@ -10,8 +10,6 @@ from functools import wraps # Third Party Imports -from bokeh.document import Document -from bokeh.embed import server_document # Django Imports from django.http.request import HttpRequest @@ -20,6 +18,12 @@ # Tethys Imports from tethys_sdk.workspaces import get_user_workspace, get_app_workspace +from tethys_portal.optional_dependencies import optional_import + +# Optional Imports +Document = optional_import("Document", from_module="bokeh.document") +server_document = optional_import("server_document", from_module="bokeh.embed") + def with_request(handler): @wraps(handler) diff --git a/tethys_apps/migrations/0001_initial_40.py b/tethys_apps/migrations/0001_initial_40.py deleted file mode 100644 index 170d77487..000000000 --- a/tethys_apps/migrations/0001_initial_40.py +++ /dev/null @@ -1,390 +0,0 @@ -# Generated by Django 3.2.12 on 2022-08-12 16:39 -# flake8: noqa - -import django.contrib.postgres.fields -from django.db import migrations, models -import django.db.models.deletion -import tethys_apps.base.mixins -import tethys_services.models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("tethys_services", "0001_initial_40"), - ("tethys_compute", "0001_initial_40"), - ] - - operations = [ - migrations.CreateModel( - name="TethysApp", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("package", models.CharField(default="", max_length=200, unique=True)), - ("name", models.CharField(default="", max_length=200)), - ( - "description", - models.TextField(blank=True, default="", max_length=1000), - ), - ("enable_feedback", models.BooleanField(default=False)), - ( - "feedback_emails", - django.contrib.postgres.fields.ArrayField( - base_field=models.CharField( - blank=True, max_length=200, null=True - ), - default=list, - size=None, - ), - ), - ("tags", models.CharField(blank=True, default="", max_length=200)), - ("index", models.CharField(default="", max_length=200)), - ("icon", models.CharField(default="", max_length=200)), - ("root_url", models.CharField(default="", max_length=200)), - ("color", models.CharField(default="", max_length=10)), - ("enabled", models.BooleanField(default=True)), - ("show_in_apps_library", models.BooleanField(default=True)), - ("order", models.IntegerField(default=0)), - ], - options={ - "verbose_name": "Tethys App", - "verbose_name_plural": "Installed Apps", - }, - bases=(models.Model, tethys_apps.base.mixins.TethysBaseMixin), - ), - migrations.CreateModel( - name="TethysAppSetting", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(default="", max_length=200)), - ( - "description", - models.TextField(blank=True, default="", max_length=1000), - ), - ("required", models.BooleanField(default=True)), - ("initializer", models.CharField(default="", max_length=1000)), - ("initialized", models.BooleanField(default=False)), - ( - "tethys_app", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="settings_set", - to="tethys_apps.tethysapp", - ), - ), - ], - ), - migrations.CreateModel( - name="WebProcessingServiceSetting", - fields=[ - ( - "tethysappsetting_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_apps.tethysappsetting", - ), - ), - ( - "web_processing_service", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="tethys_services.webprocessingservice", - ), - ), - ], - bases=("tethys_apps.tethysappsetting",), - ), - migrations.CreateModel( - name="SpatialDatasetServiceSetting", - fields=[ - ( - "tethysappsetting_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_apps.tethysappsetting", - ), - ), - ( - "engine", - models.CharField( - choices=[ - ( - "tethys_dataset_services.engines.GeoServerSpatialDatasetEngine", - "GeoServer", - ), - ("thredds-engine", "THREDDS"), - ], - default="tethys_dataset_services.engines.GeoServerSpatialDatasetEngine", - max_length=200, - ), - ), - ( - "spatial_dataset_service", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="tethys_services.spatialdatasetservice", - ), - ), - ], - bases=("tethys_apps.tethysappsetting",), - ), - migrations.CreateModel( - name="PersistentStoreDatabaseSetting", - fields=[ - ( - "tethysappsetting_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_apps.tethysappsetting", - ), - ), - ("spatial", models.BooleanField(default=False)), - ("dynamic", models.BooleanField(default=False)), - ( - "persistent_store_service", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="tethys_services.persistentstoreservice", - ), - ), - ], - bases=("tethys_apps.tethysappsetting",), - ), - migrations.CreateModel( - name="PersistentStoreConnectionSetting", - fields=[ - ( - "tethysappsetting_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_apps.tethysappsetting", - ), - ), - ( - "persistent_store_service", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="tethys_services.persistentstoreservice", - ), - ), - ], - bases=("tethys_apps.tethysappsetting",), - ), - migrations.CreateModel( - name="DatasetServiceSetting", - fields=[ - ( - "tethysappsetting_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_apps.tethysappsetting", - ), - ), - ( - "engine", - models.CharField( - choices=[ - ( - "tethys_dataset_services.engines.CkanDatasetEngine", - "CKAN", - ), - ( - "tethys_dataset_services.engines.HydroShareDatasetEngine", - "HydroShare", - ), - ], - default="tethys_dataset_services.engines.CkanDatasetEngine", - max_length=200, - ), - ), - ( - "dataset_service", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="tethys_services.datasetservice", - ), - ), - ], - bases=("tethys_apps.tethysappsetting",), - ), - migrations.CreateModel( - name="CustomSetting", - fields=[ - ( - "tethysappsetting_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_apps.tethysappsetting", - ), - ), - ("value", models.CharField(blank=True, default="", max_length=1024)), - ("default", models.CharField(blank=True, default="", max_length=1024)), - ( - "type", - models.CharField( - choices=[ - ("STRING", "String"), - ("INTEGER", "Integer"), - ("FLOAT", "Float"), - ("BOOLEAN", "Boolean"), - ("UUID", "UUID"), - ], - default="STRING", - max_length=200, - ), - ), - ], - bases=("tethys_apps.tethysappsetting",), - ), - migrations.CreateModel( - name="TethysExtension", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("package", models.CharField(default="", max_length=200, unique=True)), - ("name", models.CharField(default="", max_length=200)), - ( - "description", - models.TextField(blank=True, default="", max_length=1000), - ), - ("root_url", models.CharField(default="", max_length=200)), - ("enabled", models.BooleanField(default=True)), - ], - options={ - "verbose_name": "Tethys Extension", - "verbose_name_plural": "Installed Extensions", - }, - bases=(models.Model, tethys_apps.base.mixins.TethysBaseMixin), - ), - migrations.CreateModel( - name="SchedulerSetting", - fields=[ - ( - "tethysappsetting_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_apps.tethysappsetting", - ), - ), - ( - "engine", - models.CharField( - choices=[("htcondor", "HTCondor"), ("dask", "Dask")], - default="htcondor", - max_length=200, - ), - ), - ( - "scheduler_service", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="tethys_compute.scheduler", - ), - ), - ], - bases=("tethys_apps.tethysappsetting",), - ), - migrations.CreateModel( - name="ProxyApp", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100, unique=True)), - ( - "endpoint", - models.CharField( - max_length=1024, - validators=[tethys_services.models.validate_url], - ), - ), - ( - "logo_url", - models.CharField( - blank=True, - max_length=100, - validators=[tethys_services.models.validate_url], - ), - ), - ("description", models.TextField(blank=True, max_length=2048)), - ("tags", models.CharField(blank=True, default="", max_length=200)), - ("enabled", models.BooleanField(default=True)), - ("show_in_apps_library", models.BooleanField(default=True)), - ("order", models.IntegerField(default=0)), - ], - options={ - "verbose_name": "Proxy App", - "verbose_name_plural": "Proxy Apps", - }, - ), - ] diff --git a/tethys_apps/migrations/0001_initial_41.py b/tethys_apps/migrations/0001_initial_41.py index d49d0db82..aa5ab0d7d 100644 --- a/tethys_apps/migrations/0001_initial_41.py +++ b/tethys_apps/migrations/0001_initial_41.py @@ -6,11 +6,6 @@ class Migration(migrations.Migration): - replaces = [ - ("tethys_apps", "0001_initial_40"), - ("tethys_apps", "0002_auto_20221130_2305"), - ] - initial = True dependencies = [ diff --git a/tethys_apps/migrations/0002_auto_20221130_2305.py b/tethys_apps/migrations/0002_auto_20221130_2305.py deleted file mode 100644 index a4c997e0d..000000000 --- a/tethys_apps/migrations/0002_auto_20221130_2305.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 3.2.16 on 2022-11-30 23:05 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("tethys_apps", "0001_initial_40"), - ] - - operations = [ - migrations.AddField( - model_name="proxyapp", - name="back_url", - field=models.URLField(blank=True, max_length=512), - ), - migrations.AddField( - model_name="proxyapp", - name="open_in_new_tab", - field=models.BooleanField(default=True), - ), - migrations.AlterField( - model_name="proxyapp", - name="endpoint", - field=models.URLField(max_length=512), - ), - migrations.AlterField( - model_name="proxyapp", - name="logo_url", - field=models.URLField(blank=True, max_length=512), - ), - ] diff --git a/tethys_apps/models.py b/tethys_apps/models.py index 961b46e8f..99c73afc4 100644 --- a/tethys_apps/models.py +++ b/tethys_apps/models.py @@ -7,7 +7,6 @@ * License: BSD 2-Clause ******************************************************************************** """ -import sqlalchemy from django.dispatch import receiver import logging import uuid @@ -22,7 +21,6 @@ PersistentStorePermissionError, PersistentStoreInitializerError, ) -from sqlalchemy.orm import sessionmaker from tethys_apps.base.mixins import TethysBaseMixin from tethys_compute.models.condor.condor_scheduler import CondorScheduler from tethys_compute.models.dask.dask_scheduler import DaskScheduler @@ -30,6 +28,11 @@ from tethys_sdk.testing import is_testing_environment, get_test_db_name from tethys_apps.base.function_extractor import TethysFunctionExtractor from tethys_apps.utilities import secrets_signed_unsigned_value +from tethys_portal.optional_dependencies import optional_import, has_module + +# optional imports +sqlalchemy = optional_import("sqlalchemy") +sessionmaker = optional_import("sessionmaker", from_module="sqlalchemy.orm") log = logging.getLogger("tethys") @@ -203,7 +206,8 @@ class TethysAppSetting(models.Model): DB Model for Tethys App Settings """ - objects = InheritanceManager() + if has_module(InheritanceManager): + objects = InheritanceManager() tethys_app = models.ForeignKey( TethysApp, on_delete=models.CASCADE, related_name="settings_set" diff --git a/tethys_apps/templates/tethys_apps/app_base.html b/tethys_apps/templates/tethys_apps/app_base.html index 3c2b1ce4a..7b6c1d76f 100644 --- a/tethys_apps/templates/tethys_apps/app_base.html +++ b/tethys_apps/templates/tethys_apps/app_base.html @@ -1,4 +1,4 @@ -{% load static app_theme tethys_gizmos terms_tags analytical %} +{% load static app_theme tethys_gizmos %} {# Allows custom attributes to be added to the html tag #} @@ -11,7 +11,9 @@ {# Allows custom attributes to be added to the head tag #} - {% analytical_head_top %} + {% if has_analytical %} + {% include "analytical_head_top.html" %} + {% endif %} {% comment "meta explanation" %} Add custom meta tags to the page. Call block.super to get the default tags @@ -135,17 +137,23 @@ {% gizmo_dependencies global_js %} {% endblock %} + {% if has_session_security %} {% block session_timeout_modal %} {% include 'session_security/all.html' %} {% endblock %} + {% endif %} - {% analytical_head_bottom %} + {% if has_analytical %} + {% include "analytical_head_bottom.html" %} + {% endif %} {# Allows custom attributes to be added to the body tag #} - {% analytical_body_top %} + {% if has_analytical %} + {% include "analytical_body_top.html" %} + {% endif %} {% block app_content_wrapper_override %}
@@ -273,7 +281,9 @@ {% endblock %} {% block terms-of-service-override %} - {% show_terms_if_not_agreed %} + {% if has_terms %} + {% include "terms.html" %} + {% endif %} {% endblock %} {% block page_attributes_override %} @@ -310,6 +320,8 @@ {% gizmo_dependencies js %} {% endblock %} - {% analytical_body_bottom %} + {% if has_analytical %} + {% include "analytical_body_bottom.html" %} + {% endif %} diff --git a/tethys_apps/templatetags/app_theme.py b/tethys_apps/templatetags/app_theme.py index 2f8ccb3df..46ea1ac4c 100644 --- a/tethys_apps/templatetags/app_theme.py +++ b/tethys_apps/templatetags/app_theme.py @@ -8,6 +8,16 @@ @register.filter def lighten(hex_color, percentage): + """ + Lighten a hex color by a certain percentage and return the lightened color. + + Args: + hex_color: A hex color value in the format "#2d3436". + percentage: A number (0-100) representing a percentage to lighten the color by. + + Returns: A hex color value in the format "#2d3436" + + """ if not re.search(hex_regex_pattern, hex_color): raise ValueError( f'Given "{hex_color}", but needs to be in hex color format (e.g.: "#2d3436").' diff --git a/tethys_apps/templatetags/humanize.py b/tethys_apps/templatetags/humanize.py index 2fbfaf832..406ceb171 100644 --- a/tethys_apps/templatetags/humanize.py +++ b/tethys_apps/templatetags/humanize.py @@ -1,7 +1,12 @@ -import arrow -import isodate from django import template +from tethys_portal.optional_dependencies import optional_import + +# optional imports +arrow = optional_import("arrow") +isodate = optional_import("isodate") + + register = template.Library() @@ -10,11 +15,29 @@ def human_duration(iso_duration_str): """ Converts an ISO 8601 formatted duration to a humanized time from now (UTC). + .. important:: + + This feature requires the `arrow` and `isodate` libraries to be installed. Starting with Tethys 5.0 or if you are + using `micro-tethys-platform`, you will need to install these libraries using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge arrow isodate + + # pip + pip install arrow isodate + Args: iso_duration_str: An ISO 8601 formatted string (e.g. "P1DT3H6M") Returns: str: humanized string representing the amount of time from now (e.g.: "in 30 minutes"). + + Usage: + {% load static humanize %} + + {{ P1DT3H6M|human_duration }} """ time_change = isodate.parse_duration(iso_duration_str) now = arrow.utcnow() diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 7b04f8ffb..dc3c14c27 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -10,15 +10,17 @@ import importlib import logging import os +from pathlib import Path + import pkgutil import yaml -from pathlib import Path + from django.core.signing import Signer from django.core import signing -from tethys_apps.exceptions import TethysAppSettingNotAssigned - from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned from django.utils._os import safe_join + +from tethys_apps.exceptions import TethysAppSettingNotAssigned from .harvester import SingletonHarvester tethys_log = logging.getLogger("tethys." + __name__) diff --git a/tethys_apps/views.py b/tethys_apps/views.py index 4c4eb892a..ccbed923e 100644 --- a/tethys_apps/views.py +++ b/tethys_apps/views.py @@ -156,7 +156,7 @@ def update_job_status(request, job_id): Callback endpoint for jobs to update status. """ try: - job = TethysJob.objects.filter(id=job_id)[0] + job = TethysJob.objects.get_subclass(id=job_id) job.status json = {"success": True} except Exception: diff --git a/tethys_cli/app_settings_commands.py b/tethys_cli/app_settings_commands.py index 5643d9d21..cb71dad28 100644 --- a/tethys_cli/app_settings_commands.py +++ b/tethys_cli/app_settings_commands.py @@ -15,7 +15,7 @@ write_warning, write_msg, ) -from tethys_cli.cli_helpers import load_apps, gen_salt_string_for_setting +from tethys_cli.cli_helpers import setup_django, gen_salt_string_for_setting from subprocess import call TETHYS_HOME = Path(get_tethys_home_dir()) @@ -170,7 +170,7 @@ def add_app_settings_parser(subparsers): def app_settings_list_command(args): - load_apps() + setup_django() app_settings = get_app_settings(args.app) if app_settings is None: return @@ -233,7 +233,7 @@ def app_settings_list_command(args): def app_settings_set_command(args): - load_apps() + setup_django() setting = get_custom_setting(args.app, args.setting) actual_value = args.value @@ -278,7 +278,7 @@ def app_settings_set_command(args): def app_settings_reset_command(args): - load_apps() + setup_django() setting = get_custom_setting(args.app, args.setting) if not setting: @@ -322,7 +322,7 @@ def get_setting_type(setting): def app_settings_create_ps_database_command(args): - load_apps() + setup_django() from tethys_apps.utilities import create_ps_database_setting app_package = args.app @@ -352,7 +352,7 @@ def app_settings_create_ps_database_command(args): def app_settings_remove_command(args): - load_apps() + setup_django() from tethys_apps.utilities import remove_ps_database_setting app_package = args.app @@ -367,7 +367,7 @@ def app_settings_remove_command(args): def app_settings_gen_salt_strings_command(args): - load_apps() + setup_django() # create a list for apps, settings, and salt strings from tethys_apps.models import TethysApp, CustomSettingBase, TethysExtension diff --git a/tethys_cli/cli_helpers.py b/tethys_cli/cli_helpers.py index ec0ba650e..7e4431e5a 100644 --- a/tethys_cli/cli_helpers.py +++ b/tethys_cli/cli_helpers.py @@ -1,11 +1,12 @@ import os import sys import subprocess -import bcrypt -import yaml +from pathlib import Path +from functools import wraps +import bcrypt import django -from pathlib import Path +import yaml from tethys_apps.base.testing.environment import set_testing_environment from tethys_apps.utilities import ( @@ -67,11 +68,23 @@ def run_process(process): set_testing_environment(False) -def load_apps(): - stdout = sys.stdout - sys.stdout = open(os.devnull, "w") - django.setup() - sys.stdout = stdout +def supress_stdout(func): + @wraps(func) + def wrapped(*args, **kwargs): + stdout = sys.stdout + sys.stdout = open(os.devnull, "w") + result = func(*args, **kwargs) + sys.stdout = stdout + return result + + return wrapped + + +def setup_django(supress_output=False): + func = django.setup + if supress_output: + func = supress_stdout(func) + func() def generate_salt_string(): diff --git a/tethys_cli/db_commands.py b/tethys_cli/db_commands.py index 23f1276e8..fee76b98a 100644 --- a/tethys_cli/db_commands.py +++ b/tethys_cli/db_commands.py @@ -15,7 +15,7 @@ from django.conf import settings from django.db.utils import IntegrityError -from tethys_cli.cli_helpers import get_manage_path, run_process, load_apps +from tethys_cli.cli_helpers import get_manage_path, run_process, setup_django from tethys_cli.cli_colors import write_info, write_error from tethys_apps.utilities import relative_to_tethys_home @@ -399,7 +399,7 @@ def sync_tethys_apps_db(**kwargs): **kwargs: processed key word arguments from commandline """ write_info("Syncing the Tethys database with installed apps and extensions...") - load_apps() + setup_django() from tethys_apps.harvester import SingletonHarvester harvester = SingletonHarvester() @@ -421,7 +421,7 @@ def create_portal_superuser( **kwargs: processed key word arguments from commandline """ write_info(f'Creating Tethys Portal superuser "{portal_superuser_name}"...') - load_apps() + setup_django() from django.contrib.auth.models import User # noqa: E402 try: @@ -472,6 +472,10 @@ def configure_tethys_db(**kwargs): _prompt_if_error(start_db_server, **kwargs) if "postgresql" in kwargs.get("db_engine"): _prompt_if_error(create_tethys_db, **kwargs) + if "sqlite" in kwargs.get("db_engine"): + # Make sure the parent directory for the database exists + db_path = Path(kwargs["db_name"]) + db_path.parent.mkdir(exist_ok=True, parents=True) migrate_tethys_db(**kwargs) create_portal_superuser(**kwargs) diff --git a/tethys_cli/docker_commands.py b/tethys_cli/docker_commands.py index 5f0606f04..c78aa5b9e 100644 --- a/tethys_cli/docker_commands.py +++ b/tethys_cli/docker_commands.py @@ -7,21 +7,20 @@ * License: BSD 2-Clause ******************************************************************************** """ -try: - import curses -except Exception: # pragma: no cover - pass # curses not available on Windows -import platform import os import json from abc import ABC, abstractmethod import getpass -import docker -from docker.types import Mount -from docker.errors import NotFound as DockerNotFound from tethys_cli.cli_colors import write_pretty_output, write_error, write_warning from tethys_apps.utilities import get_tethys_home_dir +from tethys_portal.optional_dependencies import optional_import, has_module + +# optional imports +curses = optional_import("curses") # curses not available on Windows +docker = optional_import("docker") +Mount = optional_import("Mount", from_module="docker.types") +DockerNotFound = optional_import("NotFound", from_module="docker.errors") __all__ = [ @@ -1008,7 +1007,7 @@ def log_pull_stream(stream): """ Handle the printing of pull statuses """ - if platform.system() == "Windows": # i.e. can't uses curses + if not has_module(curses): for block in stream: lines = [line for line in block.split(b"\r\n") if line] for line in lines: diff --git a/tethys_cli/gen_commands.py b/tethys_cli/gen_commands.py index fb34cf2f8..3d2745071 100644 --- a/tethys_cli/gen_commands.py +++ b/tethys_cli/gen_commands.py @@ -15,10 +15,8 @@ from pathlib import Path from subprocess import call, run -import yaml -from yaml import safe_load -from distro import linux_distribution from jinja2 import Template +import yaml from django.conf import settings @@ -31,17 +29,16 @@ ) from tethys_portal.dependencies import vendor_static_dependencies from tethys_cli.cli_colors import write_error, write_info, write_warning -from tethys_cli.cli_helpers import load_apps +from tethys_cli.cli_helpers import setup_django from .site_commands import SITE_SETTING_CATEGORIES -has_conda = False -try: - from conda.cli.python_api import run_command, Commands +from tethys_portal.optional_dependencies import optional_import, has_module - has_conda = True -except ModuleNotFoundError: - write_warning("Conda not found. Some functionality will not be available.") +# optional imports +run_command, Commands = optional_import( + ("run_command", "Commands"), from_module="conda.cli.python_api" +) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tethys_portal.settings") @@ -112,6 +109,11 @@ def add_gen_parser(subparsers): help='Level to pin dependencies when generating the meta.yaml. One of "major", "minor", ' '"patch", or "none". Defaults to "none".', ) + gen_parser.add_argument( + "--micro", + action="store_true", + help="Use micro-tethys dependencies when generating the meta.yaml.", + ) gen_parser.add_argument( "--client-max-body-size", dest="client_max_body_size", @@ -199,6 +201,7 @@ def add_gen_parser(subparsers): server_port=None, overwrite=False, pin_level="none", + micro=False, ssl=False, ssl_cert="", ssl_key="", @@ -291,15 +294,6 @@ def gen_asgi_service(args): conda_home = Path(conda_prefix).parents[1] conda_env_name = Path(conda_prefix).name - user_option_prefix = "" - - try: - linux_distro = linux_distribution(full_distribution_name=0)[0] - if linux_distro in ["redhat", "centos"]: - user_option_prefix = "http-" - except Exception: - pass - context = { "nginx_user": nginx_user, "port": args.tethys_port, @@ -309,7 +303,6 @@ def gen_asgi_service(args): "conda_env_name": conda_env_name, "tethys_src": TETHYS_SRC, "tethys_home": TETHYS_HOME, - "user_option_prefix": user_option_prefix, "is_micromamba": args.micromamba, } return context @@ -346,7 +339,7 @@ def gen_portal_yaml(args): def gen_secrets_yaml(args): - load_apps() + setup_django() tethys_secrets_settings = {} tethys_secrets_settings.setdefault("version", 1.0) tethys_secrets_settings.setdefault("secrets", {}) @@ -437,9 +430,11 @@ def derive_version_from_conda_environment(dep_str, level="none"): def gen_meta_yaml(args): - environment_file_path = os.path.join(TETHYS_SRC, "environment.yml") + filename = "micro_environment.yml" if args.micro else "environment.yml" + package_name = "micro-tethys-platform" if args.micro else "tethys-platform" + environment_file_path = os.path.join(TETHYS_SRC, filename) with open(environment_file_path, "r") as env_file: - environment = safe_load(env_file) + environment = yaml.safe_load(env_file) dependencies = environment.get("dependencies", []) run_requirements = [] @@ -454,7 +449,9 @@ def gen_meta_yaml(args): run_requirements.append(dependency) context = dict( - run_requirements=run_requirements, tethys_version=tethys_portal.__version__ + run_requirements=run_requirements, + tethys_version=tethys_portal.__version__, + package_name=package_name, ) return context @@ -480,7 +477,7 @@ def download_vendor_static_files(args, cwd=None): install_instructions = ( "To get npm you must install nodejs. Run the following command to install nodejs:" "\n\n\tconda install -c conda-forge nodejs\n" - if has_conda + if has_module(run_command) else "For help installing npm see: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm" ) msg = ( diff --git a/tethys_cli/gen_templates/metayaml b/tethys_cli/gen_templates/metayaml index 5dcd418cd..9cc998f75 100644 --- a/tethys_cli/gen_templates/metayaml +++ b/tethys_cli/gen_templates/metayaml @@ -4,7 +4,7 @@ package: - name: tethys-platform + name: {{ package_name }} version: {{ tethys_version }} source: diff --git a/tethys_cli/install_commands.py b/tethys_cli/install_commands.py index 8b59ac885..da3430ebb 100644 --- a/tethys_cli/install_commands.py +++ b/tethys_cli/install_commands.py @@ -5,12 +5,14 @@ from pathlib import Path from subprocess import call, Popen, PIPE, STDOUT from argparse import Namespace +from collections.abc import Mapping + from django.core.exceptions import ObjectDoesNotExist, ValidationError from tethys_cli.cli_colors import write_msg, write_error, write_warning, write_success from tethys_cli.services_commands import services_list_command from tethys_cli.cli_helpers import ( - load_apps, + setup_django, generate_salt_string, ) from tethys_apps.utilities import ( @@ -21,15 +23,12 @@ ) from .gen_commands import download_vendor_static_files -from collections.abc import Mapping +from tethys_portal.optional_dependencies import optional_import, has_module -has_conda = False -try: - from conda.cli.python_api import run_command as conda_run, Commands - - has_conda = True -except ModuleNotFoundError: - write_warning("Conda not found. Some functionality will not be available.") +# optional imports +conda_run, Commands = optional_import( + ("run_command", "Commands"), from_module="conda.cli.python_api" +) FNULL = open(os.devnull, "w") @@ -746,7 +745,7 @@ def install_command(args): write_warning("Skipping package installation.") else: if validate_schema("conda", requirements_config): # noqa: E501 - if has_conda: + if has_module(conda_run): conda_config = requirements_config["conda"] install_packages( conda_config, update_installed=args.update_installed @@ -822,7 +821,7 @@ def install_command(args): # Run Portal Level Config if present if not skip_config: - load_apps() + setup_django() if args.force_services: run_services(app_name, args) else: diff --git a/tethys_cli/list_command.py b/tethys_cli/list_command.py index 42f6cf872..61adf0909 100644 --- a/tethys_cli/list_command.py +++ b/tethys_cli/list_command.py @@ -1,5 +1,5 @@ from tethys_apps.utilities import get_installed_tethys_items, SingletonHarvester -from tethys_cli.cli_helpers import load_apps +from tethys_cli.cli_helpers import setup_django from tethys_cli.cli_colors import write_info, write_msg @@ -20,7 +20,7 @@ def list_command(args): """ List installed apps. """ - load_apps() + setup_django() installed_apps = get_installed_tethys_items(apps=True) installed_extensions = get_installed_tethys_items(extensions=True) diff --git a/tethys_cli/scheduler_commands.py b/tethys_cli/scheduler_commands.py index 016e2cdce..e005a8aab 100644 --- a/tethys_cli/scheduler_commands.py +++ b/tethys_cli/scheduler_commands.py @@ -1,5 +1,5 @@ from .cli_colors import FG_RED, FG_GREEN, FG_YELLOW, BOLD, pretty_output -from tethys_cli.cli_helpers import load_apps +from tethys_cli.cli_helpers import setup_django from django.core.exceptions import ObjectDoesNotExist @@ -141,7 +141,7 @@ def add_scheduler_parser(subparsers): def schedulers_remove_command(args): - load_apps() + setup_django() from tethys_compute.models import Scheduler scheduler = None @@ -181,7 +181,7 @@ def schedulers_remove_command(args): def condor_scheduler_create_command(args): - load_apps() + setup_django() from tethys_compute.models.condor.condor_scheduler import CondorScheduler name = args.name @@ -221,7 +221,7 @@ def condor_scheduler_create_command(args): def dask_scheduler_create_command(args): - load_apps() + setup_django() from tethys_compute.models.dask.dask_scheduler import DaskScheduler name = args.name @@ -256,7 +256,7 @@ def dask_scheduler_create_command(args): def schedulers_list_command(args): - load_apps() + setup_django() schedule_type = args.type.lower() if schedule_type == "condor": from tethys_compute.models.condor.condor_scheduler import CondorScheduler diff --git a/tethys_cli/services_commands.py b/tethys_cli/services_commands.py index 402c44fb3..a009b6140 100644 --- a/tethys_cli/services_commands.py +++ b/tethys_cli/services_commands.py @@ -3,7 +3,7 @@ from django.forms.models import model_to_dict from .cli_colors import BOLD, pretty_output, FG_RED, FG_GREEN -from .cli_helpers import add_geoserver_rest_to_endpoint, load_apps +from .cli_helpers import add_geoserver_rest_to_endpoint, setup_django SERVICES_CREATE = "create" SERVICES_CREATE_PERSISTENT = "persistent" @@ -235,7 +235,7 @@ def services_create_persistent_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ - load_apps() + setup_django() from tethys_services.models import PersistentStoreService name = None @@ -283,7 +283,7 @@ def services_create_spatial_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ - load_apps() + setup_django() from tethys_services.models import SpatialDatasetService name = None @@ -419,7 +419,7 @@ def services_create_wps_command(args): """ Interact with Tethys Services (WPS) to create them and/or link them to existing apps """ - load_apps() + setup_django() from tethys_services.models import WebProcessingService as currentService name = None @@ -464,7 +464,7 @@ def services_create_wps_command(args): def remove_service(serviceType, args): - load_apps() + setup_django() from tethys_services.models import ( SpatialDatasetService, DatasetService, @@ -554,7 +554,7 @@ def services_list_command(args): """ Interact with Tethys Services (Spatial/Persistent Stores) to create them and/or link them to existing apps """ - load_apps() + setup_django() from tethys_services.models import ( SpatialDatasetService, PersistentStoreService, diff --git a/tethys_cli/site_commands.py b/tethys_cli/site_commands.py index 8650b59c2..794aa665a 100644 --- a/tethys_cli/site_commands.py +++ b/tethys_cli/site_commands.py @@ -4,7 +4,7 @@ from django.utils import timezone -from tethys_cli.cli_helpers import load_apps +from tethys_cli.cli_helpers import setup_django from tethys_cli.cli_colors import write_msg, write_warning from tethys_apps.utilities import get_tethys_home_dir @@ -294,7 +294,7 @@ def add_site_parser(subparsers): def gen_site_content(args): - load_apps() + setup_django() if args.restore_defaults: restore_site_setting_defaults() diff --git a/tethys_compute/migrations/0001_initial_40.py b/tethys_compute/migrations/0001_initial_40.py deleted file mode 100644 index d73bbf403..000000000 --- a/tethys_compute/migrations/0001_initial_40.py +++ /dev/null @@ -1,417 +0,0 @@ -# Generated by Django 3.2.12 on 2022-08-12 16:42 - -from django.conf import settings -import django.contrib.postgres.fields -from django.db import migrations, models -import django.db.models.deletion -import tethys_compute.models.dask.dask_field - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="CondorPyJob", - fields=[ - ("condorpyjob_id", models.AutoField(primary_key=True, serialize=False)), - ("_attributes", models.JSONField(blank=True, default=dict, null=True)), - ("_num_jobs", models.IntegerField(default=1)), - ( - "_remote_input_files", - django.contrib.postgres.fields.ArrayField( - base_field=models.CharField( - blank=True, max_length=1024, null=True - ), - default=list, - size=None, - ), - ), - ], - ), - migrations.CreateModel( - name="CondorPyWorkflow", - fields=[ - ( - "condorpyworkflow_id", - models.AutoField(primary_key=True, serialize=False), - ), - ("_max_jobs", models.JSONField(blank=True, default=dict, null=True)), - ("_config", models.CharField(blank=True, max_length=1024, null=True)), - ], - ), - migrations.CreateModel( - name="CondorWorkflowNode", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=1024)), - ( - "pre_script", - models.CharField(blank=True, max_length=1024, null=True), - ), - ( - "pre_script_args", - models.CharField(blank=True, max_length=1024, null=True), - ), - ( - "post_script", - models.CharField(blank=True, max_length=1024, null=True), - ), - ( - "post_script_args", - models.CharField(blank=True, max_length=1024, null=True), - ), - ("variables", models.JSONField(blank=True, default=dict, null=True)), - ("priority", models.IntegerField(blank=True, null=True)), - ("category", models.CharField(blank=True, max_length=128, null=True)), - ("retry", models.PositiveSmallIntegerField(blank=True, null=True)), - ("retry_unless_exit_value", models.IntegerField(blank=True, null=True)), - ("pre_skip", models.IntegerField(blank=True, null=True)), - ("abort_dag_on", models.IntegerField(blank=True, null=True)), - ( - "abort_dag_on_return_value", - models.IntegerField(blank=True, null=True), - ), - ("dir", models.CharField(blank=True, max_length=1024, null=True)), - ("noop", models.BooleanField(default=False)), - ("done", models.BooleanField(default=False)), - ( - "parent_nodes", - models.ManyToManyField( - related_name="children_nodes", - to="tethys_compute.CondorWorkflowNode", - ), - ), - ( - "workflow", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="node_set", - to="tethys_compute.condorpyworkflow", - ), - ), - ], - ), - migrations.CreateModel( - name="Scheduler", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=1024)), - ("host", models.CharField(max_length=1024)), - ], - options={ - "verbose_name": "Scheduler", - "verbose_name_plural": "Schedulers", - }, - ), - migrations.CreateModel( - name="TethysJob", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=1024)), - ( - "description", - models.CharField(blank=True, default="", max_length=2048), - ), - ("label", models.CharField(max_length=1024)), - ("creation_time", models.DateTimeField(auto_now_add=True)), - ("execute_time", models.DateTimeField(blank=True, null=True)), - ("start_time", models.DateTimeField(blank=True, null=True)), - ("completion_time", models.DateTimeField(blank=True, null=True)), - ("workspace", models.CharField(default="", max_length=1024)), - ( - "extended_properties", - models.JSONField(blank=True, default=dict, null=True), - ), - ( - "_process_results_function", - models.CharField(blank=True, max_length=1024, null=True), - ), - ( - "_status", - models.CharField( - choices=[ - ("PEN", "Pending"), - ("SUB", "Submitted"), - ("RUN", "Running"), - ("VAR", "Various"), - ("PAS", "Paused"), - ("COM", "Complete"), - ("ERR", "Error"), - ("ABT", "Aborted"), - ("VCP", "Various-Complete"), - ("RES", "Results-Ready"), - ("OTH", "Other"), - ], - default="PEN", - max_length=3, - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "groups", - models.ManyToManyField( - blank=True, - related_name="tethys_jobs", - to="auth.Group", - verbose_name="groups", - ), - ), - ( - "status_message", - models.CharField(blank=True, max_length=2048, null=True), - ), - ], - options={ - "verbose_name": "Job", - }, - ), - migrations.CreateModel( - name="BasicJob", - fields=[ - ( - "tethysjob_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_compute.tethysjob", - ), - ), - ], - bases=("tethys_compute.tethysjob",), - ), - migrations.CreateModel( - name="CondorBase", - fields=[ - ( - "tethysjob_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_compute.tethysjob", - ), - ), - ("cluster_id", models.IntegerField(blank=True, default=0)), - ("remote_id", models.CharField(blank=True, max_length=32, null=True)), - ], - bases=("tethys_compute.tethysjob",), - ), - migrations.CreateModel( - name="CondorScheduler", - fields=[ - ( - "scheduler_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_compute.scheduler", - ), - ), - ("username", models.CharField(blank=True, max_length=1024, null=True)), - ("password", models.CharField(blank=True, max_length=1024, null=True)), - ( - "private_key_path", - models.CharField(blank=True, max_length=1024, null=True), - ), - ( - "private_key_pass", - models.CharField(blank=True, max_length=1024, null=True), - ), - ("port", models.IntegerField(blank=True, default=22, null=True)), - ], - options={ - "verbose_name": "HTCondor Scheduler", - "verbose_name_plural": "HTCondor Schedulers", - }, - bases=("tethys_compute.scheduler",), - ), - migrations.CreateModel( - name="CondorWorkflowJobNode", - fields=[ - ( - "condorpyjob_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - to="tethys_compute.condorpyjob", - ), - ), - ( - "condorworkflownode_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_compute.condorworkflownode", - ), - ), - ], - bases=("tethys_compute.condorworkflownode", "tethys_compute.condorpyjob"), - ), - migrations.CreateModel( - name="DaskScheduler", - fields=[ - ( - "scheduler_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_compute.scheduler", - ), - ), - ("timeout", models.IntegerField(blank=True, default=0)), - ("heartbeat_interval", models.IntegerField(blank=True, default=0)), - ("dashboard", models.CharField(blank=True, max_length=255, null=True)), - ], - options={ - "verbose_name": "Dask Scheduler", - "verbose_name_plural": "Dask Schedulers", - }, - bases=("tethys_compute.scheduler",), - ), - migrations.CreateModel( - name="CondorJob", - fields=[ - ( - "condorpyjob_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - to="tethys_compute.condorpyjob", - ), - ), - ( - "condorbase_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_compute.condorbase", - ), - ), - ], - bases=("tethys_compute.condorbase", "tethys_compute.condorpyjob"), - ), - migrations.CreateModel( - name="CondorWorkflow", - fields=[ - ( - "condorpyworkflow_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - to="tethys_compute.condorpyworkflow", - ), - ), - ( - "condorbase_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_compute.condorbase", - ), - ), - ], - bases=("tethys_compute.condorbase", "tethys_compute.condorpyworkflow"), - ), - migrations.CreateModel( - name="DaskJob", - fields=[ - ( - "tethysjob_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="tethys_compute.tethysjob", - ), - ), - ("key", models.CharField(max_length=1024, null=True)), - ("forget", models.BooleanField(default=False)), - ( - "result", - tethys_compute.models.dask.dask_field.DaskSerializedField( - blank=True, null=True - ), - ), - ( - "scheduler", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="tethys_compute.daskscheduler", - ), - ), - ], - bases=("tethys_compute.tethysjob",), - ), - migrations.AddField( - model_name="condorbase", - name="scheduler", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="tethys_compute.condorscheduler", - ), - ), - ] diff --git a/tethys_compute/migrations/0001_initial_41.py b/tethys_compute/migrations/0001_initial_41.py index 504a74116..82c101a4d 100644 --- a/tethys_compute/migrations/0001_initial_41.py +++ b/tethys_compute/migrations/0001_initial_41.py @@ -7,11 +7,6 @@ class Migration(migrations.Migration): - replaces = [ - ("tethys_compute", "0001_initial_40"), - ("tethys_compute", "0002_alter_condorpyjob__remote_input_files"), - ] - initial = True dependencies = [ diff --git a/tethys_compute/migrations/0002_alter_condorpyjob__remote_input_files.py b/tethys_compute/migrations/0002_alter_condorpyjob__remote_input_files.py deleted file mode 100644 index 3b77f7162..000000000 --- a/tethys_compute/migrations/0002_alter_condorpyjob__remote_input_files.py +++ /dev/null @@ -1,49 +0,0 @@ -# Generated by Django 3.2.16 on 2022-12-02 18:07 - -import json -from django.db import migrations, models - -remote_input_files = {} - - -def save_remote_files_as_json(apps, schema_editor): - CondorPyJob = apps.get_model("tethys_compute", "CondorPyJob") - db_alias = schema_editor.connection.alias - for job in CondorPyJob.objects.using(db_alias).all(): - remote_input_files[job.condorpyjob_id] = json.dumps(job._remote_input_files) - - -def load_saved_remote_files(apps, schema_editor): - CondorPyJob = apps.get_model("tethys_compute", "CondorPyJob") - db_alias = schema_editor.connection.alias - for job_id, files in remote_input_files.items(): - job = CondorPyJob.objects.using(db_alias).get(condorpyjob_id=job_id) - job._remote_input_files = files - job.save() - - -def save_remote_files_as_list(apps, schema_editor): - CondorPyJob = apps.get_model("tethys_compute", "CondorPyJob") - db_alias = schema_editor.connection.alias - for job in CondorPyJob.objects.using(db_alias).all(): - remote_input_files[job.id] = json.loads(job._remote_input_files) - - -class Migration(migrations.Migration): - dependencies = [ - ("tethys_compute", "0001_initial_40"), - ] - - operations = [ - migrations.RunPython(save_remote_files_as_json, load_saved_remote_files), - migrations.RemoveField( - model_name="condorpyjob", - name="_remote_input_files", - ), - migrations.AddField( - model_name="condorpyjob", - name="_remote_input_files", - field=models.JSONField(blank=True, default=list, null=True), - ), - migrations.RunPython(load_saved_remote_files, save_remote_files_as_list), - ] diff --git a/tethys_compute/models/condor/condor_py_job.py b/tethys_compute/models/condor/condor_py_job.py index eb5e134e2..6baf2e2e3 100644 --- a/tethys_compute/models/condor/condor_py_job.py +++ b/tethys_compute/models/condor/condor_py_job.py @@ -6,11 +6,15 @@ * Copyright: (c) Aquaveo 2018 ******************************************************************************** """ +from tethys_portal.optional_dependencies import optional_import import os -from condorpy import Templates, Job from django.db import models +# optional imports +Templates = optional_import("Templates", from_module="condorpy") +Job = optional_import("Job", from_module="condorpy") + class CondorPyJob(models.Model): """ diff --git a/tethys_compute/models/condor/condor_py_workflow.py b/tethys_compute/models/condor/condor_py_workflow.py index 202461137..4cd4769c2 100644 --- a/tethys_compute/models/condor/condor_py_workflow.py +++ b/tethys_compute/models/condor/condor_py_workflow.py @@ -6,9 +6,13 @@ * Copyright: (c) Aquaveo 2018 ******************************************************************************** """ -from condorpy import Workflow +from tethys_portal.optional_dependencies import optional_import + from django.db import models +# optional imports +Workflow = optional_import("Workflow", from_module="condorpy") + class CondorPyWorkflow(models.Model): """ diff --git a/tethys_compute/models/condor/condor_workflow_node.py b/tethys_compute/models/condor/condor_workflow_node.py index da19c9060..fef7c190c 100644 --- a/tethys_compute/models/condor/condor_workflow_node.py +++ b/tethys_compute/models/condor/condor_workflow_node.py @@ -8,11 +8,14 @@ """ from abc import abstractmethod -from condorpy import Node from django.db import models from model_utils.managers import InheritanceManager from tethys_compute.models.condor.condor_py_workflow import CondorPyWorkflow +from tethys_portal.optional_dependencies import optional_import + +# optional imports +Node = optional_import("Node", from_module="condorpy") class CondorWorkflowNode(models.Model): diff --git a/tethys_compute/models/dask/dask_field.py b/tethys_compute/models/dask/dask_field.py index 34f65278c..89cdc243c 100644 --- a/tethys_compute/models/dask/dask_field.py +++ b/tethys_compute/models/dask/dask_field.py @@ -1,7 +1,12 @@ from ast import literal_eval as make_tuple from django.db import models from django.core.exceptions import ValidationError -from distributed.protocol.serialize import serialize, deserialize +from tethys_portal.optional_dependencies import optional_import + +# optional imports +serialize, deserialize = optional_import( + ("serialize", "deserialize"), from_module="distributed.protocol.serialize" +) class DaskSerializedField(models.Field): diff --git a/tethys_compute/models/dask/dask_job.py b/tethys_compute/models/dask/dask_job.py index 847be8db8..4f0be22a9 100644 --- a/tethys_compute/models/dask/dask_job.py +++ b/tethys_compute/models/dask/dask_job.py @@ -8,15 +8,21 @@ """ import logging import datetime +import json from django.utils import timezone from django.db import models -from dask.delayed import Delayed -from dask.distributed import Client, Future, fire_and_forget + from tethys_compute.models.tethys_job import TethysJob from tethys_compute.models.dask.dask_scheduler import DaskScheduler from tethys_compute.models.dask.dask_field import DaskSerializedField -import json +from tethys_portal.optional_dependencies import optional_import + +# optional imports +Delayed = optional_import("Delayed", from_module="dask.delayed") +Client = optional_import("Client", from_module="dask.distributed") +Future = optional_import("Future", from_module="dask.distributed") +fire_and_forget = optional_import("fire_and_forget", from_module="dask.distributed") log = logging.getLogger("tethys." + __name__) client_fire_forget = None diff --git a/tethys_compute/models/dask/dask_scheduler.py b/tethys_compute/models/dask/dask_scheduler.py index 9a0295e9d..ea751b6b5 100644 --- a/tethys_compute/models/dask/dask_scheduler.py +++ b/tethys_compute/models/dask/dask_scheduler.py @@ -10,7 +10,10 @@ from django.db import models from tethys_compute.models.scheduler import Scheduler from tethys_compute.models.dask.dask_job_exception import DaskJobException -from dask.distributed import Client +from tethys_portal.optional_dependencies import optional_import + +# optional imports +Client = optional_import("client", from_module="dask.distributed") log = logging.getLogger("tethys." + __name__) diff --git a/tethys_compute/models/tethys_job.py b/tethys_compute/models/tethys_job.py index 9793f8208..20676c75e 100644 --- a/tethys_compute/models/tethys_job.py +++ b/tethys_compute/models/tethys_job.py @@ -53,8 +53,12 @@ class Meta: PRE_RUNNING_STATUSES = DISPLAY_STATUSES[:2] RUNNING_STATUSES = DISPLAY_STATUSES[2:4] ACTIVE_STATUSES = DISPLAY_STATUSES[1:5] + NON_TERMINAL_STATUSES = DISPLAY_STATUSES[0:5] TERMINAL_STATUSES = DISPLAY_STATUSES[5:] + NON_TERMINAL_STATUS_CODES = VALID_STATUSES[0:5] + TERMINAL_STATUS_CODES = VALID_STATUSES[5:] + OTHER_STATUS_KEY = "__other_status__" name = models.CharField(max_length=1024) @@ -103,21 +107,31 @@ def last_status_update(self): return self._last_status_update @property - def status(self): + def cached_status(self): """ - The current status of the job. + The cached status of the job (i.e. the status from the last time it was updated). - Returns: A string of the display name for the current job status. + Returns: A string of the display name for the cached job status. - It may be set as an attribute in which case ``update_status`` is called. """ - self.update_status() field = self._meta.get_field("_status") status = self._get_FIELD_display(field) if self._status == "OTH": status = self.extended_properties.get(self.OTHER_STATUS_KEY, status) return status + @property + def status(self): + """ + The current status of the job. ``update_status`` is called to ensure status is current. + + Returns: A string of the display name for the current job status. + + It may be set as an attribute in which case ``update_status`` is called. + """ + self.update_status() + return self.cached_status + @status.setter def status(self, value): self.update_status(status=value) @@ -160,7 +174,7 @@ def update_status(self, status=None, *args, **kwargs): """ old_status = self._status - + update_needed = old_status in self.NON_TERMINAL_STATUS_CODES # Set status from status given if status: if status not in self.VALID_STATUSES: @@ -175,13 +189,13 @@ def update_status(self, status=None, *args, **kwargs): self.save() # Update status if status not given and still pending/running - elif old_status in ["PEN", "SUB", "RUN", "VAR"] and self.is_time_to_update(): + elif update_needed and self.is_time_to_update(): self._update_status(*args, **kwargs) self._last_status_update = timezone.now() # Post-process status after update if old status was pending/running - if old_status in ["PEN", "SUB", "RUN", "VAR"]: - if self._status == "RUN" and (old_status == "PEN" or old_status == "SUB"): + if update_needed: + if self._status == "RUN" and (old_status in ("PEN", "SUB")): self.start_time = timezone.now() if self._status in ["COM", "VCP", "RES"]: self.process_results() diff --git a/tethys_compute/views/dask_dashboard.py b/tethys_compute/views/dask_dashboard.py index 50217f3e9..59e033148 100644 --- a/tethys_compute/views/dask_dashboard.py +++ b/tethys_compute/views/dask_dashboard.py @@ -1,6 +1,9 @@ from django.shortcuts import render, reverse -from bokeh.embed import server_document from tethys_compute.models.dask.dask_scheduler import DaskScheduler +from tethys_portal.optional_dependencies import optional_import + +# optional imports +server_document = optional_import("server_document", from_module="bokeh.embed") def dask_dashboard(request, dask_scheduler_id, page="status"): diff --git a/tethys_config/context_processors.py b/tethys_config/context_processors.py index 7a1745664..5b34caff8 100644 --- a/tethys_config/context_processors.py +++ b/tethys_config/context_processors.py @@ -8,6 +8,7 @@ ******************************************************************************** """ import datetime as dt +from tethys_portal.optional_dependencies import optional_import, has_module def tethys_global_settings_context(request): @@ -15,7 +16,11 @@ def tethys_global_settings_context(request): Add the current Tethys app metadata to the template context. """ from .models import Setting - from termsandconditions.models import TermsAndConditions + + # optional imports + TermsAndConditions = optional_import( + "TermsAndConditions", from_module="termsandconditions.models" + ) # Get settings site_globals = Setting.as_dict() @@ -43,7 +48,8 @@ def tethys_global_settings_context(request): site_globals["secondary_text_hover_color"] = "#aaaaaa" # Get terms and conditions - site_globals.update({"documents": TermsAndConditions.get_active_terms_list()}) + if has_module(TermsAndConditions): + site_globals.update({"documents": TermsAndConditions.get_active_terms_list()}) context = { "site_globals": site_globals, diff --git a/tethys_gizmos/gizmo_options/bokeh_view.py b/tethys_gizmos/gizmo_options/bokeh_view.py index 19a5749a3..79e8e8c41 100644 --- a/tethys_gizmos/gizmo_options/bokeh_view.py +++ b/tethys_gizmos/gizmo_options/bokeh_view.py @@ -1,13 +1,17 @@ # coding=utf-8 -from bokeh.embed import components -from bokeh.resources import Resources -from bokeh.settings import settings as bk_settings - from django.conf import settings from django.utils.functional import classproperty from .base import TethysGizmoOptions +from tethys_portal.optional_dependencies import optional_import + +# optional imports +bokeh = optional_import("bokeh") +components = optional_import("components", from_module="bokeh.embed") +Resources = optional_import("Resources", from_module="bokeh.resources") +bk_settings = optional_import("settings", from_module="bokeh.settings") + __all__ = ["BokehView"] @@ -175,7 +179,6 @@ class BokehView(TethysGizmoOptions): select.line('date', 'close', source=source) select.ygrid.grid_line_color = None select.add_tools(range_tool) - select.toolbar.active_multi = range_tool time_series_plot = BokehView(column(time_series_fig, select)) @@ -251,6 +254,8 @@ def bk_resources(cls): # configure bokeh resources default = "server" if settings.STATICFILES_USE_NPM else "cdn" mode = bk_settings.resources(default=default) + if mode == "inline" and int(bokeh.__version__.split(".")[0]) > 2: + mode = "server" kwargs = {"mode": mode} if mode == "server": kwargs["root_url"] = "/" diff --git a/tethys_gizmos/gizmo_options/jobs_table.py b/tethys_gizmos/gizmo_options/jobs_table.py index 12bdd4666..cdaba095e 100644 --- a/tethys_gizmos/gizmo_options/jobs_table.py +++ b/tethys_gizmos/gizmo_options/jobs_table.py @@ -25,7 +25,7 @@ __all__ = ["JobsTable", "CustomJobAction"] -JobsTableRow = namedtuple("JobsTableRow", ["columns", "actions"]) +JobsTableRow = namedtuple("JobsTableRow", ["columns", "job_status", "actions"]) def add_static_method(cls): @@ -183,6 +183,8 @@ class JobsTable(TethysGizmoOptions): classes(str): Additional classes to add to the primary HTML element (e.g. "example-class another-class"). refresh_interval(int): The refresh interval for the runtime and status fields in milliseconds. Default is 5000. show_detailed_status(bool): Show status of each node in CondorWorkflow jobs when True. Defaults to False. + sort(bool|callable): Whether to sort the list of jobs in the table. If True, jobs are sorted by creation time from oldest (top of the table) to newest. If a callable is passed then it is used as the key to sort the jobs. Default is True. + reverse_sort(bool): Whether to reverse the sorting order. If ``sort`` is False then this argument has no effect. Default is False. Controller Example @@ -292,6 +294,8 @@ def __init__( actions=None, enable_data_table=False, data_table_options=None, + sort=True, + reverse_sort=False, ): """ Constructor @@ -302,6 +306,9 @@ def __init__( super().__init__(attributes=attributes, classes=classes) self.jobs = jobs + if sort: + key = sort if callable(sort) else lambda j: j.creation_time + self.jobs.sort(key=key, reverse=reverse_sort) self.rows = None self.column_fields = None self.column_names = None @@ -428,7 +435,7 @@ def __init__( if isinstance(action, CustomJobAction): self.actions[action.label] = action.properties - self.set_rows_and_columns(jobs, column_fields) + self.set_rows_and_columns(self.jobs, column_fields) # Compute column count self.num_cols = len(column_fields) @@ -476,7 +483,7 @@ def set_rows_and_columns(self, jobs, column_fields): field, ) - for job in sorted(jobs): + for job in jobs: row_values = self.get_row( job, self.column_fields, @@ -493,12 +500,15 @@ def get_row(job, job_attributes, actions=None, delay_loading_status=False): job (TethysJob): An instance of a subclass of TethysJob job_attributes (list): a list of attribute names corresponding to the fields in the jobs table actions (dict): a dictionary of custom actions - delay_loading_status (bool): whether to delay loading the status + delay_loading_status (bool): whether to delay loading the status. + Note that ``cached_status`` will be used and only non-terminal statuses will be updated on load. Returns: A list of field values for one row. """ + from tethys_compute.models import TethysJob + job_actions = actions or {} row_values = list() extended_properties = job.extended_properties @@ -518,14 +528,16 @@ def get_row(job, job_attributes, actions=None, delay_loading_status=False): row_values.append(value) - job_status = None if delay_loading_status else job.status + job_status = job.cached_status + if job_status not in TethysJob.TERMINAL_STATUSES: + job_status = None if delay_loading_status else job.status for action, properties in job_actions.items(): properties["enabled"] = getattr( job, CustomJobAction.get_enabled_callback_name(action), lambda js: True )(job_status) - return JobsTableRow(row_values, actions=job_actions) + return JobsTableRow(row_values, job_status=job_status, actions=job_actions) @staticmethod def get_gizmo_css(): diff --git a/tethys_gizmos/gizmo_options/plotly_view.py b/tethys_gizmos/gizmo_options/plotly_view.py index 6d2863ae5..9e3fb77af 100644 --- a/tethys_gizmos/gizmo_options/plotly_view.py +++ b/tethys_gizmos/gizmo_options/plotly_view.py @@ -1,7 +1,9 @@ # coding=utf-8 -import plotly.offline as opy - from .base import TethysGizmoOptions +from tethys_portal.optional_dependencies import optional_import + +# optional imports +opy = optional_import("plotly.offline") __all__ = ["PlotlyView"] diff --git a/tethys_gizmos/templates/tethys_gizmos/gizmos/job_row.html b/tethys_gizmos/templates/tethys_gizmos/gizmos/job_row.html index ec0702509..79b128ca1 100644 --- a/tethys_gizmos/templates/tethys_gizmos/gizmos/job_row.html +++ b/tethys_gizmos/templates/tethys_gizmos/gizmos/job_row.html @@ -7,7 +7,7 @@ {% endfor %} {% if show_status %} - {% if delay_loading_status %} + {% if delay_loading_status and not job_status %}
diff --git a/tethys_gizmos/templates/tethys_gizmos/gizmos/jobs_table.html b/tethys_gizmos/templates/tethys_gizmos/gizmos/jobs_table.html index 6a8c070d5..88dc1339d 100644 --- a/tethys_gizmos/templates/tethys_gizmos/gizmos/jobs_table.html +++ b/tethys_gizmos/templates/tethys_gizmos/gizmos/jobs_table.html @@ -37,7 +37,7 @@ {% endif %} - {% include "tethys_gizmos/gizmos/job_row.html" %} + {% include "tethys_gizmos/gizmos/job_row.html" with job_status=row.job_status %} {% if job.type == 'CondorWorkflow' and show_detailed_status %} diff --git a/tethys_gizmos/templatetags/tethys_gizmos.py b/tethys_gizmos/templatetags/tethys_gizmos.py index f35c35fa8..06eebd732 100644 --- a/tethys_gizmos/templatetags/tethys_gizmos.py +++ b/tethys_gizmos/templatetags/tethys_gizmos.py @@ -19,12 +19,15 @@ from django.templatetags.static import static from django.core.serializers.json import DjangoJSONEncoder -import plotly # noqa: F401 -from plotly.offline.offline import get_plotlyjs from tethys_apps.harvester import SingletonHarvester from ..gizmo_options.base import TethysGizmoOptions import tethys_sdk.gizmos +from tethys_portal.optional_dependencies import optional_import + +# optional imports +plotly = optional_import("plotly") +get_plotlyjs = optional_import("get_plotlyjs", from_module="plotly.offline.offline") GIZMO_NAME_PROPERTY = "gizmo_name" GIZMO_NAME_MAP = {} diff --git a/tethys_gizmos/views/gizmos/jobs_table.py b/tethys_gizmos/views/gizmos/jobs_table.py index 6842e9d36..a8c1dcda0 100644 --- a/tethys_gizmos/views/gizmos/jobs_table.py +++ b/tethys_gizmos/views/gizmos/jobs_table.py @@ -5,8 +5,11 @@ from django.template.loader import render_to_string from tethys_compute.models import TethysJob, CondorWorkflow, DaskJob, DaskScheduler from tethys_gizmos.gizmo_options.jobs_table import JobsTable -from bokeh.embed import server_document from tethys_sdk.gizmos import SelectInput +from tethys_portal.optional_dependencies import optional_import + +# optional imports +server_document = optional_import("server_document", from_module="bokeh.embed") log = logging.getLogger("tethys.tethys_gizmos.views.jobs_table") @@ -353,7 +356,7 @@ def _parse_value(val): def reconstruct_post_dict(request): data = {key: _parse_value(val) for key, val in request.POST.items()} # parse out dictionaries from POST items - parts = [re.split("[\[\]]+", k)[:-1] for k in data.keys() if "[" in k] # noqa: W605 + parts = [re.split("[][]+", k)[:-1] for k in data.keys() if "[" in k] # noqa: W605 for p in parts: name = p[0] keys = p[1:] diff --git a/tethys_layouts/views/map_layout.py b/tethys_layouts/views/map_layout.py index 7fb312dda..56fb7c4ee 100644 --- a/tethys_layouts/views/map_layout.py +++ b/tethys_layouts/views/map_layout.py @@ -6,6 +6,7 @@ * Copyright: (c) Aquaveo 2021 ******************************************************************************** """ +from tethys_portal.optional_dependencies import optional_import from abc import ABCMeta import collections from io import BytesIO @@ -20,7 +21,6 @@ from django.http import HttpResponse, JsonResponse from django.shortcuts import render from django.utils.functional import classproperty -import shapefile # PyShp from tethys_layouts.exceptions import TethysLayoutPropertyException from tethys_layouts.mixins.map_layout import MapLayoutMixin @@ -35,6 +35,9 @@ SelectInput, ) +# optional imports +shapefile = optional_import("shapefile") # PyShp + log = logging.getLogger(f"tethys.{__name__}") @@ -797,6 +800,20 @@ def convert_geojson_to_shapefile(self, request, *args, **kwargs): AJAX handler that converts GeoJSON data into a shapefile for download. Credit to: https://github.com/TipsForGIS/geoJSONToShpFile/blob/master/geoJ.py + .. important:: + + This method requires the `pyshp` library to be installed. Starting with Tethys 5.0 or if you are using `micro-tethys-platform`, you will need to install `django-json-widget` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge pyshp + + # pip + pip install pyshp + + **Don't Forget**: If you end up using this method in your app, add `pyshp` as a requirement to your `install.yml`. + Args: request(HttpRequest): The request. diff --git a/tethys_portal/context_processors.py b/tethys_portal/context_processors.py new file mode 100644 index 000000000..531a5ac5c --- /dev/null +++ b/tethys_portal/context_processors.py @@ -0,0 +1,25 @@ +""" +******************************************************************************** +* Name: context_processors.py +* Author: Scott Christensen +* Created On: 2023 +* Copyright: (c) Brigham Young University 2014 +* License: BSD 2-Clause +******************************************************************************** +""" + +from .optional_dependencies import has_module + + +def tethys_portal_context(request): + context = { + "has_analytical": has_module("analytical"), + "has_recaptcha": has_module("snowpenguin.django.recaptcha2"), + "has_terms": has_module("termsandconditions"), + "has_mfa": has_module("mfa"), + "has_gravatar": has_module("django_gravatar"), + "has_session_security": has_module("session_security"), + "has_oauth2_provider": has_module("oauth2_provider"), + } + + return context diff --git a/tethys_portal/middleware.py b/tethys_portal/middleware.py index 0be18ee22..7c0bf5177 100644 --- a/tethys_portal/middleware.py +++ b/tethys_portal/middleware.py @@ -11,52 +11,64 @@ from django.contrib import messages from django.core.exceptions import PermissionDenied from django.shortcuts import redirect -from rest_framework.authentication import TokenAuthentication -from rest_framework.exceptions import AuthenticationFailed -from mfa.helpers import has_mfa -from social_django.middleware import SocialAuthExceptionMiddleware -from social_core import exceptions as social_exceptions from tethys_cli.cli_colors import pretty_output, FG_WHITE from tethys_apps.utilities import get_active_app, user_can_access_app from tethys_portal.views.error import handler_404 - -class TethysSocialAuthExceptionMiddleware(SocialAuthExceptionMiddleware): - def process_exception(self, request, exception): - if hasattr(social_exceptions, exception.__class__.__name__): - if isinstance(exception, social_exceptions.AuthCanceled): - if request.user.is_anonymous: - return redirect("accounts:login") - else: - return redirect("user:settings") - elif isinstance(exception, social_exceptions.AuthAlreadyAssociated): - blurb = "The {0} account you tried to connect to has already been associated with another account." - with pretty_output(FG_WHITE) as p: - p.write(exception.backend.name) - if "google" in exception.backend.name: - blurb = blurb.format("Google") - elif "linkedin" in exception.backend.name: - blurb = blurb.format("LinkedIn") - elif "hydroshare" in exception.backend.name: - blurb = blurb.format("HydroShare") - elif "facebook" in exception.backend.name: - blurb = blurb.format("Facebook") - else: - blurb = blurb.format("social") - - messages.success(request, blurb) - - if request.user.is_anonymous: - return redirect("accounts:login") - else: - return redirect("user:settings") - elif isinstance(exception, social_exceptions.NotAllowedToDisconnect): - blurb = "Unable to disconnect from this social account." - messages.success(request, blurb) - if request.user.is_anonymous: - return redirect("accounts:login") - else: - return redirect("user:settings") +from tethys_portal.optional_dependencies import optional_import, has_module + +# optional imports +has_mfa = optional_import("has_mfa", from_module="mfa.helpers") +SocialAuthExceptionMiddleware = optional_import( + "SocialAuthExceptionMiddleware", from_module="social_django.middleware" +) +social_exceptions = optional_import("exceptions", from_module="social_core") +TokenAuthentication = optional_import( + "TokenAuthentication", from_module="rest_framework.authentication" +) +AuthenticationFailed = optional_import( + "AuthenticationFailed", from_module="rest_framework.exceptions" +) + + +if has_module(SocialAuthExceptionMiddleware): + + class TethysSocialAuthExceptionMiddleware(SocialAuthExceptionMiddleware): + def process_exception(self, request, exception): + if hasattr(social_exceptions, exception.__class__.__name__): + if isinstance(exception, social_exceptions.AuthCanceled): + if request.user.is_anonymous: + return redirect("accounts:login") + else: + return redirect("user:settings") + elif isinstance(exception, social_exceptions.AuthAlreadyAssociated): + blurb = "The {0} account you tried to connect to has already been associated with another account." + with pretty_output(FG_WHITE) as p: + p.write(exception.backend.name) + if "google" in exception.backend.name: + blurb = blurb.format("Google") + elif "linkedin" in exception.backend.name: + blurb = blurb.format("LinkedIn") + elif "hydroshare" in exception.backend.name: + blurb = blurb.format("HydroShare") + elif "facebook" in exception.backend.name: + blurb = blurb.format("Facebook") + else: + blurb = blurb.format("social") + + messages.success(request, blurb) + + if request.user.is_anonymous: + return redirect("accounts:login") + else: + return redirect("user:settings") + elif isinstance(exception, social_exceptions.NotAllowedToDisconnect): + blurb = "Unable to disconnect from this social account." + messages.success(request, blurb) + if request.user.is_anonymous: + return redirect("accounts:login") + else: + return redirect("user:settings") class TethysAppAccessMiddleware: diff --git a/tethys_portal/optional_dependencies.py b/tethys_portal/optional_dependencies.py new file mode 100644 index 000000000..48b7f318a --- /dev/null +++ b/tethys_portal/optional_dependencies.py @@ -0,0 +1,55 @@ +from importlib import import_module + + +class MissingOptionalDependency(ImportError): + pass + + +class FailedImport: + def __init__(self, module, import_error): + self.module_name = module + self.error = import_error + + def __call__(self, *args, **kwargs): + raise MissingOptionalDependency( + f'Optional dependency "{self.module_name}" was not able to be imported because of the following error:\n' + f"{self.error}." + ) + + def __getattr__(self, item): + self.__call__() + + def __getitem__(self, item): + self.__call__() + + +def _attempt_import(module, from_module, error_message): + try: + if from_module: + from_module = import_module(from_module) + return getattr(from_module, module) + return import_module(module) + except ImportError as e: + return FailedImport(module, e) + + +def optional_import(module, from_module=None, error_message=None): + if isinstance(module, (list, tuple)): + return [_attempt_import(m, from_module, error_message) for m in module] + else: + return _attempt_import(module, from_module, error_message) + + +def verify_import(module, error_message=None): + if isinstance(module, FailedImport): + error_message = error_message or ( + f'Optional dependency "{module.module_name}" was not able to be imported because of the ' + f"following error:\n{module.error}." + ) + raise MissingOptionalDependency(error_message) + + +def has_module(module, from_module=None): + if isinstance(module, str): + module = optional_import(module, from_module=from_module) + return not isinstance(module, FailedImport) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index d84a303a7..af3b78e47 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -27,13 +27,20 @@ import logging import datetime as dt from pathlib import Path +from importlib import import_module from django.contrib.messages import constants as message_constants + from tethys_apps.utilities import relative_to_tethys_home from tethys_cli.cli_colors import write_warning from tethys_cli.gen_commands import generate_secret_key +from tethys_portal.optional_dependencies import optional_import, has_module -from bokeh.settings import settings as bokeh_settings, bokehjsdir +# optional imports +bokeh_settings, bokehjsdir = optional_import( + ("settings", "bokehjsdir"), from_module="bokeh.settings" +) +bokeh_django = optional_import("bokeh_django") log = logging.getLogger(__name__) this_module = sys.modules[__name__] @@ -53,7 +60,6 @@ log.exception( "There was an error while attempting to read the settings from the portal_config.yml file." ) -bokeh_settings.resources = portal_config_settings.pop("BOKEH_RESOURCES", "inline") # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = portal_config_settings.pop("SECRET_KEY", generate_secret_key()) @@ -84,6 +90,8 @@ REGISTER_CONTROLLER = TETHYS_PORTAL_CONFIG.pop("REGISTER_CONTROLLER", None) +ADDITIONAL_URLPATTERNS = TETHYS_PORTAL_CONFIG.pop("ADDITIONAL_URLPATTERNS", []) + SESSION_CONFIG = portal_config_settings.pop("SESSION_CONFIG", {}) # Force user logout once the browser has been closed. # If changed, delete all django_session table entries from the tethys_default database to ensure updated behavior @@ -203,40 +211,49 @@ }, ) +default_installed_apps = [ + "channels", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django_bootstrap5", + "tethys_apps", + "tethys_compute", + "tethys_config", + "tethys_gizmos", + "tethys_layouts", + "tethys_sdk", + "tethys_services", + "tethys_quotas", + "guardian", +] + +for module in [ + "analytical", + "axes", + "captcha", + "corsheaders", + "django_gravatar", + "django_json_widget", + "mfa", + "oauth2_provider", + "rest_framework", + "rest_framework.authtoken", + "session_security", + "snowpenguin.django.recaptcha2", + "social_django", + "termsandconditions", +]: + if has_module(module): + default_installed_apps.append(module) + + INSTALLED_APPS = portal_config_settings.pop( "INSTALLED_APPS_OVERRIDE", - [ - "channels", - "corsheaders", - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "django_gravatar", - "django_bootstrap5", - "django_json_widget", - "termsandconditions", - "tethys_apps", - "tethys_compute", - "tethys_config", - "tethys_gizmos", - "tethys_layouts", - "tethys_sdk", - "tethys_services", - "tethys_quotas", - "social_django", - "guardian", - "session_security", - "captcha", - "snowpenguin.django.recaptcha2", - "rest_framework", - "rest_framework.authtoken", - "analytical", - "mfa", - "axes", - ], + default_installed_apps, ) INSTALLED_APPS = tuple( @@ -247,28 +264,44 @@ "MIDDLEWARE_OVERRIDE", [ "django.contrib.sessions.middleware.SessionMiddleware", - "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "tethys_portal.middleware.TethysMfaRequiredMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "tethys_portal.middleware.TethysSocialAuthExceptionMiddleware", "tethys_portal.middleware.TethysAppAccessMiddleware", - "session_security.middleware.SessionSecurityMiddleware", # TODO: Templates need to be upgraded - "axes.middleware.AxesMiddleware", ], ) +if has_module("corsheaders"): + MIDDLEWARE.insert( + MIDDLEWARE.index( + "django.middleware.common.CommonMiddleware" + ), # insert right before + "corsheaders.middleware.CorsMiddleware", + ) +if has_module("social_django"): + MIDDLEWARE.append("tethys_portal.middleware.TethysSocialAuthExceptionMiddleware") +if has_module("session_security"): + MIDDLEWARE.append( + "session_security.middleware.SessionSecurityMiddleware" + ) # TODO: Templates need to be upgraded +if has_module("axes"): + MIDDLEWARE.append("axes.middleware.AxesMiddleware") + MIDDLEWARE = tuple(MIDDLEWARE + portal_config_settings.pop("MIDDLEWARE", [])) +default_authentication_backends = [ + "django.contrib.auth.backends.ModelBackend", + "guardian.backends.ObjectPermissionBackend", +] + +if has_module("axes"): + default_authentication_backends.insert(0, "axes.backends.AxesBackend") + AUTHENTICATION_BACKENDS = portal_config_settings.pop( "AUTHENTICATION_BACKENDS_OVERRIDE", - [ - "axes.backends.AxesBackend", - "django.contrib.auth.backends.ModelBackend", - "guardian.backends.ObjectPermissionBackend", - ], + default_authentication_backends, ) AUTHENTICATION_BACKENDS = tuple( portal_config_settings.pop("AUTHENTICATION_BACKENDS", []) + AUTHENTICATION_BACKENDS @@ -316,29 +349,46 @@ USE_TZ = True +default_context_processors = [ + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.template.context_processors.request", + "django.contrib.messages.context_processors.messages", + # "social_django.context_processors.backends", + # "social_django.context_processors.login_redirect", + "tethys_config.context_processors.tethys_global_settings_context", + "tethys_apps.context_processors.tethys_apps_context", + "tethys_gizmos.context_processors.tethys_gizmos_context", + "tethys_portal.context_processors.tethys_portal_context", +] +if has_module("social_django"): + default_context_processors.extend( + [ + "social_django.context_processors.backends", + "social_django.context_processors.login_redirect", + ] + ) + # Templates + +ADDITIONAL_TEMPLATE_DIRS = [ + import_module(d).__path__[0] + for d in TETHYS_PORTAL_CONFIG.get("ADDITIONAL_TEMPLATE_DIRS", []) +] + TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [ + *ADDITIONAL_TEMPLATE_DIRS, BASE_DIR / "templates", ], "OPTIONS": { - "context_processors": [ - "django.contrib.auth.context_processors.auth", - "django.template.context_processors.debug", - "django.template.context_processors.i18n", - "django.template.context_processors.media", - "django.template.context_processors.static", - "django.template.context_processors.tz", - "django.template.context_processors.request", - "django.contrib.messages.context_processors.messages", - "social_django.context_processors.backends", - "social_django.context_processors.login_redirect", - "tethys_config.context_processors.tethys_global_settings_context", - "tethys_apps.context_processors.tethys_apps_context", - "tethys_gizmos.context_processors.tethys_gizmos_context", - ], + "context_processors": default_context_processors, "loaders": [ "django.template.loaders.filesystem.Loader", "django.template.loaders.app_directories.Loader", @@ -356,21 +406,30 @@ STATICFILES_DIRS = [ BASE_DIR / "static", - bokehjsdir(), ] +if has_module(bokehjsdir): + STATICFILES_DIRS.append(bokehjsdir()) STATICFILES_USE_NPM = TETHYS_PORTAL_CONFIG.pop("STATICFILES_USE_NPM", False) if STATICFILES_USE_NPM: STATICFILES_DIRS.append(BASE_DIR / "static" / "node_modules") +if has_module(bokeh_settings): + bokeh_settings.resources = portal_config_settings.pop( + "BOKEH_RESOURCES", "server" if STATICFILES_USE_NPM else "cdn" + ) + STATICFILES_FINDERS = portal_config_settings.pop( "STATICFILES_FINDERS_OVERRIDE", - ( + [ "django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.AppDirectoriesFinder", "tethys_apps.static_finders.TethysStaticFinder", - ), + ], ) +if has_module(bokeh_django): + STATICFILES_FINDERS.append("bokeh_django.static.BokehExtensionFinder") + STATICFILES_FINDERS = ( *STATICFILES_FINDERS, *portal_config_settings.pop("STATICFILES_FINDERS", []), @@ -409,7 +468,7 @@ GRAVATAR_DEFAULT_SIZE = "80" GRAVATAR_DEFAULT_IMAGE = "retro" GRAVATAR_DEFAULT_RATING = "g" -GRAVATAR_DFFAULT_SECURE = True +GRAVATAR_DEFAULT_SECURE = True # OAuth Settings # http://psa.matiasaguirre.net/docs/configuration/index.html @@ -486,9 +545,6 @@ CAPTCHA_CONFIG = portal_config_settings.pop("CAPTCHA_CONFIG", {}) for setting, value in CAPTCHA_CONFIG.items(): setattr(this_module, setting, value) -# If you require reCaptcha to be loaded from somewhere other than https://google.com -# (e.g. to bypass firewall restrictions), you can specify what proxy to use. -# RECAPTCHA_PROXY_HOST: https://recaptcha.net # Placeholders for the ID's required by various web-analytics services supported by Django-Analytical. # Replace False with the tracking ID as a string e.g. SERVICE_ID = 'abcd1234' diff --git a/tethys_portal/templates/analytical_body_bottom.html b/tethys_portal/templates/analytical_body_bottom.html new file mode 100644 index 000000000..f87af1a1d --- /dev/null +++ b/tethys_portal/templates/analytical_body_bottom.html @@ -0,0 +1,2 @@ +{% load analytical %} +{% analytical_body_bottom %} \ No newline at end of file diff --git a/tethys_portal/templates/analytical_body_top.html b/tethys_portal/templates/analytical_body_top.html new file mode 100644 index 000000000..c3f9b3380 --- /dev/null +++ b/tethys_portal/templates/analytical_body_top.html @@ -0,0 +1,2 @@ +{% load analytical %} +{% analytical_body_top %} \ No newline at end of file diff --git a/tethys_portal/templates/analytical_head_bottom.html b/tethys_portal/templates/analytical_head_bottom.html new file mode 100644 index 000000000..702e3a007 --- /dev/null +++ b/tethys_portal/templates/analytical_head_bottom.html @@ -0,0 +1,2 @@ +{% load analytical %} +{% analytical_head_bottom %} \ No newline at end of file diff --git a/tethys_portal/templates/analytical_head_top.html b/tethys_portal/templates/analytical_head_top.html new file mode 100644 index 000000000..07a4a3df9 --- /dev/null +++ b/tethys_portal/templates/analytical_head_top.html @@ -0,0 +1,2 @@ +{% load analytical %} +{% analytical_head_top %} \ No newline at end of file diff --git a/tethys_portal/templates/base.html b/tethys_portal/templates/base.html index 210f74760..620b65648 100644 --- a/tethys_portal/templates/base.html +++ b/tethys_portal/templates/base.html @@ -1,4 +1,4 @@ -{% load static site_settings terms_tags analytical %} +{% load static site_settings %} {# Allows custom attributes to be added to the html tag #} @@ -11,7 +11,9 @@ {# Allows custom attributes to be added to the head tag #} - {% analytical_head_top %} + {% if has_analytical %} + {% include "analytical_head_top.html" %} + {% endif %} {% comment "meta explanation" %} Add custom meta tags to the page. Call block.super to get the default tags @@ -213,21 +215,27 @@ {{ tethys.bootstrap.script_tag|safe }} {% endblock %} + {% if has_session_security %} {% block session_timeout_modal %} {% include 'session_security/all.html' %} {% endblock %} + {% endif %} {% block head %}{% endblock %} {% block extrahead %}{% endblock %} {% block blockbots %}{% endblock %} - {% analytical_head_bottom %} + {% if has_analytical %} + {% include "analytical_head_bottom.html" %} + {% endif %} {# Allows custom attributes to be added to the body tag #} - {% analytical_body_top %} + {% if has_analytical %} + {% include "analytical_body_top.html" %} + {% endif %} {% comment "page explanation" %} The page block allows you to add content to the page. @@ -298,7 +306,9 @@ {% endblock %} {% block tos_override %} - {% show_terms_if_not_agreed %} + {% if has_terms %} + {% include "terms.html" %} + {% endif %} {% endblock %} {% comment "scripts explanation" %} @@ -316,6 +326,8 @@ {% endblock %} - {% analytical_body_bottom %} + {% if has_analytical %} + {% include "analytical_body_bottom.html" %} + {% endif %} diff --git a/tethys_portal/templates/gravatar.html b/tethys_portal/templates/gravatar.html new file mode 100644 index 000000000..48fa9897b --- /dev/null +++ b/tethys_portal/templates/gravatar.html @@ -0,0 +1,7 @@ +{% load gravatar static %} + +{% if gravatar_url %} + +{% else %} +{% if user.email %}{% gravatar user.email image_size %}{% else %}{% gravatar "tethys@example.com" image_size %}{% endif %} +{% endif %} \ No newline at end of file diff --git a/tethys_portal/templates/header.html b/tethys_portal/templates/header.html index 16378e058..449fbb5bf 100644 --- a/tethys_portal/templates/header.html +++ b/tethys_portal/templates/header.html @@ -1,4 +1,4 @@ -{% load gravatar static %} +{% load static %}