From ae0098899ac47126200026f02e75abe81283c378 Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Wed, 28 Feb 2024 14:55:34 -0600
Subject: [PATCH 01/20] add tapipy; Tapis v3 OAuth token and flow

---
 .pylintrc                                     |   3 +-
 README.md                                     |   7 +-
 conf/env_files/designsafe.sample.env          |  30 +-
 designsafe/LoginTest.py                       | 117 +--
 .../designsafe/apps/accounts/register.html    |   4 +-
 designsafe/apps/accounts/views.py             |   4 +-
 designsafe/apps/api/users/utils.py            |  29 +-
 designsafe/apps/api/users/views.py            |  25 +-
 designsafe/apps/auth/README.md                |  16 +-
 designsafe/apps/auth/backends.py              | 217 +++---
 designsafe/apps/auth/context_processors.py    |  14 -
 designsafe/apps/auth/middleware.py            |  87 ++-
 designsafe/apps/auth/models.py                | 163 ++--
 designsafe/apps/auth/models_unit_test.py      |  46 ++
 .../templates/designsafe/apps/auth/login.html |   4 +-
 designsafe/apps/auth/unit_test.py             |  98 +++
 designsafe/apps/auth/urls.py                  |  65 +-
 designsafe/apps/auth/views.py                 | 299 ++++----
 designsafe/apps/auth/views_unit_test.py       |  95 +++
 designsafe/conftest.py                        |  50 +-
 designsafe/settings/common_settings.py        |  11 +-
 designsafe/settings/test_settings.py          |   4 +-
 designsafe/templates/includes/header.html     |  10 +-
 designsafe/urls.py                            |   2 -
 poetry.lock                                   | 700 ++++++++----------
 pyproject.toml                                |   2 +-
 26 files changed, 1116 insertions(+), 986 deletions(-)
 delete mode 100644 designsafe/apps/auth/context_processors.py
 create mode 100644 designsafe/apps/auth/models_unit_test.py
 create mode 100644 designsafe/apps/auth/unit_test.py
 create mode 100644 designsafe/apps/auth/views_unit_test.py

diff --git a/.pylintrc b/.pylintrc
index 8f56da028e..cbf324a0ef 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -430,7 +430,8 @@ disable=raw-checker-failed,
         useless-suppression,
         deprecated-pragma,
         use-symbolic-message-instead,
-        duplicate-code
+        duplicate-code,
+        logging-fstring-interpolation
 
 # Enable the message, report, category or checker with the given id(s). You can
 # either give multiple identifier separated by comma (,) or put this option
diff --git a/README.md b/README.md
index 392638ee7a..fb5a9a734c 100644
--- a/README.md
+++ b/README.md
@@ -225,8 +225,8 @@ $ docker-compose -f conf/docker/docker-compose-dev.all.debug.yml up
 $ npm run dev
 ```
 
-When using this compose file, your Agave Client should be configured with a `callback_url`
-of `http://$DOCKER_HOST_IP:8000/auth/agave/callback/`.
+When using this compose file, your Tapis Client should be configured with a `callback_url`
+of `http://$DOCKER_HOST_IP:8000/auth/tapis/callback/`.
 
 For developing some services, e.g. Box.com integration, https support is required. To
 enable an Nginx http proxy run using the [`docker-compose-http.yml`](docker-compose-http.yml)
@@ -238,9 +238,6 @@ $ docker-compose -f docker-compose-http.yml build
 $ docker-compose -f docker-compose-http.yml up
 ```
 
-When using this compose file, your Agave Client should be configured with a `callback_url`
-of `https://$DOCKER_HOST_IP/auth/agave/callback/`.
-
 ### Agave filesystem setup
 1. Delete all of the old metadata objects using this command:
 
diff --git a/conf/env_files/designsafe.sample.env b/conf/env_files/designsafe.sample.env
index 8e02f3b6f2..7b08b33f24 100644
--- a/conf/env_files/designsafe.sample.env
+++ b/conf/env_files/designsafe.sample.env
@@ -57,13 +57,13 @@ OPBEAT_SECRET_TOKEN=
 #
 # To configure Agave Authentication for DesignSafe-CI Portal, you need to
 # generate a set of API client keys. The client MUST be configured with a `callbackUrl`
-# which should be the URL path to the `designsafe.apps.auth.views.agave_oauth_callback`
-# view. In production, this would be `https://www.designsafe-ci.org/auth/agave/callback/`.
+# which should be the URL path to the `designsafe.apps.auth.views.tapis_oauth_callback`
+# view. In production, this would be `https://www.designsafe-ci.org/auth/tapis/callback/`.
 # If you are following the "First time setup" from the README, this would be
-# `http://$DOCKER_HOST_IP:8000/auth/agave/callback/`, where `$DOCKER_HOST_IP` is either
+# `http://$DOCKER_HOST_IP:8000/auth/tapis/callback/`, where `$DOCKER_HOST_IP` is either
 # `localhost` or the result of the `docker-machine ip machine-name` command.
 #
-# See https://agave.readthedocs.io/en/latest/agave/guides/clients/introduction.html
+# See https://tapis.readthedocs.io/en/latest/technical/authentication.html#creating-clients
 # for more information
 #
 AGAVE_TENANT_ID=designsafe
@@ -81,6 +81,28 @@ AGAVE_JWT_HEADER=
 AGAVE_JWT_USER_CLAIM_FIELD=
 AGAVE_JWT_SERVICE_ACCOUNT=
 
+########################
+# TAPIS v3 SETTINGS
+# NOTE: ONLY USED FOR TAPIS V3 DEVELOPMENT.
+# YOU CAN IGNORE THIS FOR TAPIS V2 DEVELOPMENT.
+########################
+
+# Admin account
+PORTAL_ADMIN_USERNAME=
+
+# Tapis Tenant.
+TAPIS_TENANT_BASEURL=
+
+# Tapis Client Configuration
+TAPIS_CLIENT_ID=
+TAPIS_CLIENT_KEY=
+
+# Long-live portal admin access token
+TAPIS_ADMIN_JWT=
+
+# Key service token for registering public keys with cloud.corral
+KEY_SERVICE_TOKEN=
+
 ###
 # Box.com Integration
 #
diff --git a/designsafe/LoginTest.py b/designsafe/LoginTest.py
index 05f648a313..49f76b52af 100644
--- a/designsafe/LoginTest.py
+++ b/designsafe/LoginTest.py
@@ -1,98 +1,27 @@
-#python manage.py test designsafe.LoginTest --settings=designsafe.settings.test_settings
-from django.test import TestCase, RequestFactory
-from django.contrib.auth import get_user_model
-from django.urls import reverse
+# python manage.py test designsafe.LoginTest --settings=designsafe.settings.test_settings
 import mock
-from designsafe.apps.auth.models import AgaveOAuthToken
-from designsafe.apps.auth.tasks import check_or_create_agave_home_dir
-from designsafe.apps.auth.views import agave_oauth_callback
+from django.test import TestCase
+from designsafe.apps.auth.views import tapis_oauth_callback
 
 
 class LoginTestClass(TestCase):
-  def setUp (self):
-    User = get_user_model()
-
-    user_with_agave = User.objects.create_user('test_with_agave', 'test@test.com', 'test')
-    token = AgaveOAuthToken(
-        token_type="bearer",
-        scope="default",
-        access_token="1234abcd",
-        refresh_token="123123123",
-        expires_in=14400,
-        created=1523633447)
-    token.user = user_with_agave
-    token.save()
-    self.rf = RequestFactory()
-
-    user_without_agave = User.objects.create_user('test_without_agave', 'test2@test.com', 'test')
-    token = AgaveOAuthToken(
-        token_type="bearer",
-        scope="default",
-        access_token="5555abcd",
-        refresh_token="5555555",
-        expires_in=14400,
-        created=1523633447)
-    token.user = user_without_agave
-    token.save()
-    
-  
-  def tearDown(self):
-    return
-    
-  """ @mock.patch('designsafe.apps.auth.models.AgaveOAuthToken.client')
-  @mock.patch('agavepy.agave.Agave')
-  def test_has_agave_file_listing(self, agave_client, agave):
-    #agaveclient.return_value.files.list.return_value = [] // whatever the listing should look like
-    #request.post.return_value = {} // some object that looks like a requests response
-    
-    self.client.login(username='test_with_agave', password='test')
-
-    agave_client.files.list.return_value = {
-        "name": "test",
-        "system": "designsafe.storage.default",
-        "trail": [{"path": "/", "name": "/", "system": "designsafe.storage.default"}, 
-                  {"path": "/test", "name": "test", "system": "designsafe.storage.default"}],
-        "path": "test",
-        "type": "dir",
-        "children": [],
-        "permissions": "READ"
-      } 
-
-    resp = self.client.get('/api/agave/files/listing/agave/designsafe.storage.default/test', follow=True)
-
-    self.assertEqual(resp.status_code, 200)
-    self.assertJSONEqual(resp.content, agave_client.files.list.return_value, msg='Agave homedir listing has unexpected values') """
-
-  @mock.patch('designsafe.apps.auth.models.AgaveOAuthToken.client')
-  @mock.patch('agavepy.agave.Agave')
-  def test_no_agave_file_listing(self, agave_client, agave):
-    self.client.login(username='test_without_agave', password='test')
-    session = self.client.session
-    session['auth_state'] = 'test'
-    session.save()
-    
-    request_without_agave = self.client.post("/auth/agave/callback/?state=test&code=test", follow = True)
-    print('Initial Query Status: ' + str(request_without_agave.status_code))
-    """ request_without_agave.get.return_value = {
-      'state': 'test',
-      'session': {'auth_state': 'test'},
-      'code': 'test'
-      } """
-    
-    """ agave_client.files.list.return_value = {
-        "status": "error", 
-        "message": "File/folder does not exist", 
-        "version": "test"
-        } """
-        
-    resp = agave_oauth_callback(request_without_agave)
-    print('Oauth Callback Status: ' + str(resp))
-    self.assertEqual(resp.status_code, 200)
-
-  """ def test_user_without_agave_homedir_gets_redirected(self, mock_client, mock_Agave):
-
-    pass """
-    
-  """ def test_agave_callbak(self):
-
-      resp = self.client.post("/auth/agave/callback?code=code&state=test", data=data) """
+    def setUp(self):
+        pass
+
+    def tearDown(self):
+        pass
+
+    @mock.patch("designsafe.apps.auth.models.TapisOAuthToken.client")
+    @mock.patch("tapipy.tapis.Tapis")
+    def test_no_tapis_file_listing(self, tapis_client, tapis):
+        self.client.login(username="test_without_tapis", password="test")
+        session = self.client.session
+        session["auth_state"] = "test"
+        session.save()
+
+        request_without_tapis = self.client.post(
+            "/auth/tapis/callback/?state=test&code=test", follow=True
+        )
+        resp = tapis_oauth_callback(request_without_tapis)
+        print("Oauth Callback Status: " + str(resp))
+        self.assertEqual(resp.status_code, 200)
diff --git a/designsafe/apps/accounts/templates/designsafe/apps/accounts/register.html b/designsafe/apps/accounts/templates/designsafe/apps/accounts/register.html
index 9a73fb665a..f00c56c014 100644
--- a/designsafe/apps/accounts/templates/designsafe/apps/accounts/register.html
+++ b/designsafe/apps/accounts/templates/designsafe/apps/accounts/register.html
@@ -19,7 +19,7 @@ <h1 class="headline headline-research" style="margin-bottom:40px;">Register an A
           If you already have a TACC account, log in with your TACC username and password to access DesignSafe.
         </p>
         <p class="text-center">
-          <a href="{% url 'designsafe_auth:agave_oauth' %}"><button class="btn btn-default"
+          <a href="{% url 'designsafe_auth:tapis_oauth' %}"><button class="btn btn-default"
               style="width:100%; font-weight: bold;">Log
               in</button></a>
         </p>
@@ -61,4 +61,4 @@ <h1 class="headline headline-research" style="margin-bottom:40px;">Register an A
 </div>
 {% endblock %}
 
-{% block footer %}{% include 'includes/footer.html' %}{% endblock footer %}
\ No newline at end of file
+{% block footer %}{% include 'includes/footer.html' %}{% endblock footer %}
diff --git a/designsafe/apps/accounts/views.py b/designsafe/apps/accounts/views.py
index 7a6671deb6..a624448010 100644
--- a/designsafe/apps/accounts/views.py
+++ b/designsafe/apps/accounts/views.py
@@ -278,7 +278,7 @@ def register(request):
             if not captcha_json.get("success", False):
                 messages.error(request, "Please complete the reCAPTCHA before submitting your account request.")
                 return render(request,'designsafe/apps/accounts/register.html', context)
-            
+
             # Once captcha is verified, send request to TRAM.
             tram_headers = {"tram-services-key": settings.TRAM_SERVICES_KEY}
             tram_body = {"project_id": settings.TRAM_PROJECT_ID,
@@ -290,7 +290,7 @@ def register(request):
             tram_resp.raise_for_status()
             logger.info("Received response from TRAM: %s", tram_resp.json())
             messages.success(request, "Your request has been received. Please check your email for a project invitation.")
-                
+
         except requests.HTTPError as exc:
             logger.debug(exc)
             messages.error(request, "An unknown error occurred. Please try again later.")
diff --git a/designsafe/apps/api/users/utils.py b/designsafe/apps/api/users/utils.py
index 0923178d1c..0eb576a8e3 100644
--- a/designsafe/apps/api/users/utils.py
+++ b/designsafe/apps/api/users/utils.py
@@ -1,10 +1,34 @@
+import logging
+from pytas.http import TASClient
 from django.db.models import Q
+from django.conf import settings
 
-import logging
-import json
 
 logger = logging.getLogger(__name__)
 
+
+def get_tas_client():
+    """Return a TAS Client with pytas"""
+    return TASClient(
+        baseURL=settings.TAS_URL,
+        credentials={
+            'username': settings.TAS_CLIENT_KEY,
+            'password': settings.TAS_CLIENT_SECRET
+        }
+    )
+
+
+def get_user_data(username):
+    """Returns user contact information
+
+    : returns: user_data
+    : rtype: dict
+    """
+    tas_client = get_tas_client()
+    user_data = tas_client.get_user(username=username)
+    return user_data
+
+
 def list_to_model_queries(q_comps):
     query = None
     if len(q_comps) > 2:
@@ -17,6 +41,7 @@ def list_to_model_queries(q_comps):
         query |= Q(last_name__icontains = q_comps[1])
     return query
 
+
 def q_to_model_queries(q):
     if not q:
         return None
diff --git a/designsafe/apps/api/users/views.py b/designsafe/apps/api/users/views.py
index 792f66181b..0ffa7a833c 100644
--- a/designsafe/apps/api/users/views.py
+++ b/designsafe/apps/api/users/views.py
@@ -17,9 +17,9 @@
 
 def check_public_availability(username):
     es_client = new_es_client()
-    query = Q({'multi_match': {'fields': ['project.value.teamMembers', 
-                                          'project.value.coPis', 
-                                          'project.value.pi'], 
+    query = Q({'multi_match': {'fields': ['project.value.teamMembers',
+                                          'project.value.coPis',
+                                          'project.value.pi'],
                                'query': username}})
     res = IndexedPublication.search(using=es_client).filter(query).execute()
     return res.hits.total.value > 0
@@ -50,14 +50,13 @@ def get(self, request):
                 "last_name": u.last_name,
                 "email": u.email,
                 "oauth": {
-                    "access_token": u.agave_oauth.access_token,
-                    "expires_in": u.agave_oauth.expires_in,
-                    "scope": u.agave_oauth.scope,
-                }
+                    "expires_in": u.tapis_oauth.expires_in,
+                },
+                "isStaff": u.is_staff,
             }
 
             return JsonResponse(out)
-        return HttpResponse('Unauthorized', status=401)
+        return JsonResponse({'message': 'Unauthorized'}, status=401)
 
 
 class SearchView(View):
@@ -120,7 +119,7 @@ def get(self, request):
             return JsonResponse(resp, safe=False)
         else:
             return HttpResponseNotFound()
-        
+
 
 class ProjectUserView(BaseApiView):
     """View for handling search for project users"""
@@ -128,17 +127,17 @@ def get(self, request: HttpRequest):
         """retrieve a user by their exact TACC username."""
         if not request.user.is_authenticated:
             raise ApiException(message="Authentication required", status=401)
-        
+
         username_query = request.GET.get("q")
         user_match = get_user_model().objects.filter(username__iexact=username_query)
         user_resp = [{"fname": u.first_name,
                      "lname": u.last_name,
                      "inst": u.profile.institution,
-                     "email": u.email, 
+                     "email": u.email,
                      "username": u.username} for u in user_match]
-        
+
         return JsonResponse({"result": user_resp})
-        
+
 
 
 class PublicView(View):
diff --git a/designsafe/apps/auth/README.md b/designsafe/apps/auth/README.md
index ed0e415cbb..de247e4a59 100644
--- a/designsafe/apps/auth/README.md
+++ b/designsafe/apps/auth/README.md
@@ -9,27 +9,27 @@ support the various authentication requirements of DesignSafe.
 
 Authenticate directly against TACC's TAS Identity Store. This backend is used when
 authenticating directly to the Django Admin app. An OAuth token will not be obtained when
-using this backend, so using Agave/DesignSafe API features will not work.
+using this backend, so using Tapis/DesignSafe API features will not work.
 
-### AgaveOAuthBackend
+### TapisOAuthBackend
 
-Authenticate using Agave OAuth Webflow (authorization code). See the [Agave Authentication Docs][1]
+Authenticate using Tapis OAuth Webflow (authorization code). See the [Tapis Authentication Docs][1]
 for complete documentation.
 
-#### AgaveTokenRefreshMiddleware
+#### TapisTokenRefreshMiddleware
 
-OAuth tokens obtained from Agave are valid for a limited time, usually one hour (3600s).
+OAuth tokens obtained from Tapis are valid for a limited time, usually ten days (14400s).
 The app can automatically refresh the OAuth token as necessary. Add the refresh middleware
 in `settings.py`. The middleware *must* appear after
 `django.contrib.sessions.middleware.SessionMiddleware`:
 
 ```
-MIDDLEWARE_CLASSES = (
+MIDDLEWARE = (
   ...,
   'django.contrib.sessions.middleware.SessionMiddleware',
-  designsafe.apps.auth.middleware.AgaveTokenRefreshMiddleware,
+  designsafe.apps.auth.middleware.TapisTokenRefreshMiddleware,
   ...,
 )
 ```
 
-[1]: http://agaveapi.co/documentation/authorization-guide/#authorization_code_flow
\ No newline at end of file
+[1]: https://tapis.readthedocs.io/en/latest/technical/authentication.html#authorization-code-grant-generating-tokens-for-users
diff --git a/designsafe/apps/auth/backends.py b/designsafe/apps/auth/backends.py
index f822bf649a..8e933c8966 100644
--- a/designsafe/apps/auth/backends.py
+++ b/designsafe/apps/auth/backends.py
@@ -1,51 +1,57 @@
+"""Auth backends"""
+
+import logging
+import re
 from django.conf import settings
 from django.contrib.auth import get_user_model
+from django.contrib.auth.backends import ModelBackend
+from tapipy.tapis import Tapis
+from tapipy.errors import BaseTapyException
+from designsafe.apps.accounts.models import DesignSafeProfile, NotificationPreferences
+from designsafe.apps.users.utils import get_user_data
+from desingsafe.apps.auth.models.TapisOAuthToken import get_masked_token
 from django.contrib.auth.signals import user_logged_out
 from django.contrib import messages
-from django.contrib.auth.backends import ModelBackend
 from django.core.exceptions import ValidationError
 from django.dispatch import receiver
-from designsafe.apps.accounts.models import DesignSafeProfile, NotificationPreferences
-from designsafe.apps.api.agave import get_service_account_client
 from designsafe.apps.auth.tasks import update_institution_from_tas
 from pytas.http import TASClient
-import logging
-import re
-import requests
-from requests.auth import HTTPBasicAuth
+
+logger = logging.getLogger(__name__)
 
 
 @receiver(user_logged_out)
 def on_user_logged_out(sender, request, user, **kwargs):
-    backend = request.session.get('_auth_user_backend', None)
-    tas_backend_name = '%s.%s' % (TASBackend.__module__,
-                                  TASBackend.__name__)
-    agave_backend_name = '%s.%s' % (AgaveOAuthBackend.__module__,
-                                    AgaveOAuthBackend.__name__)
+    "Signal processor for user_logged_out"
+    backend = request.session.get("_auth_user_backend", None)
+    tas_backend_name = "%s.%s" % (TASBackend.__module__, TASBackend.__name__)
+    tapis_backend_name = "%s.%s" % (
+        TapisOAuthBackend.__module__,
+        TapisOAuthBackend.__name__,
+    )
 
     if backend == tas_backend_name:
-        login_provider = 'TACC'
-    elif backend == agave_backend_name:
-        login_provider = 'TACC'
-    else:
-        login_provider = 'your authentication provider'
-
-    logger = logging.getLogger(__name__)
-    logger.debug("attempting call to revoke agave token function: %s", user.agave_oauth.token)
-    a = AgaveOAuthBackend()
-    AgaveOAuthBackend.revoke(a,user.agave_oauth)
-
-    logout_message = '<h4>You are Logged Out!</h4>' \
-                     'You are now logged out of DesignSafe! However, you may still ' \
-                     'be logged in at %s. To ensure security, you should close your ' \
-                     'browser to end all authenticated sessions.' % login_provider
+        login_provider = "TACC"
+    elif backend == tapis_backend_name:
+        login_provider = "TACC"
+
+    logger.info(
+        "Revoking tapis token: %s", get_masked_token(user.tapis_oauth.access_token)
+    )
+    backend = TapisOAuthBackend()
+    TapisOAuthBackend.revoke(backend, user.tapis_oauth.access_token)
+
+    logout_message = (
+        "<h4>You are Logged Out!</h4>"
+        "You are now logged out of DesignSafe! However, you may still "
+        f"be logged in at {login_provider}. To ensure security, you should close your "
+        "browser to end all authenticated sessions."
+    )
     messages.warning(request, logout_message)
 
 
 class TASBackend(ModelBackend):
 
-    logger = logging.getLogger(__name__)
-
     def __init__(self):
         self.tas = TASClient()
 
@@ -56,20 +62,31 @@ def authenticate(self, request, username=None, password=None, **kwargs):
         if username is not None and password is not None:
             tas_user = None
             if request is not None:
-                self.logger.info('Attempting login via TAS for user "%s" from IP "%s"' % (username, request.META.get('REMOTE_ADDR')))
+                self.logger.info(
+                    'Attempting login via TAS for user "%s" from IP "%s"'
+                    % (username, request.META.get("REMOTE_ADDR"))
+                )
             else:
-                self.logger.info('Attempting login via TAS for user "%s" from IP "%s"' % (username, 'unknown'))
+                self.logger.info(
+                    'Attempting login via TAS for user "%s" from IP "%s"'
+                    % (username, "unknown")
+                )
             try:
                 # Check if this user is valid on the mail server
                 if self.tas.authenticate(username, password):
                     tas_user = self.tas.get_user(username=username)
                     self.logger.info('Login successful for user "%s"' % username)
                 else:
-                    raise ValidationError('Authentication Error', 'Your username or password is incorrect.')
+                    raise ValidationError(
+                        "Authentication Error",
+                        "Your username or password is incorrect.",
+                    )
             except Exception as e:
                 self.logger.warning(e.args)
-                if re.search(r'PendingEmailConfirmation', e.args[1]):
-                    raise ValidationError('Please confirm your email address before logging in.')
+                if re.search(r"PendingEmailConfirmation", e.args[1]):
+                    raise ValidationError(
+                        "Please confirm your email address before logging in."
+                    )
                 else:
                     raise ValidationError(e.args[1])
 
@@ -78,27 +95,30 @@ def authenticate(self, request, username=None, password=None, **kwargs):
                 try:
                     # Check if the user exists in Django's local database
                     user = UserModel.objects.get(username=username)
-                    user.first_name = tas_user['firstName']
-                    user.last_name = tas_user['lastName']
-                    user.email = tas_user['email']
+                    user.first_name = tas_user["firstName"]
+                    user.last_name = tas_user["lastName"]
+                    user.email = tas_user["email"]
                     user.save()
 
                 except UserModel.DoesNotExist:
                     # Create a user in Django's local database
-                    self.logger.info('Creating local user record for "%s" from TAS Profile' % username)
+                    self.logger.info(
+                        'Creating local user record for "%s" from TAS Profile'
+                        % username
+                    )
                     user = UserModel.objects.create_user(
                         username=username,
-                        first_name=tas_user['firstName'],
-                        last_name=tas_user['lastName'],
-                        email=tas_user['email']
-                        )
+                        first_name=tas_user["firstName"],
+                        last_name=tas_user["lastName"],
+                        email=tas_user["email"],
+                    )
                 try:
                     profile = DesignSafeProfile.objects.get(user=user)
-                    profile.institution = tas_user.get('institution', None)
+                    profile.institution = tas_user.get("institution", None)
                     profile.save()
                 except DesignSafeProfile.DoesNotExist:
                     profile = DesignSafeProfile(user=user)
-                    profile.institution = tas_user.get('institution', None)
+                    profile.institution = tas_user.get("institution", None)
                     profile.save()
 
                 try:
@@ -110,72 +130,67 @@ def authenticate(self, request, username=None, password=None, **kwargs):
         return user
 
 
-# class CILogonBackend(ModelBackend):
-
-#     def authenticate(self, **kwargs):
-#         return None
-
-
-class AgaveOAuthBackend(ModelBackend):
-
-    logger = logging.getLogger(__name__)
+class TapisOAuthBackend(ModelBackend):
 
     def authenticate(self, *args, **kwargs):
         user = None
 
-        if 'backend' in kwargs and kwargs['backend'] == 'agave':
-            token = kwargs['token']
-            base_url = getattr(settings, 'AGAVE_TENANT_BASEURL')
+        if "backend" in kwargs and kwargs["backend"] == "tapis":
+            token = kwargs["token"]
 
-            self.logger.info('Attempting login via Agave with token "%s"' %
-                             token[:8].ljust(len(token), '-'))
+            logger.info(
+                'Attempting login via Tapis with token "%s"' % get_masked_token(token)
+            )
+            client = Tapis(base_url=settings.TAPIS_TENANT_BASEURL, access_token=token)
 
-            # TODO make this into an AgavePy call
-            response = requests.get('%s/profiles/v2/me' % base_url,
-                                    headers={'Authorization': 'Bearer %s' % token})
-            if response.status_code >= 200 and response.status_code <= 299:
-                json_result = response.json()
-                agave_user = json_result['result']
-                username = agave_user['username']
-                UserModel = get_user_model()
-                try:
-                    user = UserModel.objects.get(username=username)
-                    user.first_name = agave_user['first_name']
-                    user.last_name = agave_user['last_name']
-                    user.email = agave_user['email']
-                    user.save()
-                except UserModel.DoesNotExist:
-                    self.logger.info('Creating local user record for "%s" '
-                                     'from Agave Profile' % username)
-                    user = UserModel.objects.create_user(
-                        username=username,
-                        first_name=agave_user['first_name'],
-                        last_name=agave_user['last_name'],
-                        email=agave_user['email']
-                        )
+            try:
+                tapis_user_info = client.authenticator.get_userinfo()
+            except BaseTapyException as e:
+                logger.info("Tapis Authentication failed: %s", e.message)
+                return None
 
-                try:
-                    profile = DesignSafeProfile.objects.get(user=user)
-                except DesignSafeProfile.DoesNotExist:
-                    profile = DesignSafeProfile(user=user)
-                    profile.save()
-                update_institution_from_tas.apply_async(args=[username], queue='api')
+            username = tapis_user_info.username
 
-                try:
-                    prefs = NotificationPreferences.objects.get(user=user)
-                except NotificationPreferences.DoesNotExist:
-                    prefs = NotificationPreferences(user=user)
-                    prefs.save()
+            try:
+                user_data = get_user_data(username=username)
+                defaults = {
+                    "first_name": user_data["firstName"],
+                    "last_name": user_data["lastName"],
+                    "email": user_data["email"],
+                }
+            except Exception:
+                logger.exception(
+                    "Error retrieving TAS user profile data for user: %s", username
+                )
+                defaults = {
+                    "first_name": tapis_user_info.given_name,
+                    "last_name": tapis_user_info.last_name,
+                    "email": tapis_user_info.email,
+                }
+
+            user, created = get_user_model().objects.update_or_create(
+                username=username, defaults=defaults
+            )
+
+            if created:
+                logger.info(
+                    'Created local user record for "%s" from TAS Profile', username
+                )
+
+            DesignSafeProfile.objects.get_or_create(user=user)
+            NotificationPreferences.objects.get_or_create(user=user)
+
+            update_institution_from_tas.apply_async(args=[username], queue="api")
+
+            logger.info('Login successful for user "%s"', username)
 
-                self.logger.error('Login successful for user "%s"' % username)
-            else:
-                self.logger.error('Agave Authentication failed: %s' % response.text)
         return user
 
-    def revoke(self, user):
-        base_url = getattr(settings, 'AGAVE_TENANT_BASEURL')
-        self.logger.info("attempting to revoke agave token %s" % user.masked_token)
-        response = requests.post('{base_url}/revoke'.format(base_url = base_url),
-            auth=HTTPBasicAuth(settings.AGAVE_CLIENT_KEY, settings.AGAVE_CLIENT_SECRET),
-            data={'token': user.access_token})
+    def revoke(self, token):
+        self.logger.info(
+            "Attempting to revoke Tapis token %s" % get_masked_token(token)
+        )
+
+        client = Tapis(base_url=settings.TAPIS_TENANT_BASEURL, access_token=token)
+        response = client.authenticator.revoke_token(token=token)
         self.logger.info("revoke response is %s" % response)
diff --git a/designsafe/apps/auth/context_processors.py b/designsafe/apps/auth/context_processors.py
deleted file mode 100644
index b4ec8e65a0..0000000000
--- a/designsafe/apps/auth/context_processors.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from designsafe.apps.auth.models import AgaveOAuthToken
-
-
-def auth(request):
-    try:
-        ag_token = request.user.agave_oauth
-        context = {
-            'agave_ready': ag_token is not None
-        }
-    except (AttributeError, AgaveOAuthToken.DoesNotExist):
-        context = {
-            'agave_ready': False
-        }
-    return context
diff --git a/designsafe/apps/auth/middleware.py b/designsafe/apps/auth/middleware.py
index acfc8174a5..877657d0e1 100644
--- a/designsafe/apps/auth/middleware.py
+++ b/designsafe/apps/auth/middleware.py
@@ -1,36 +1,67 @@
+"""
+Auth middleware
+"""
+
+import logging
 from django.contrib.auth import logout
 from django.core.exceptions import ObjectDoesNotExist
-from requests.exceptions import RequestException, HTTPError
-import logging
-from django.utils.deprecation import MiddlewareMixin
+from django.db import transaction
+from django.urls import reverse
+from tapipy.errors import BaseTapyException
+from designsafe.apps.auth.models import TapisOAuthToken
 
 logger = logging.getLogger(__name__)
 
 
-class AgaveTokenRefreshMiddleware(MiddlewareMixin):
+class TapisTokenRefreshMiddleware:
+    """Refresh Middleware for a User's Tapis OAuth Token"""
 
-    def process_request(self, request):
-        if request.path != '/logout/' and request.user.is_authenticated:
-            try:
-                agave_oauth = request.user.agave_oauth
-                if agave_oauth.expired:
-                    try:
-                        agave_oauth.client.token.refresh()
-                    except HTTPError:
-                        logger.exception('Agave Token refresh failed; Forcing logout',
-                                         extra={'user': request.user.username})
-                        logout(request)
-            except ObjectDoesNotExist:
-                logger.warn('Authenticated user missing Agave API Token',
-                            extra={'user': request.user.username})
-                logout(request)
-            except RequestException:
-                logger.exception('Agave Token refresh failed. Forcing logout',
-                                 extra={'user': request.user.username})
-                logout(request)
-
-    def process_response(self, request, response):
-        if hasattr(request, 'user'):
-            if request.user.is_authenticated:
-                response['Authorization'] = 'Bearer ' + request.user.agave_oauth.access_token
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+        if request.path != reverse("logout") and request.user.is_authenticated:
+            self.process_request(request)
+
+        response = self.get_response(request)
         return response
+
+    def process_request(self, request):
+        """Processes requests to backend and refreshes Tapis Token atomically if token is expired."""
+        try:
+            tapis_oauth = request.user.tapis_oauth
+        except ObjectDoesNotExist:
+            logger.warning(
+                "Authenticated user missing Tapis OAuth Token",
+                extra={"user": request.user.username},
+            )
+            logout(request)
+
+        if not tapis_oauth.expired:
+            return
+
+        logger.info(
+            f"Tapis OAuth token expired for user {request.user.username}. Refreshing token"
+        )
+        with transaction.atomic():
+            # Get a lock on this user's token row in db.
+            latest_token = (
+                TapisOAuthToken.objects.select_for_update()
+                .filter(user=request.user)
+                .first()
+            )
+            if latest_token.expired:
+                try:
+                    logger.info("Refreshing Tapis OAuth token")
+                    tapis_oauth.refresh_tokens()
+                except BaseTapyException:
+                    logger.exception(
+                        "Tapis Token refresh failed. Forcing logout",
+                        extra={"user": request.user.username},
+                    )
+                    logout(request)
+
+            else:
+                logger.info(
+                    "Token updated by another request. Refreshing token from DB."
+                )
diff --git a/designsafe/apps/auth/models.py b/designsafe/apps/auth/models.py
index 9f57bc9fb8..6db226f3f0 100644
--- a/designsafe/apps/auth/models.py
+++ b/designsafe/apps/auth/models.py
@@ -1,126 +1,115 @@
-from django.db import models
-from django.conf import settings
-from agavepy.agave import Agave
-from agavepy import agave
+"""Auth models
+"""
+
 import logging
-import six
 import time
-import requests
-from requests import HTTPError
-# from .signals import *
-from designsafe.libs.common.decorators import deprecated
+from django.db import models
+from django.conf import settings
+from tapipy.tapis import Tapis
 
 logger = logging.getLogger(__name__)
 
 
 TOKEN_EXPIRY_THRESHOLD = 600
 
-AGAVE_RESOURCES = agave.load_resource(getattr(settings, 'AGAVE_TENANT_BASEURL'))
 
-class AgaveOAuthToken(models.Model):
-    user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='agave_oauth', on_delete=models.CASCADE)
-    token_type = models.CharField(max_length=255)
-    scope = models.CharField(max_length=255)
-    access_token = models.CharField(max_length=255)
-    refresh_token = models.CharField(max_length=255)
+class TapisOAuthToken(models.Model):
+    """Represents an Tapis OAuth Token object.
+
+    Use this class to store login details as well as refresh a token.
+    """
+
+    user = models.OneToOneField(
+        settings.AUTH_USER_MODEL, related_name="tapis_oauth", on_delete=models.CASCADE
+    )
+    access_token = models.CharField(max_length=2048)
+    refresh_token = models.CharField(max_length=2048)
     expires_in = models.BigIntegerField()
     created = models.BigIntegerField()
 
-    @property
-    def masked_token(self):
-        return self.access_token[:8].ljust(len(self.access_token), '-')
-
     @property
     def expired(self):
-        current_time = time.time()
-        return self.created + self.expires_in - current_time - TOKEN_EXPIRY_THRESHOLD <= 0
+        """Check if token is expired
+
+        :return: True or False, depending if the token is expired.
+        :rtype: bool
+        """
+        return self.is_token_expired(self.created, self.expires_in)
 
     @property
     def created_at(self):
-        """
-        Map the agavepy.Token property to model property
+        """Map the tapipy.Token property to model property
+
         :return: The Epoch timestamp this token was created
+        :rtype: int
         """
         return self.created_at
 
     @created_at.setter
     def created_at(self, value):
-        """
-        Map the agavepy.Token property to model property
-        :param value: The Epoch timestamp this token was created
+        """Map the tapipy.Token property to model property
+
+        :param int value: The Epoch timestamp this token was created
         """
         self.created = value
 
     @property
     def token(self):
+        """Token dictionary.
+
+        :return: Full token object
+        :rtype: dict
+        """
         return {
-            'access_token': self.access_token,
-            'refresh_token': self.refresh_token,
-            'token_type': self.token_type,
-            'scope': self.scope,
-            'created': self.created,
-            'expires_in': self.expires_in
+            "access_token": self.access_token,
+            "refresh_token": self.refresh_token,
+            "created": self.created,
+            "expires_in": self.expires_in,
         }
 
     @property
     def client(self):
-        return Agave(api_server=getattr(settings, 'AGAVE_TENANT_BASEURL'),
-                     api_key=getattr(settings, 'AGAVE_CLIENT_KEY'),
-                     api_secret=getattr(settings, 'AGAVE_CLIENT_SECRET'),
-                     token=self.access_token,
-                     resources=AGAVE_RESOURCES,
-                     refresh_token=self.refresh_token,
-                     token_callback=self.update)
+        """Tapis client to limit one request to Tapis per User.
+
+        :return: Tapis client using refresh token.
+        :rtype: :class:Tapis
+        """
+        return Tapis(
+            base_url=getattr(settings, "TAPIS_TENANT_BASEURL"),
+            client_id=getattr(settings, "TAPIS_CLIENT_ID"),
+            client_key=getattr(settings, "TAPIS_CLIENT_KEY"),
+            access_token=self.access_token,
+            refresh_token=self.refresh_token,
+        )
 
     def update(self, **kwargs):
-        for k, v in six.iteritems(kwargs):
+        """Bulk update model attributes"""
+        for k, v in kwargs.items():
             setattr(self, k, v)
         self.save()
 
-    @deprecated
-    def refresh(self):
-        """
-        DEPRECATED
-        :return:
-        """
-        logger.debug('Refreshing Agave OAuth token for user=%s' % self.user.username)
-        ag = Agave(api_server=getattr(settings, 'AGAVE_TENANT_BASEURL'),
-                   api_key=getattr(settings, 'AGAVE_CLIENT_KEY'),
-                   api_secret=getattr(settings, 'AGAVE_CLIENT_SECRET'),
-                   resources=AGAVE_RESOURCES,
-                   token=self.access_token,
-                   refresh_token=self.refresh_token)
+    def refresh_tokens(self):
+        """Refresh and update Tapis OAuth Tokens"""
+        self.client.refresh_tokens()
+        self.update(
+            created=int(time.time()),
+            access_token=self.client.access_token.access_token,
+            refresh_token=self.client.refresh_token.refresh_token,
+            expires_in=self.client.access_token.expires_in().total_seconds(),
+        )
+
+    def __str__(self):
+        access_token_masked = self.access_token[-5:]
+        refresh_token_masked = self.refresh_token[-5:]
+        return f"access_token:{access_token_masked} refresh_token:{refresh_token_masked} expires_in:{self.expires_in} created:{self.created}"
+
+    @staticmethod
+    def is_token_expired(created, expires_in):
+        """Check if token is expired, with TOKEN_EXPIRY_THRESHOLD buffer."""
         current_time = time.time()
-        ag.token.refresh()
-        self.created = int(current_time)
-        self.update(**ag.token.token_info)
-        logger.debug('Agave OAuth token for user=%s refreshed: %s' % (self.user.username,
-                                                                      self.masked_token))
-
-
-class AgaveServiceStatus(object):
-    page_id = getattr(settings, 'AGAVE_STATUSIO_PAGE_ID', '53a1e022814a437c5a000781')
-    status_io_base_url = getattr(settings, 'STATUSIO_BASE_URL',
-                                 'https://api.status.io/1.0')
-    status_overall = {}
-    status = []
-    incidents = []
-    maintenance = {
-        'active': [],
-        'upcoming': [],
-    }
-
-    def __init__(self):
-        self.update()
-
-    def update(self):
-        try:
-            resp = requests.get('%s/status/%s' % (self.status_io_base_url, self.page_id))
-            data = resp.json()
-            if 'result' in data:
-                for k, v, in six.iteritems(data['result']):
-                    setattr(self, k, v)
-            else:
-                raise Exception(data)
-        except HTTPError:
-            logger.warning('Agave Service Status update failed')
+        return created + expires_in - current_time - TOKEN_EXPIRY_THRESHOLD <= 0
+
+    @staticmethod
+    def get_masked_token(token):
+        """Return a token as a masked string"""
+        return token[:8].ljust(len(token), "-")
diff --git a/designsafe/apps/auth/models_unit_test.py b/designsafe/apps/auth/models_unit_test.py
new file mode 100644
index 0000000000..cbd9d159c2
--- /dev/null
+++ b/designsafe/apps/auth/models_unit_test.py
@@ -0,0 +1,46 @@
+import pytest
+import time
+from datetime import timedelta
+from designsafe.apps.auth.models import TapisOAuthToken
+
+pytestmark = pytest.mark.django_db
+
+
+@pytest.fixture
+def authenticated_user_with_expired_token(authenticated_user):
+    authenticated_user.tapis_oauth.expires_in = 0
+    authenticated_user.tapis_oauth.save()
+    yield authenticated_user
+
+
+@pytest.fixture
+def authenticated_user_with_valid_token(authenticated_user):
+    authenticated_user.tapis_oauth.created = time.time()
+    authenticated_user.tapis_oauth.save()
+    yield authenticated_user
+
+
+@pytest.fixture()
+def tapis_client_mock(mocker):
+    mock_client = mocker.patch("designsafe.apps.auth.models.TapisOAuthToken.client")
+    mock_client.access_token.access_token = ("XYZXYZXYZ",)
+    mock_client.access_token.expires_in.return_value = timedelta(seconds=2000)
+    yield mock_client
+
+
+def test_valid_user(client, authenticated_user_with_valid_token, tapis_client_mock):
+    tapis_oauth = (
+        TapisOAuthToken.objects.filter(user=authenticated_user_with_valid_token)
+        .select_for_update()
+        .get()
+    )
+    assert not tapis_oauth.expired
+
+
+def test_expired_user(client, authenticated_user_with_expired_token, tapis_client_mock):
+    tapis_oauth = (
+        TapisOAuthToken.objects.filter(user=authenticated_user_with_expired_token)
+        .select_for_update()
+        .get()
+    )
+    assert tapis_oauth.expired
diff --git a/designsafe/apps/auth/templates/designsafe/apps/auth/login.html b/designsafe/apps/auth/templates/designsafe/apps/auth/login.html
index 6b8e292501..a10dbc2408 100644
--- a/designsafe/apps/auth/templates/designsafe/apps/auth/login.html
+++ b/designsafe/apps/auth/templates/designsafe/apps/auth/login.html
@@ -23,13 +23,13 @@ <h1 class="headline headline-community"><span class="hl-community">Log in</span>
       {% endif %}
       <div class="login-option-block">
         <p>
-          <a href="{% url 'designsafe_auth:agave_oauth' %}?{{ request.META.QUERY_STRING }}" class="btn btn-lg btn-primary img-responsive">
+          <a href="{% url 'designsafe_auth:tapis_oauth' %}?{{ request.META.QUERY_STRING }}" class="btn btn-lg btn-primary img-responsive">
             <img class="provider-logo img-responsive" style="width:100% \9;" alt="TACC" title="Log in with TACC" src="{% static 'designsafe/apps/auth/logos/tacc.svg'%}">
           </a>
         </p>
 
         <p>
-          <a href="{% url 'designsafe_auth:agave_oauth' %}?{{ request.META.QUERY_STRING }}">Log in using your TACC Account.</a>
+          <a href="{% url 'designsafe_auth:tapis_oauth' %}?{{ request.META.QUERY_STRING }}">Log in using your TACC Account.</a>
         </p>
         <a href="{% url 'designsafe_accounts:register' %}?{{ request.META.QUERY_STRING }}">Register for a TACC account</a>
         &bull;
diff --git a/designsafe/apps/auth/unit_test.py b/designsafe/apps/auth/unit_test.py
new file mode 100644
index 0000000000..ad86c7b049
--- /dev/null
+++ b/designsafe/apps/auth/unit_test.py
@@ -0,0 +1,98 @@
+from django.test import TransactionTestCase, override_settings
+from django.contrib.auth import get_user_model
+from mock import patch, MagicMock
+from portal.apps.auth.backends import TapisOAuthBackend
+from requests import Response
+from portal.apps.auth.views import launch_setup_checks
+import pytest
+
+pytestmark = pytest.mark.django_db
+
+
+def test_launch_setup_checks(mocker, regular_user, settings):
+    mocker.patch("portal.apps.auth.views.new_user_setup_check")
+    mock_execute = mocker.patch("portal.apps.auth.views.execute_setup_steps")
+    regular_user.profile.setup_complete = False
+    launch_setup_checks(regular_user)
+    mock_execute.apply_async.assert_called_with(args=["username"])
+
+
+class TestTapisOAuthBackend(TransactionTestCase):
+    def setUp(self):
+        super(TestTapisOAuthBackend, self).setUp()
+        self.backend = TapisOAuthBackend()
+        self.mock_response = MagicMock(autospec=Response)
+        self.mock_requests_patcher = patch(
+            "portal.apps.auth.backends.requests.get", return_value=self.mock_response
+        )
+        self.mock_requests = self.mock_requests_patcher.start()
+
+        self.mock_user_data_patcher = patch(
+            "portal.apps.auth.backends.get_user_data",
+            return_value={
+                "username": "testuser",
+                "firstName": "test",
+                "lastName": "user",
+                "email": "new@email.com",
+            },
+        )
+        self.mock_user_data = self.mock_user_data_patcher.start()
+
+    def tearDown(self):
+        super(TestTapisOAuthBackend, self).tearDown()
+        self.mock_requests_patcher.stop()
+        self.mock_user_data_patcher.stop()
+
+    def test_bad_backend_params(self):
+        # Test backend authenticate with no params
+        result = self.backend.authenticate()
+        self.assertIsNone(result)
+        # Test TapisOAuthBackend if params do not indicate tapis
+        result = self.backend.authenticate(backend="not_tapis")
+        self.assertIsNone(result)
+
+    def test_bad_response_status(self):
+        # Test that backend failure responses are handled
+
+        # Mock different return values for the backend response
+        self.mock_response.json.return_value = {}
+        result = self.backend.authenticate(backend="tapis", token="1234")
+        self.assertIsNone(result)
+        self.mock_response.json.return_value = {"status": "failure"}
+        result = self.backend.authenticate(backend="tapis", token="1234")
+        self.assertIsNone(result)
+
+    @override_settings(PORTAL_USER_ACCOUNT_SETUP_STEPS=[])
+    def test_new_user(self):
+        # Test that a new user is created and returned
+        self.mock_response.json.return_value = {
+            "status": "success",
+            "result": {"username": "testuser"},
+        }
+        result = self.backend.authenticate(backend="tapis", token="1234")
+        self.assertEqual(result.username, "testuser")
+
+    @override_settings(PORTAL_USER_ACCOUNT_SETUP_STEPS=[])
+    def test_update_existing_user(self):
+        # Test that an existing user's information is
+        # updated with from info from the Tapis backend response
+
+        # Create a pre-existing user with the same username
+        user = get_user_model().objects.create_user(
+            username="testuser",
+            first_name="test",
+            last_name="user",
+            email="old@email.com",
+        )
+        self.mock_response.json.return_value = {
+            "status": "success",
+            "result": {
+                "username": "testuser",
+            },
+        }
+        result = self.backend.authenticate(backend="tapis", token="1234")
+        # Result user object should be the same
+        self.assertEqual(result, user)
+        # Existing user object should be updated
+        user = get_user_model().objects.get(username="testuser")
+        self.assertEqual(user.email, "new@email.com")
diff --git a/designsafe/apps/auth/urls.py b/designsafe/apps/auth/urls.py
index 003639c78d..cc6a5ef832 100644
--- a/designsafe/apps/auth/urls.py
+++ b/designsafe/apps/auth/urls.py
@@ -1,36 +1,41 @@
-from django.urls import re_path as url
-from django.urls import reverse
-from django.utils.translation import gettext_lazy as _
+"""
+.. module:: portal.apps.auth.urls
+   :synopsis: Auth URLs
+"""
+
+from django.urls import path
 from designsafe.apps.auth import views
 
+app_name = "portal_auth"
 urlpatterns = [
-    url(r'^$', views.login_options, name='login'),
-    url(r'^logged-out/$', views.logged_out, name='logout'),
-    url(r'^agave/$', views.agave_oauth, name='agave_oauth'),
-    url(r'^agave/callback/$', views.agave_oauth_callback, name='agave_oauth_callback'),
-    url(r'^agave/session-error/$', views.agave_session_error, name='agave_session_error'),
+    path("logged-out/", views.logged_out, name="logout"),
+    path("tapis/", views.tapis_oauth, name="tapis_oauth"),
+    path("tapis/callback/", views.tapis_oauth_callback, name="tapis_oauth_callback"),
 ]
 
 
-def menu_items(**kwargs):
-    if 'type' in kwargs and kwargs['type'] == 'account':
-        return [
-            {
-                'label': _('Login'),
-                'url': reverse('login'),
-                'children': [],
-                'visible': False
-            },
-            {
-                'label': _('Login'),
-                'url': reverse('designsafe_auth:login'),
-                'children': [],
-                'visible': False
-            },
-            {
-                'label': _('Agave'),
-                'url': reverse('designsafe_auth:agave_session_error'),
-                'children': [],
-                'visible': False
-            }
-        ]
+# from django.urls import reverse
+# from django.utils.translation import gettext_lazy as _
+
+# def menu_items(**kwargs):
+#     if 'type' in kwargs and kwargs['type'] == 'account':
+#         return [
+#             {
+#                 'label': _('Login'),
+#                 'url': reverse('login'),
+#                 'children': [],
+#                 'visible': False
+#             },
+#             {
+#                 'label': _('Login'),
+#                 'url': reverse('designsafe_auth:login'),
+#                 'children': [],
+#                 'visible': False
+#             },
+#             {
+#                 'label': _('Agave'),
+#                 'url': reverse('designsafe_auth:agave_session_error'),
+#                 'children': [],
+#                 'visible': False
+#             }
+#         ]
diff --git a/designsafe/apps/auth/views.py b/designsafe/apps/auth/views.py
index 92e0813d53..5714c81de0 100644
--- a/designsafe/apps/auth/views.py
+++ b/designsafe/apps/auth/views.py
@@ -1,209 +1,174 @@
+"""
+Auth views.
+"""
+
+import logging
+import time
+import secrets
+import requests
 from django.conf import settings
 from django.contrib import messages
 from django.contrib.auth import authenticate, login
 from django.urls import reverse
-from django.core.exceptions import ObjectDoesNotExist
 from django.http import HttpResponseRedirect, HttpResponseBadRequest
 from django.shortcuts import render
-import secrets
-
-from .models import AgaveOAuthToken, AgaveServiceStatus
-from agavepy.agave import Agave
-from designsafe.apps.auth.tasks import check_or_create_agave_home_dir, new_user_alert
-import logging
-import os
-import requests
-import time
-from requests import HTTPError
-
+from .models import TapisOAuthToken
 
+# TODOV3: Onboarding
+# from tapipy.errors import BaseTapyException
+# from designsafe.apps.auth.tasks import check_or_create_agave_home_dir
+# from portal.apps.onboarding.execute import execute_setup_steps, new_user_setup_check
+# from portal.apps.search.tasks import index_allocations
 
 logger = logging.getLogger(__name__)
+METRICS = logging.getLogger(f"metrics.{__name__}")
 
 
 def logged_out(request):
-    return render(request, 'designsafe/apps/auth/logged_out.html')
-
-
-def login_options(request):
-    if request.user.is_authenticated:
-        messages.info(request, 'You are already logged in!')
-        return HttpResponseRedirect('/')
-
-    message = False
-
-    try:
-        agave_status = AgaveServiceStatus()
-        ds_oauth_svc_id = getattr(settings, 'AGAVE_DESIGNSAFE_OAUTH_STATUS_ID',
-                                  '56bb6d92a216b873280008fd')
-        designsafe_status = next((s for s in agave_status.status
-                             if s['id'] == ds_oauth_svc_id))
-        if designsafe_status and 'status_code' in designsafe_status:
-            if designsafe_status['status_code'] == 400:
-                message = {
-                    'class': 'warning',
-                    'text': 'DesignSafe API Services are experiencing a '
-                            'Partial Service Disruption. Some services '
-                            'may be unavailable.'
-                }
-            elif designsafe_status['status_code'] == 500:
-                message = {
-                    'class': 'danger',
-                    'text': 'DesignSafe API Services are experiencing a '
-                            'Service Disruption. Some services may be '
-                            'unavailable.'
-                }
-    except Exception as e:
-        logger.warn('Unable to check AgaveServiceStatus')
-        logger.warn(e)
-        agave_status = None
-        designsafe_status = None
-
-    if not message:
-        return agave_oauth(request)
-    else:
-        context = {
-            'message': message,
-            'agave_status': agave_status,
-            'designsafe_status': designsafe_status,
-        }
-        return render(request, 'designsafe/apps/auth/login.html', context)
+    """Render logged out page upon logout"""
+    return render(request, "designsafe/apps/auth/logged_out.html")
 
 
-def agave_oauth(request):
-    tenant_base_url = getattr(settings, 'AGAVE_TENANT_BASEURL')
-    client_key = getattr(settings, 'AGAVE_CLIENT_KEY')
+def _get_auth_state():
+    return secrets.token_hex(24)
 
+
+def tapis_oauth(request):
+    """First step for Tapis OAuth workflow."""
     session = request.session
-    session['auth_state'] = secrets.token_hex(24)
-    next_page = request.GET.get('next')
+    session["auth_state"] = _get_auth_state()
+    next_page = request.GET.get("next")
     if next_page:
-        session['next'] = next_page
-    # Check for HTTP_X_DJANGO_PROXY custom header
-    django_proxy = request.META.get('HTTP_X_DJANGO_PROXY', 'false') == 'true'
-    if django_proxy or request.is_secure():
-        protocol = 'https'
+        session["next"] = next_page
+
+    if request.is_secure():
+        protocol = "https"
     else:
-        protocol = 'http'
-    redirect_uri = '{}://{}{}'.format(
-        protocol,
-        request.get_host(),
-        reverse('designsafe_auth:agave_oauth_callback')
-    )
+        protocol = "http"
+
+    redirect_uri = f"{protocol}://{request.get_host()}{reverse('designsafe_auth:tapis_oauth_callback')}"
+
+    tenant_base_url = getattr(settings, "TAPIS_TENANT_BASEURL")
+    client_id = getattr(settings, "TAPIS_CLIENT_ID")
+
+    METRICS.info(f"user:{request.user.username} starting oauth redirect login")
+
+    # Authorization code request
     authorization_url = (
-        '%s/authorize?'
-        'client_id=%s&'
-        'response_type=code&'
-        'redirect_uri=%s&'
-        'state=%s' % (
-            tenant_base_url,
-            client_key,
-            redirect_uri,
-            session['auth_state'],
-        )
+        f"{tenant_base_url}/v3/oauth2/authorize?"
+        f"client_id={client_id}&"
+        f"redirect_uri={redirect_uri}&"
+        "response_type=code&"
+        f"state={session['auth_state']}"
     )
+
     return HttpResponseRedirect(authorization_url)
 
 
-def agave_oauth_callback(request):
-    """
-    http://agaveapi.co/documentation/authorization-guide/#authorization_code_flow
-    """
-    state = request.GET.get('state')
+# TODOV3: Onboarding
+# def launch_setup_checks(user):
+#     """Perform any onboarding checks or non-onboarding steps that may spawn celery tasks"""
+
+#     # Check onboarding settings
+#     # new_user_setup_check(user)
+#     # if not user.profile.setup_complete:
+#     #     logger.info("Executing onboarding setup steps for %s", user.username)
+#     #     execute_setup_steps.apply_async(args=[user.username])
+#     # else:
+#     #     logger.info(
+#     #         "Already onboarded, running non-onboarding steps (e.g. update cached "
+#     #         "allocation information) for %s",
+#     #         user.username,
+#     #     )
+#     #     index_allocations.apply_async(args=[user.username])
+
+#     # TODOV3: Onboarding -> Move home dir creation to onboarding step
+#     client = user.tapis_oauth.client
+#     try:
+#         client.files.list(
+#             systemId=settings.AGAVE_STORAGE_SYSTEM, filePath=user.username
+#         )
+#     except BaseTapyException as e:
+#         if e.response.status_code == 404:
+#             check_or_create_agave_home_dir.apply_async(
+#                 args=(user.username, settings.AGAVE_STORAGE_SYSTEM), queue="files"
+#             )
+
+#     try:
+#         client.files.list(
+#             systemId=settings.AGAVE_WORKING_SYSTEM, filePath=user.username
+#         )
+#     except BaseTapyException as e:
+#         if e.response.status_code == 404:
+#             check_or_create_agave_home_dir.apply_async(
+#                 args=(user.username, settings.AGAVE_WORKING_SYSTEM), queue="files"
+#             )
+
+
+def tapis_oauth_callback(request):
+    """Tapis OAuth callback handler."""
+    state = request.GET.get("state")
+
+    if request.session["auth_state"] != state:
+        msg = f"OAuth Authorization State mismatch: auth_state={request.session['auth_state']} does not match returned state={state}"
 
-    if request.session['auth_state'] != state:
-        msg = (
-            'OAuth Authorization State mismatch!? auth_state=%s '
-            'does not match returned state=%s' % (
-                request.session['auth_state'], state
-            )
-        )
         logger.warning(msg)
-        return HttpResponseBadRequest('Authorization State Failed')
+        return HttpResponseBadRequest("Authorization State Failed")
 
-    if 'code' in request.GET:
+    if "code" in request.GET:
         # obtain a token for the user
-        # Check for HTTP_X_DJANGO_PROXY custom header
-        request.META.get('HTTP_X_DJANGO_PROXY', 'false') == 'true'
-        django_proxy = request.META.get('HTTP_X_DJANGO_PROXY', 'false')
-        if django_proxy or request.is_secure():
-            protocol = 'https'
+        if request.is_secure():
+            protocol = "https"
         else:
-            protocol = 'http'
-        redirect_uri = '{}://{}{}'.format(
-            protocol,
-            request.get_host(),
-            reverse('designsafe_auth:agave_oauth_callback')
-        )
-        code = request.GET['code']
-        tenant_base_url = getattr(settings, 'AGAVE_TENANT_BASEURL')
-        client_key = getattr(settings, 'AGAVE_CLIENT_KEY')
-        client_sec = getattr(settings, 'AGAVE_CLIENT_SECRET')
+            protocol = "http"
+        redirect_uri = f"{protocol}://{request.get_host()}{reverse('designsafe_auth:tapis_oauth_callback')}"
+        code = request.GET["code"]
+
         body = {
-            'grant_type': 'authorization_code',
-            'code': code,
-            'redirect_uri': redirect_uri,
+            "grant_type": "authorization_code",
+            "code": code,
+            "redirect_uri": redirect_uri,
+        }
+        response = requests.post(
+            f"{settings.TAPIS_TENANT_BASEURL}/v3/oauth2/tokens",
+            data=body,
+            auth=(settings.TAPIS_CLIENT_ID, settings.TAPIS_CLIENT_KEY),
+            timeout=30,
+        )
+        response_json = response.json()
+        token_data = {
+            "created": int(time.time()),
+            "access_token": response_json["result"]["access_token"]["access_token"],
+            "refresh_token": response_json["result"]["refresh_token"]["refresh_token"],
+            "expires_in": response_json["result"]["access_token"]["expires_in"],
         }
-        # TODO update to token call in agavepy
-        response = requests.post('%s/token' % tenant_base_url,
-                                 data=body,
-                                 auth=(client_key, client_sec))
-        token_data = response.json()
-        token_data['created'] = int(time.time())
+
         # log user in
-        user = authenticate(backend='agave', token=token_data['access_token'])
+        user = authenticate(backend="tapis", token=token_data["access_token"])
 
         if user:
-            try:
-                token = user.agave_oauth
-                token.update(**token_data)
-            except ObjectDoesNotExist:
-                token = AgaveOAuthToken(**token_data)
-                token.user = user
-                new_user_alert.apply_async(args=(user.username,))
-            token.save()
+            TapisOAuthToken.objects.update_or_create(user=user, defaults={**token_data})
 
             login(request, user)
-
-            ag = Agave(api_server=settings.AGAVE_TENANT_BASEURL,
-                       token=settings.AGAVE_SUPER_TOKEN)
-            try:
-                ag.files.list(systemId=settings.AGAVE_STORAGE_SYSTEM,
-                              filePath=user.username)
-            except HTTPError as e:
-                if e.response.status_code == 404:
-                    check_or_create_agave_home_dir.apply_async(args=(user.username, settings.AGAVE_STORAGE_SYSTEM),queue='files')
-
-            try:
-                ag.files.list(systemId=settings.AGAVE_WORKING_SYSTEM,
-                              filePath=user.username)
-            except HTTPError as e:
-                if e.response.status_code == 404:
-                    check_or_create_agave_home_dir.apply_async(args=(user.username, settings.AGAVE_WORKING_SYSTEM),queue='files')
-
+            # TODOV3: Onboarding
+            # launch_setup_checks(user)
         else:
             messages.error(
                 request,
-                'Authentication failed. Please try again. If this problem '
-                'persists please submit a support ticket.'
+                "Authentication failed. Please try again. If this problem "
+                "persists please submit a support ticket.",
             )
-            return HttpResponseRedirect(reverse('designsafe_auth:login'))
+            return HttpResponseRedirect(reverse("logout"))
     else:
-        if 'error' in request.GET:
-            error = request.GET['error']
-            logger.warning('Authorization failed: %s' % error)
-        messages.error(
-            request, 'Authentication failed! Did you forget your password? '
-                     '<a href="%s">Click here</a> to reset your password.' %
-                     reverse('designsafe_accounts:password_reset'))
-        return HttpResponseRedirect(reverse('designsafe_auth:login'))
-    if 'next' in request.session:
-        next_uri = request.session.pop('next')
-        return HttpResponseRedirect(next_uri)
-    else:
-        # return HttpResponseRedirect(settings.LOGIN_REDIRECT_URL)
-        return HttpResponseRedirect(reverse('designsafe_dashboard:index'))
+        if "error" in request.GET:
+            error = request.GET["error"]
+            logger.warning("Authorization failed: %s", error)
+
+        return HttpResponseRedirect(reverse("logout"))
 
+    redirect = getattr(settings, "LOGIN_REDIRECT_URL", "/")
+    if "next" in request.session:
+        redirect += "?next=" + request.session.pop("next")
 
-def agave_session_error(request):
-    return render(request, 'designsafe/apps/auth/agave_session_error.html')
+    response = HttpResponseRedirect(redirect)
+    return response
diff --git a/designsafe/apps/auth/views_unit_test.py b/designsafe/apps/auth/views_unit_test.py
new file mode 100644
index 0000000000..c09d592dd1
--- /dev/null
+++ b/designsafe/apps/auth/views_unit_test.py
@@ -0,0 +1,95 @@
+"""DesignSafe Auth Tapis OAuth flow view tests"""
+import pytest
+from django.conf import settings
+from django.urls import reverse
+
+# TODOV3: Onboarding Tests
+# from portal.apps.auth.views import launch_setup_checks
+
+TEST_STATE = "ABCDEFG123456"
+
+pytestmark = pytest.mark.django_db
+
+
+def test_auth_tapis(client, mocker):
+    """Test auth flow redirect"""
+    mocker.patch("designsafe.apps.auth.views._get_auth_state", return_value=TEST_STATE)
+
+    response = client.get("/auth/tapis/", follow=False)
+
+    tapis_authorize = (
+        f"{settings.TAPIS_TENANT_BASEURL}/v3/oauth2/authorize"
+        f"?client_id=test&redirect_uri=http://testserver/auth/tapis/callback/&response_type=code&state={TEST_STATE}"
+    )
+
+    assert response.status_code == 302
+    assert response.url == tapis_authorize
+    assert client.session["auth_state"] == TEST_STATE
+
+
+def test_tapis_callback(client, mocker, regular_user, tapis_tokens_create_mock):
+    """Test successful Tapis callback"""
+    mock_authenticate = mocker.patch("designsafe.apps.auth.views.authenticate")
+    mock_tapis_token_post = mocker.patch("designsafe.apps.auth.views.requests.post")
+    # TODOV3: Onboarding Tests
+    # mock_launch_setup_checks = mocker.patch(
+    #     "designsafe.apps.auth.views.launch_setup_checks"
+    # )
+
+    # add auth to session
+    session = client.session
+    session["auth_state"] = TEST_STATE
+    session.save()
+
+    mock_tapis_token_post.return_value.json.return_value = tapis_tokens_create_mock
+    mock_tapis_token_post.return_value.status_code = 200
+    mock_authenticate.return_value = regular_user
+
+    response = client.get(
+        f"/auth/tapis/callback/?state={TEST_STATE}&code=83163624a0bc41c4a376e0acb16a62f9"
+    )
+    assert response.status_code == 302
+    assert response.url == settings.LOGIN_REDIRECT_URL
+    # TODOV3: Onboarding Tests
+    # assert mock_launch_setup_checks.call_count == 1
+
+
+def test_tapis_callback_no_code(client):
+    """Test Tapis callback with no auth code"""
+    # add auth to session
+    session = client.session
+    session["auth_state"] = TEST_STATE
+    session.save()
+
+    response = client.get(f"/auth/tapis/callback/?state={TEST_STATE}")
+    assert response.status_code == 302
+    assert response.url == reverse("logout")
+
+
+def test_tapis_callback_mismatched_state(client):
+    """Test Tapis callback with mismatched state"""
+    # add auth to session
+    session = client.session
+    session["auth_state"] = "TEST_STATE"
+    session.save()
+    response = client.get("/auth/tapis/callback/?state=bar")
+    assert response.status_code == 400
+
+
+# TODOV3: Onboarding Tests
+# def test_launch_setup_checks(regular_user, mocker):
+#     mock_execute_setup_steps = mocker.patch(
+#         "portal.apps.auth.views.execute_setup_steps"
+#     )
+#     launch_setup_checks(regular_user)
+#     mock_execute_setup_steps.apply_async.assert_called_with(
+#         args=[regular_user.username]
+#     )
+
+
+# TODOV3: Onboarding Tests
+# def test_launch_setup_checks_already_onboarded(regular_user, mocker):
+#     regular_user.profile.setup_complete = True
+#     mock_index_allocations = mocker.patch("portal.apps.auth.views.index_allocations")
+#     launch_setup_checks(regular_user)
+#     mock_index_allocations.apply_async.assert_called_with(args=[regular_user.username])
diff --git a/designsafe/conftest.py b/designsafe/conftest.py
index 3b327245b1..1cb2309d9d 100644
--- a/designsafe/conftest.py
+++ b/designsafe/conftest.py
@@ -1,44 +1,46 @@
+"""Base User pytest fixtures"""
+
 import pytest
-from django.conf import settings
-from designsafe.apps.auth.models import AgaveOAuthToken
+from designsafe.apps.auth.models import TapisOAuthToken
+
 
 @pytest.fixture
-def mock_agave_client(mocker):
-    yield mocker.patch('designsafe.apps.auth.models.AgaveOAuthToken.client', autospec=True)
+def mock_tapis_client(mocker):
+    """Tapis client fixture"""
+    yield mocker.patch(
+        "designsafe.apps.auth.models.TapisOAuthToken.client", autospec=True
+    )
 
 
 @pytest.fixture
-def regular_user(django_user_model, mock_agave_client):
-    django_user_model.objects.create_user(username="username",
-                                          password="password",
-                                          first_name="Firstname",
-                                          last_name="Lastname",
-                                          email="user@user.com")
-    django_user_model.objects.create_user(username="username2",
-                                          password="password2",
-                                          first_name="Firstname2",
-                                          last_name="Lastname2",
-                                          email="user@user.com2")
+def regular_user(django_user_model, mock_tapis_client):
+    """Normal User fixture"""
+    django_user_model.objects.create_user(
+        username="username",
+        password="password",
+        first_name="Firstname",
+        last_name="Lastname",
+        email="user@user.com",
+    )
     user = django_user_model.objects.get(username="username")
-    token = AgaveOAuthToken.objects.create(
+    TapisOAuthToken.objects.create(
         user=user,
-        token_type="bearer",
-        scope="default",
         access_token="1234fsf",
         refresh_token="123123123",
         expires_in=14400,
-        created=1523633447)
-    token.save()
+        created=1523633447,
+    )
 
     yield user
 
 
 @pytest.fixture
 def project_admin_user(django_user_model):
-    django_user_model.objects.create_user(username="test_prjadmin",
-                                          password="password",
-                                          first_name="Project",
-                                          last_name="Admin",
+    django_user_model.objects.create_user(
+        username="test_prjadmin",
+        password="password",
+        first_name="Project",
+        last_name="Admin",
     )
     user = django_user_model.objects.get(username="test_prjadmin")
     yield user
diff --git a/designsafe/settings/common_settings.py b/designsafe/settings/common_settings.py
index af2b46e187..ec2917098d 100644
--- a/designsafe/settings/common_settings.py
+++ b/designsafe/settings/common_settings.py
@@ -138,7 +138,7 @@
     'django.contrib.auth.middleware.AuthenticationMiddleware',
     'designsafe.apps.token_access.middleware.TokenAuthenticationMiddleware',
     'django.contrib.messages.middleware.MessageMiddleware',
-    'designsafe.apps.auth.middleware.AgaveTokenRefreshMiddleware',
+    'designsafe.apps.auth.middleware.TapisTokenRefreshMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
     'django.middleware.locale.LocaleMiddleware',
     'django.middleware.security.SecurityMiddleware',
@@ -524,6 +524,15 @@
 AGAVE_USER_STORE_ID = os.environ.get('AGAVE_USER_STORE_ID', 'TACC')
 AGAVE_USE_SANDBOX = os.environ.get('AGAVE_USE_SANDBOX', 'False').lower() == 'true'
 
+# Tapis Client Configuration
+PORTAL_ADMIN_USERNAME = os.environ.get('PORTAL_ADMIN_USERNAME')
+TAPIS_TENANT_BASEURL = os.environ.get('TAPIS_TENANT_BASEURL')
+TAPIS_CLIENT_ID = os.environ.get('TAPIS_CLIENT_ID')
+TAPIS_CLIENT_KEY = os.environ.get('TAPIS_CLIENT_KEY')
+TAPIS_ADMIN_JWT = os.environ.get('TAPIS_ADMIN_JWT')
+
+KEY_SERVICE_TOKEN = os.environ.get('KEY_SERVICE_TOKEN')
+
 DS_ADMIN_USERNAME = os.environ.get('DS_ADMIN_USERNAME')
 DS_ADMIN_PASSWORD = os.environ.get('DS_ADMIN_PASSWORD')
 
diff --git a/designsafe/settings/test_settings.py b/designsafe/settings/test_settings.py
index e88bd5e0be..496e2d8fce 100644
--- a/designsafe/settings/test_settings.py
+++ b/designsafe/settings/test_settings.py
@@ -131,7 +131,7 @@
     'django.contrib.sessions.middleware.SessionMiddleware',
     'designsafe.apps.token_access.middleware.TokenAuthenticationMiddleware',
     'django.contrib.messages.middleware.MessageMiddleware',
-    'designsafe.apps.auth.middleware.AgaveTokenRefreshMiddleware',
+    'designsafe.apps.auth.middleware.TapisTokenRefreshMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
     'django.middleware.locale.LocaleMiddleware',
     'django.middleware.security.SecurityMiddleware',
@@ -528,7 +528,7 @@
 
 # No token refreshes during testing
 MIDDLEWARE= [c for c in MIDDLEWARE if c !=
-                      'designsafe.apps.auth.middleware.AgaveTokenRefreshMiddleware']
+                      'designsafe.apps.auth.middleware.TapisTokenRefreshMiddleware']
 
 STATIC_ROOT = os.path.join(BASE_DIR, 'static')
 MEDIA_ROOT = os.path.join(BASE_DIR, '.media')
diff --git a/designsafe/templates/includes/header.html b/designsafe/templates/includes/header.html
index 4ef08ee78f..b3eef01240 100644
--- a/designsafe/templates/includes/header.html
+++ b/designsafe/templates/includes/header.html
@@ -37,15 +37,7 @@
       </li>
     </ul>
     {% if user.is_authenticated %}
-    <span style="vertical-align:middle">
-      {% if not agave_ready %}
-      &nbsp;
-      <a class="text-danger" title="API Session Not Available. Click for details."
-        href="{% url 'designsafe_auth:agave_session_error' %}"><i role="none" class="fa fa-exclamation-triangle"></i><span
-          class="sr-only">API Session Not Available. Click for details.</span></a>
-      {% endif %}
-    </span>
-   <notification-badge></notification-badge> 
+   <notification-badge></notification-badge>
     <div class="btn-group">
       <a href="{% url 'designsafe_dashboard:index' %}" class="btn btn-link-alt">Welcome, {{ user.first_name }}!</a>
       <button type="button" class="btn btn-link-alt dropdown-toggle" data-toggle="dropdown" aria-haspopup="true"
diff --git a/designsafe/urls.py b/designsafe/urls.py
index 0c7e5adb86..c4918d6639 100644
--- a/designsafe/urls.py
+++ b/designsafe/urls.py
@@ -29,7 +29,6 @@
 from django.views.generic import RedirectView, TemplateView
 from django.urls import reverse, path
 from django.http import HttpResponse, HttpResponseRedirect
-from designsafe.apps.auth.views import login_options as des_login_options
 from django.contrib.auth.views import LogoutView as des_logout
 from designsafe.views import project_version as des_version, redirect_old_nees
 from impersonate import views as impersonate_views
@@ -148,7 +147,6 @@
     # auth
     url(r'^auth/', include(('designsafe.apps.auth.urls', 'designsafe.apps.auth'), namespace='designsafe_auth')),
 
-    url(r'^login/$', des_login_options, name='login'),
     url(r'^logout/$', des_logout.as_view(), name='logout'),
 
     # help
diff --git a/poetry.lock b/poetry.lock
index b530c7ac6b..57c63b4fdf 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,38 +1,9 @@
-# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand.
-
-[[package]]
-name = "agavepy"
-version = "1.0.0a12"
-description = "SDK for TACC Tapis (formerly Agave)"
-category = "main"
-optional = false
-python-versions = "*"
-files = [
-    {file = "agavepy-1.0.0a12-py2.py3-none-any.whl", hash = "sha256:ad096612c07a05d7f16b0e0e49d75dacfbccd3681e82c1b5f92c2c1b733b4209"},
-    {file = "agavepy-1.0.0a12.tar.gz", hash = "sha256:60717e2749d1e4116a10b0aed509f691f56ebea8a1321f29f621ff1ddd4a9d1e"},
-]
-
-[package.dependencies]
-arrow = ">=0.15.5"
-attrdict = ">=2.0.0"
-cloudpickle = ">=1.3.0"
-curlify = ">=2.2.1"
-future = ">=0.18.2"
-Jinja2 = ">=2.11.1"
-petname = ">=2.6"
-py = ">=1.8.1"
-python-dateutil = ">=2.8.1"
-python-dotenv = ">=0.11.0"
-requests = ">=2.23.0"
-requests-toolbelt = ">=0.9.1"
-six = ">=1.12.0"
-websocket-client = ">=0.57.0"
+# This file is automatically @generated by Poetry 1.5.0 and should not be changed by hand.
 
 [[package]]
 name = "amqp"
 version = "5.1.1"
 description = "Low-level AMQP client for Python (fork of amqplib)."
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -47,7 +18,6 @@ vine = ">=5.0.0"
 name = "annotated-types"
 version = "0.6.0"
 description = "Reusable constraint types to use with typing.Annotated"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -59,7 +29,6 @@ files = [
 name = "appnope"
 version = "0.1.3"
 description = "Disable App Nap on macOS >= 10.9"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -67,31 +36,10 @@ files = [
     {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"},
 ]
 
-[[package]]
-name = "arrow"
-version = "1.3.0"
-description = "Better dates & times for Python"
-category = "main"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "arrow-1.3.0-py3-none-any.whl", hash = "sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80"},
-    {file = "arrow-1.3.0.tar.gz", hash = "sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85"},
-]
-
-[package.dependencies]
-python-dateutil = ">=2.7.0"
-types-python-dateutil = ">=2.8.10"
-
-[package.extras]
-doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"]
-test = ["dateparser (>=1.0.0,<2.0.0)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (>=3.0.0,<4.0.0)"]
-
 [[package]]
 name = "asgiref"
 version = "3.7.2"
 description = "ASGI specs, helper code, and adapters"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -106,7 +54,6 @@ tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"]
 name = "astroid"
 version = "2.15.8"
 description = "An abstract syntax tree for Python with inference support."
-category = "main"
 optional = false
 python-versions = ">=3.7.2"
 files = [
@@ -122,7 +69,6 @@ wrapt = {version = ">=1.14,<2", markers = "python_version >= \"3.11\""}
 name = "asttokens"
 version = "2.4.1"
 description = "Annotate AST trees with source code positions"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -141,7 +87,6 @@ test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"]
 name = "async-timeout"
 version = "4.0.3"
 description = "Timeout context manager for asyncio programs"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -149,11 +94,20 @@ files = [
     {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"},
 ]
 
+[[package]]
+name = "atomicwrites"
+version = "1.4.1"
+description = "Atomic file writes."
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+    {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"},
+]
+
 [[package]]
 name = "attrdict"
 version = "2.0.1"
 description = "A dict with attribute-style access"
-category = "main"
 optional = false
 python-versions = "*"
 files = []
@@ -172,7 +126,6 @@ resolved_reference = "83b779ee82d5b0e33be695d398162b8f2430ff33"
 name = "attrs"
 version = "23.1.0"
 description = "Classes Without Boilerplate"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -191,7 +144,6 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte
 name = "autobahn"
 version = "23.6.2"
 description = "WebSocket client & server library, WAMP real-time framework"
-category = "main"
 optional = false
 python-versions = ">=3.9"
 files = [
@@ -220,7 +172,6 @@ xbr = ["base58 (>=2.1.0)", "bitarray (>=2.7.5)", "cbor2 (>=5.2.0)", "click (>=8.
 name = "automat"
 version = "22.10.0"
 description = "Self-service finite-state machines for the programmer on the go."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -239,7 +190,6 @@ visualize = ["Twisted (>=16.1.1)", "graphviz (>0.5.1)"]
 name = "backcall"
 version = "0.2.0"
 description = "Specifications for callback functions passed in to an API"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -251,7 +201,6 @@ files = [
 name = "beautifulsoup4"
 version = "4.12.2"
 description = "Screen-scraping library"
-category = "main"
 optional = false
 python-versions = ">=3.6.0"
 files = [
@@ -270,7 +219,6 @@ lxml = ["lxml"]
 name = "billiard"
 version = "4.1.0"
 description = "Python multiprocessing fork with improvements and bugfixes"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -282,7 +230,6 @@ files = [
 name = "black"
 version = "23.10.1"
 description = "The uncompromising code formatter."
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -323,7 +270,6 @@ uvloop = ["uvloop (>=0.15.2)"]
 name = "boxsdk"
 version = "2.14.0"
 description = "Official Box Python SDK"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -348,7 +294,6 @@ test = ["bottle", "coverage (<5.0)", "jsonpatch", "mock (>=2.0.0,<4.0.0)", "pyco
 name = "cachetools"
 version = "5.3.2"
 description = "Extensible memoizing collections and decorators"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -360,7 +305,6 @@ files = [
 name = "celery"
 version = "5.3.4"
 description = "Distributed Task Queue."
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -416,7 +360,6 @@ zstd = ["zstandard (==0.21.0)"]
 name = "certifi"
 version = "2023.7.22"
 description = "Python package for providing Mozilla's CA Bundle."
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -428,7 +371,6 @@ files = [
 name = "cffi"
 version = "1.16.0"
 description = "Foreign Function Interface for Python calling C code."
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -493,7 +435,6 @@ pycparser = "*"
 name = "channels"
 version = "4.0.0"
 description = "Brings async, event-driven capabilities to Django 3.2 and up."
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -513,7 +454,6 @@ tests = ["async-timeout", "coverage (>=4.5,<5.0)", "pytest", "pytest-asyncio", "
 name = "channels-redis"
 version = "4.1.0"
 description = "Redis-backed ASGI channel layer implementation"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -535,7 +475,6 @@ tests = ["async-timeout", "cryptography (>=1.3.0)", "pytest", "pytest-asyncio",
 name = "charset-normalizer"
 version = "3.3.1"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
-category = "main"
 optional = false
 python-versions = ">=3.7.0"
 files = [
@@ -635,7 +574,6 @@ files = [
 name = "click"
 version = "8.1.7"
 description = "Composable command line interface toolkit"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -650,7 +588,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
 name = "click-didyoumean"
 version = "0.3.0"
 description = "Enables git-like *did-you-mean* feature in click"
-category = "main"
 optional = false
 python-versions = ">=3.6.2,<4.0.0"
 files = [
@@ -665,7 +602,6 @@ click = ">=7"
 name = "click-plugins"
 version = "1.1.1"
 description = "An extension module for click to enable registering CLI commands via setuptools entry-points."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -683,7 +619,6 @@ dev = ["coveralls", "pytest (>=3.6)", "pytest-cov", "wheel"]
 name = "click-repl"
 version = "0.3.0"
 description = "REPL plugin for Click"
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -702,7 +637,6 @@ testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"]
 name = "cloudpickle"
 version = "3.0.0"
 description = "Pickler class to extend the standard pickle.Pickler functionality"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -714,7 +648,6 @@ files = [
 name = "colorama"
 version = "0.4.6"
 description = "Cross-platform colored terminal text."
-category = "main"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 files = [
@@ -726,7 +659,6 @@ files = [
 name = "constantly"
 version = "15.1.0"
 description = "Symbolic constants in Python"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -738,7 +670,6 @@ files = [
 name = "coverage"
 version = "7.3.2"
 description = "Code coverage measurement for Python"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -803,7 +734,6 @@ toml = ["tomli"]
 name = "cryptography"
 version = "41.0.5"
 description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -849,7 +779,6 @@ test-randomorder = ["pytest-randomly"]
 name = "cssselect2"
 version = "0.7.0"
 description = "CSS selectors for Python ElementTree"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -865,25 +794,10 @@ webencodings = "*"
 doc = ["sphinx", "sphinx_rtd_theme"]
 test = ["flake8", "isort", "pytest"]
 
-[[package]]
-name = "curlify"
-version = "2.2.1"
-description = "Library to convert python requests object to curl command."
-category = "main"
-optional = false
-python-versions = "*"
-files = [
-    {file = "curlify-2.2.1.tar.gz", hash = "sha256:0d3f02e7235faf952de8ef45ef469845196d30632d5838bcd5aee217726ddd6d"},
-]
-
-[package.dependencies]
-requests = "*"
-
 [[package]]
 name = "daphne"
 version = "4.0.0"
 description = "Django ASGI (HTTP/WebSocket) server"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -903,7 +817,6 @@ tests = ["django", "hypothesis", "pytest", "pytest-asyncio"]
 name = "debugpy"
 version = "1.8.0"
 description = "An implementation of the Debug Adapter Protocol for Python"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -931,7 +844,6 @@ files = [
 name = "decorator"
 version = "5.1.1"
 description = "Decorators for Humans"
-category = "main"
 optional = false
 python-versions = ">=3.5"
 files = [
@@ -943,7 +855,6 @@ files = [
 name = "dill"
 version = "0.3.7"
 description = "serialize all of Python"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -958,7 +869,6 @@ graph = ["objgraph (>=1.7.2)"]
 name = "django"
 version = "4.2.6"
 description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -979,7 +889,6 @@ bcrypt = ["bcrypt"]
 name = "django-appconf"
 version = "1.0.5"
 description = "A helper class for handling configuration defaults of packaged apps gracefully."
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -994,7 +903,6 @@ django = "*"
 name = "django-bootstrap3"
 version = "23.4"
 description = "Bootstrap 3 for Django"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -1010,7 +918,6 @@ django = ">=3.2"
 name = "django-classy-tags"
 version = "4.1.0"
 description = "Class based template tags for Django"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -1025,7 +932,6 @@ django = ">=3.2"
 name = "django-cms"
 version = "3.11.4"
 description = "Lean enterprise content management powered by Django."
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1046,7 +952,6 @@ packaging = "*"
 name = "django-filer"
 version = "2.2.6"
 description = "A file management application for django that makes handling of files and images a breeze."
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1065,7 +970,6 @@ Unidecode = ">=0.04,<1.2"
 name = "django-formtools"
 version = "2.2"
 description = "A set of high-level abstractions for Django forms"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1080,7 +984,6 @@ Django = ">=1.11"
 name = "django-haystack"
 version = "3.2.1"
 description = "Pluggable search for Django."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1097,7 +1000,6 @@ elasticsearch = ["elasticsearch (>=5,<8)"]
 name = "django-impersonate"
 version = "1.9.1"
 description = "Django app to allow superusers to impersonate other users."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1108,7 +1010,6 @@ files = [
 name = "django-ipware"
 version = "1.2.0"
 description = "A Django utility application that returns client's real IP address"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1119,7 +1020,6 @@ files = [
 name = "django-js-asset"
 version = "2.1.0"
 description = "script tag with additional attributes for django.forms.Media"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -1137,7 +1037,6 @@ tests = ["coverage"]
 name = "django-mptt"
 version = "0.15.0"
 description = "Utilities for implementing Modified Preorder Tree Traversal with your Django Models and working with trees of Model instances."
-category = "main"
 optional = false
 python-versions = ">=3.9"
 files = [
@@ -1155,7 +1054,6 @@ tests = ["coverage[toml]", "mock-django"]
 name = "django-polymorphic"
 version = "3.1.0"
 description = "Seamless polymorphic inheritance for Django models"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1170,7 +1068,6 @@ Django = ">=2.1"
 name = "django-recaptcha"
 version = "3.0.0"
 description = "Django recaptcha form field/widget app."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1185,7 +1082,6 @@ django = "*"
 name = "django-recaptcha2"
 version = "1.4.1"
 description = "Django reCaptcha v2 field/widget"
-category = "main"
 optional = false
 python-versions = "*"
 files = []
@@ -1204,7 +1100,6 @@ resolved_reference = "1b7942af0032f1e6ba368e0028c48e3c7cfa0588"
 name = "django-sekizai"
 version = "2.0.0"
 description = "Django Sekizai"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1220,7 +1115,6 @@ django-classy-tags = ">=1"
 name = "django-select2"
 version = "6.3.1"
 description = "Select2 option fields for Django."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1235,7 +1129,6 @@ django-appconf = ">=0.6.0"
 name = "django-termsandconditions"
 version = "2.0.12"
 description = "Django app that enables users to accept terms and conditions of a site."
-category = "main"
 optional = false
 python-versions = ">=3.7.2,<4.0"
 files = [
@@ -1250,7 +1143,6 @@ Django = ">2.2"
 name = "django-treebeard"
 version = "4.7"
 description = "Efficient tree implementations for Django"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -1265,7 +1157,6 @@ Django = ">=3.2"
 name = "djangocms-admin-style"
 version = "3.2.6"
 description = "Adds pretty CSS styles for the django CMS admin interface."
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1280,7 +1171,6 @@ Django = "*"
 name = "djangocms-attributes-field"
 version = "3.0.0"
 description = "Adds attributes to Django models."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1295,7 +1185,6 @@ django-cms = ">=3.7"
 name = "djangocms-cascade"
 version = "0.16.3"
 description = "Collection of extendible plugins for django-CMS to create and edit widgets in a simple manner"
-category = "main"
 optional = false
 python-versions = "*"
 files = []
@@ -1317,7 +1206,6 @@ resolved_reference = "9e9f9e3088a0fcfdc1b27ad7e08b68390aa6a570"
 name = "djangocms-file"
 version = "3.0.0"
 description = "Adds file plugin to django CMS"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1334,7 +1222,6 @@ djangocms-attributes-field = ">=1"
 name = "djangocms-forms-maintained"
 version = "202206141440"
 description = "The easiest and most flexible Django CMS Form builder w/ ReCaptcha v2 support!"
-category = "main"
 optional = false
 python-versions = "*"
 files = []
@@ -1361,7 +1248,6 @@ resolved_reference = "63ead9288c2ea65139124698bffc0ad01d182afa"
 name = "djangocms-googlemap"
 version = "2.0.0"
 description = "Adds Google Maps plugins to django CMS."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1377,7 +1263,6 @@ django-filer = ">=1.7"
 name = "djangocms-picture"
 version = "3.0.0"
 description = "Adds an image plugin to django CMS"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1395,7 +1280,6 @@ easy-thumbnails = "*"
 name = "djangocms-snippet"
 version = "3.0.0"
 description = "Adds snippet plugin to django CMS."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1410,7 +1294,6 @@ django-cms = ">=3.7"
 name = "djangocms-style"
 version = "3.0.0"
 description = "Adds style plugin to django CMS"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1426,7 +1309,6 @@ djangocms-attributes-field = ">=1"
 name = "djangocms-text-ckeditor"
 version = "5.1.4"
 description = "Text Plugin for django CMS with CKEditor support"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1444,7 +1326,6 @@ Pillow = "*"
 name = "djangocms-video"
 version = "3.0.0"
 description = "Adds video plugin to django CMS."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1461,7 +1342,6 @@ djangocms-attributes-field = ">=1"
 name = "dropbox"
 version = "10.6.0"
 description = "Official Dropbox API Client"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1478,7 +1358,6 @@ six = ">=1.12.0"
 name = "easy-thumbnails"
 version = "2.8.5"
 description = "Easy thumbnails for Django"
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1499,7 +1378,6 @@ svg = ["reportlab", "svglib"]
 name = "elasticsearch"
 version = "7.17.9"
 description = "Python client for Elasticsearch"
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4"
 files = [
@@ -1521,7 +1399,6 @@ requests = ["requests (>=2.4.0,<3.0.0)"]
 name = "elasticsearch-dsl"
 version = "7.4.1"
 description = "Python client for Elasticsearch"
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
@@ -1541,7 +1418,6 @@ develop = ["coverage (<5.0.0)", "mock", "pytest (>=3.0.0)", "pytest-cov", "pytes
 name = "et-xmlfile"
 version = "1.1.0"
 description = "An implementation of lxml.xmlfile for the standard library"
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1553,7 +1429,6 @@ files = [
 name = "executing"
 version = "2.0.0"
 description = "Get the currently executing AST node of a frame, and other information"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1568,7 +1443,6 @@ tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipyth
 name = "exifread"
 version = "2.3.2"
 description = "Read Exif metadata from tiff and jpeg files."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1576,22 +1450,10 @@ files = [
     {file = "ExifRead-2.3.2.tar.gz", hash = "sha256:a0f74af5040168d3883bbc980efe26d06c89f026dc86ba28eb34107662d51766"},
 ]
 
-[[package]]
-name = "future"
-version = "0.18.3"
-description = "Clean single-source support for Python 3 and 2"
-category = "main"
-optional = false
-python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
-files = [
-    {file = "future-0.18.3.tar.gz", hash = "sha256:34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307"},
-]
-
 [[package]]
 name = "google-api-core"
 version = "2.12.0"
 description = "Google API client core library"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1614,7 +1476,6 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"]
 name = "google-api-python-client"
 version = "2.105.0"
 description = "Google API Client Library for Python"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1623,7 +1484,7 @@ files = [
 ]
 
 [package.dependencies]
-google-api-core = ">=1.31.5,<2.0.0 || >2.3.0,<3.0.0.dev0"
+google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0.dev0"
 google-auth = ">=1.19.0,<3.0.0.dev0"
 google-auth-httplib2 = ">=0.1.0"
 httplib2 = ">=0.15.0,<1.dev0"
@@ -1633,7 +1494,6 @@ uritemplate = ">=3.0.1,<5"
 name = "google-auth"
 version = "2.23.3"
 description = "Google Authentication Library"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1657,7 +1517,6 @@ requests = ["requests (>=2.20.0,<3.0.0.dev0)"]
 name = "google-auth-httplib2"
 version = "0.1.1"
 description = "Google Authentication Library: httplib2 transport"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1673,7 +1532,6 @@ httplib2 = ">=0.19.0"
 name = "google-auth-oauthlib"
 version = "0.4.6"
 description = "Google Authentication Library"
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1692,7 +1550,6 @@ tool = ["click (>=6.0.0)"]
 name = "googleapis-common-protos"
 version = "1.61.0"
 description = "Common protobufs used in Google APIs"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1710,7 +1567,6 @@ grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"]
 name = "hashids"
 version = "1.3.1"
 description = "Implements the hashids algorithm in python. For more information, visit http://hashids.org/"
-category = "main"
 optional = false
 python-versions = ">=2.7"
 files = [
@@ -1725,7 +1581,6 @@ test = ["pytest (>=2.1.0)"]
 name = "html5lib"
 version = "1.1"
 description = "HTML parser based on the WHATWG HTML specification"
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 files = [
@@ -1747,7 +1602,6 @@ lxml = ["lxml"]
 name = "httplib2"
 version = "0.22.0"
 description = "A comprehensive HTTP client library."
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
@@ -1762,7 +1616,6 @@ pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0
 name = "hyperlink"
 version = "21.0.0"
 description = "A featureful, immutable, and correct URL for Python."
-category = "main"
 optional = false
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
@@ -1777,7 +1630,6 @@ idna = ">=2.5"
 name = "idna"
 version = "3.4"
 description = "Internationalized Domain Names in Applications (IDNA)"
-category = "main"
 optional = false
 python-versions = ">=3.5"
 files = [
@@ -1789,7 +1641,6 @@ files = [
 name = "importlib-resources"
 version = "5.13.0"
 description = "Read resources from Python packages"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -1805,7 +1656,6 @@ testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)",
 name = "incremental"
 version = "22.10.0"
 description = "\"A small library that versions your Python projects.\""
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1821,7 +1671,6 @@ scripts = ["click (>=6.0)", "twisted (>=16.4.0)"]
 name = "iniconfig"
 version = "2.0.0"
 description = "brain-dead simple config-ini parsing"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1833,7 +1682,6 @@ files = [
 name = "ipdb"
 version = "0.13.13"
 description = "IPython-enabled pdb"
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
@@ -1849,7 +1697,6 @@ ipython = {version = ">=7.31.1", markers = "python_version >= \"3.11\""}
 name = "ipython"
 version = "8.16.1"
 description = "IPython: Productive Interactive Computing"
-category = "main"
 optional = false
 python-versions = ">=3.9"
 files = [
@@ -1884,11 +1731,24 @@ qtconsole = ["qtconsole"]
 test = ["pytest (<7.1)", "pytest-asyncio", "testpath"]
 test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"]
 
+[[package]]
+name = "isodate"
+version = "0.6.1"
+description = "An ISO 8601 date/time/duration parser and formatter"
+optional = false
+python-versions = "*"
+files = [
+    {file = "isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96"},
+    {file = "isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9"},
+]
+
+[package.dependencies]
+six = "*"
+
 [[package]]
 name = "isort"
 version = "5.12.0"
 description = "A Python utility / library to sort Python imports."
-category = "main"
 optional = false
 python-versions = ">=3.8.0"
 files = [
@@ -1906,7 +1766,6 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"]
 name = "jedi"
 version = "0.19.1"
 description = "An autocompletion tool for Python that can be used for text editors."
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1922,29 +1781,10 @@ docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alab
 qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"]
 testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"]
 
-[[package]]
-name = "jinja2"
-version = "3.1.2"
-description = "A very fast and expressive template engine."
-category = "main"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
-    {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
-]
-
-[package.dependencies]
-MarkupSafe = ">=2.0"
-
-[package.extras]
-i18n = ["Babel (>=2.7)"]
-
 [[package]]
 name = "jsonfield"
 version = "3.1.0"
 description = "A reusable Django field that allows you to store validated JSON in your model."
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1959,7 +1799,6 @@ Django = ">=2.2"
 name = "jsonpickle"
 version = "3.0.2"
 description = "Python library for serializing any arbitrary object graph into JSON"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1972,11 +1811,46 @@ docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"]
 testing = ["ecdsa", "feedparser", "gmpy2", "numpy", "pandas", "pymongo", "pytest (>=3.5,!=3.7.3)", "pytest-black-multipy", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-flake8 (>=1.1.1)", "scikit-learn", "sqlalchemy"]
 testing-libs = ["simplejson", "ujson"]
 
+[[package]]
+name = "jsonschema"
+version = "4.17.3"
+description = "An implementation of JSON Schema validation for Python"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "jsonschema-4.17.3-py3-none-any.whl", hash = "sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6"},
+    {file = "jsonschema-4.17.3.tar.gz", hash = "sha256:0f864437ab8b6076ba6707453ef8f98a6a0d512a80e93f8abdb676f737ecb60d"},
+]
+
+[package.dependencies]
+attrs = ">=17.4.0"
+pyrsistent = ">=0.14.0,<0.17.0 || >0.17.0,<0.17.1 || >0.17.1,<0.17.2 || >0.17.2"
+
+[package.extras]
+format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"]
+format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=1.11)"]
+
+[[package]]
+name = "jsonschema-spec"
+version = "0.1.6"
+description = "JSONSchema Spec with object-oriented paths"
+optional = false
+python-versions = ">=3.7.0,<4.0.0"
+files = [
+    {file = "jsonschema_spec-0.1.6-py3-none-any.whl", hash = "sha256:f2206d18c89d1824c1f775ba14ed039743b41a9167bd2c5bdb774b66b3ca0bbf"},
+    {file = "jsonschema_spec-0.1.6.tar.gz", hash = "sha256:90215863b56e212086641956b20127ccbf6d8a3a38343dad01d6a74d19482f76"},
+]
+
+[package.dependencies]
+jsonschema = ">=4.0.0,<4.18.0"
+pathable = ">=0.4.1,<0.5.0"
+PyYAML = ">=5.1"
+requests = ">=2.31.0,<3.0.0"
+
 [[package]]
 name = "kombu"
 version = "5.3.2"
 description = "Messaging library for Python."
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -2009,7 +1883,6 @@ zookeeper = ["kazoo (>=2.8.0)"]
 name = "lazy-object-proxy"
 version = "1.9.0"
 description = "A fast and thorough lazy object proxy."
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2055,7 +1928,6 @@ files = [
 name = "lxml"
 version = "4.9.3"
 description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*"
 files = [
@@ -2161,69 +2033,77 @@ source = ["Cython (>=0.29.35)"]
 
 [[package]]
 name = "markupsafe"
-version = "2.1.3"
+version = "2.1.5"
 description = "Safely add untrusted strings to HTML/XML markup."
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"},
-    {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"},
-    {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"},
-    {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"},
-    {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"},
-    {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"},
-    {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"},
-    {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"},
-    {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"},
-    {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"},
-    {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"},
-    {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"},
-    {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"},
-    {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"},
-    {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"},
-    {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"},
-    {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"},
-    {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
-    {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
-    {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
-    {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
-    {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
-    {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
-    {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"},
-    {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"},
-    {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"},
-    {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"},
-    {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"},
-    {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"},
-    {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"},
-    {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"},
-    {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"},
-    {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"},
-    {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"},
-    {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"},
-    {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"},
-    {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"},
-    {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"},
-    {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"},
-    {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"},
-    {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"},
-    {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"},
-    {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"},
-    {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"},
-    {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"},
-    {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"},
-    {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"},
-    {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"},
-    {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"},
-    {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"},
+    {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"},
 ]
 
 [[package]]
 name = "matplotlib-inline"
 version = "0.1.6"
 description = "Inline Matplotlib backend for Jupyter"
-category = "main"
 optional = false
 python-versions = ">=3.5"
 files = [
@@ -2238,7 +2118,6 @@ traitlets = "*"
 name = "mccabe"
 version = "0.7.0"
 description = "McCabe checker, plugin for flake8"
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -2250,7 +2129,6 @@ files = [
 name = "mock"
 version = "4.0.3"
 description = "Rolling backport of unittest.mock for all Pythons"
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -2263,11 +2141,21 @@ build = ["blurb", "twine", "wheel"]
 docs = ["sphinx"]
 test = ["pytest (<5.4)", "pytest-cov"]
 
+[[package]]
+name = "more-itertools"
+version = "10.2.0"
+description = "More routines for operating on iterables, beyond itertools"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "more-itertools-10.2.0.tar.gz", hash = "sha256:8fccb480c43d3e99a00087634c06dd02b0d50fbf088b380de5a41a015ec239e1"},
+    {file = "more_itertools-10.2.0-py3-none-any.whl", hash = "sha256:686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684"},
+]
+
 [[package]]
 name = "msgpack"
 version = "1.0.7"
 description = "MessagePack serializer"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -2333,7 +2221,6 @@ files = [
 name = "mypy-extensions"
 version = "1.0.0"
 description = "Type system extensions for programs checked with the mypy type checker."
-category = "main"
 optional = false
 python-versions = ">=3.5"
 files = [
@@ -2345,7 +2232,6 @@ files = [
 name = "networkx"
 version = "3.2.1"
 description = "Python package for creating and manipulating graphs and networks"
-category = "main"
 optional = false
 python-versions = ">=3.9"
 files = [
@@ -2364,7 +2250,6 @@ test = ["pytest (>=7.2)", "pytest-cov (>=4.0)"]
 name = "oauth2client"
 version = "4.1.3"
 description = "OAuth 2.0 client library"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -2383,7 +2268,6 @@ six = ">=1.6.1"
 name = "oauthlib"
 version = "3.2.2"
 description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic"
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -2400,18 +2284,84 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
 name = "olefile"
 version = "0.46"
 description = "Python package to parse, read and write Microsoft OLE2 files (Structured Storage or Compound Document, Microsoft Office)"
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
     {file = "olefile-0.46.zip", hash = "sha256:133b031eaf8fd2c9399b78b8bc5b8fcbe4c31e85295749bb17a87cba8f3c3964"},
 ]
 
+[[package]]
+name = "openapi-core"
+version = "0.16.0"
+description = "client-side and server-side support for the OpenAPI Specification v3"
+optional = false
+python-versions = ">=3.7.0,<4.0.0"
+files = [
+    {file = "openapi-core-0.16.0.tar.gz", hash = "sha256:5db8fa034e5c262de865cab5f2344995c52f1ba0386182c0be584d02f0282c6a"},
+    {file = "openapi_core-0.16.0-py3-none-any.whl", hash = "sha256:4331f528f5a74c7a3963f37b2ad73c54e3dd477276354fd6b7188d2352fd7e8e"},
+]
+
+[package.dependencies]
+isodate = "*"
+jsonschema-spec = ">=0.1.1,<0.2.0"
+more-itertools = "*"
+openapi-schema-validator = ">=0.3.0,<0.4.0"
+openapi-spec-validator = ">=0.5.0,<0.6.0"
+parse = "*"
+pathable = ">=0.4.0,<0.5.0"
+typing-extensions = ">=4.3.0,<5.0.0"
+werkzeug = "*"
+
+[package.extras]
+django = ["django (>=3.0)"]
+falcon = ["falcon (>=3.0)"]
+flask = ["flask"]
+requests = ["requests"]
+
+[[package]]
+name = "openapi-schema-validator"
+version = "0.3.4"
+description = "OpenAPI schema validation for Python"
+optional = false
+python-versions = ">=3.7.0,<4.0.0"
+files = [
+    {file = "openapi-schema-validator-0.3.4.tar.gz", hash = "sha256:7cf27585dd7970b7257cefe48e1a3a10d4e34421831bdb472d96967433bc27bd"},
+    {file = "openapi_schema_validator-0.3.4-py3-none-any.whl", hash = "sha256:34fbd14b7501abe25e64d7b4624a9db02cde1a578d285b3da6f34b290cdf0b3a"},
+]
+
+[package.dependencies]
+attrs = ">=19.2.0"
+jsonschema = ">=4.0.0,<5.0.0"
+
+[package.extras]
+isodate = ["isodate"]
+rfc3339-validator = ["rfc3339-validator"]
+strict-rfc3339 = ["strict-rfc3339"]
+
+[[package]]
+name = "openapi-spec-validator"
+version = "0.5.4"
+description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3 spec validator"
+optional = false
+python-versions = ">=3.7.0,<4.0.0"
+files = [
+    {file = "openapi_spec_validator-0.5.4-py3-none-any.whl", hash = "sha256:96be4258fdccc89d3da094738e19d56b94956914b93a22de795b9dd220cb4c7c"},
+    {file = "openapi_spec_validator-0.5.4.tar.gz", hash = "sha256:68654e81cc56c71392dba31bf55d11e1c03c99458bebcb0018959a7134e104da"},
+]
+
+[package.dependencies]
+jsonschema = ">=4.0.0,<5.0.0"
+jsonschema-spec = ">=0.1.1,<0.2.0"
+lazy-object-proxy = ">=1.7.1,<2.0.0"
+openapi-schema-validator = ">=0.3.2,<0.5"
+
+[package.extras]
+requests = ["requests"]
+
 [[package]]
 name = "openpyxl"
 version = "3.1.2"
 description = "A Python library to read/write Excel 2010 xlsx/xlsm files"
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -2426,7 +2376,6 @@ et-xmlfile = "*"
 name = "opf-fido"
 version = "1.4.1"
 description = "Format Identification for Digital Objects (FIDO)."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -2447,7 +2396,6 @@ testing = ["pytest"]
 name = "packaging"
 version = "23.2"
 description = "Core utilities for Python packages"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2455,11 +2403,21 @@ files = [
     {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
 ]
 
+[[package]]
+name = "parse"
+version = "1.20.1"
+description = "parse() is the opposite of format()"
+optional = false
+python-versions = "*"
+files = [
+    {file = "parse-1.20.1-py2.py3-none-any.whl", hash = "sha256:76ddd5214255ae711db4c512be636151fbabaa948c6f30115aecc440422ca82c"},
+    {file = "parse-1.20.1.tar.gz", hash = "sha256:09002ca350ad42e76629995f71f7b518670bcf93548bdde3684fd55d2be51975"},
+]
+
 [[package]]
 name = "parso"
 version = "0.8.3"
 description = "A Python Parser"
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -2471,11 +2429,21 @@ files = [
 qa = ["flake8 (==3.8.3)", "mypy (==0.782)"]
 testing = ["docopt", "pytest (<6.0.0)"]
 
+[[package]]
+name = "pathable"
+version = "0.4.3"
+description = "Object-oriented paths"
+optional = false
+python-versions = ">=3.7.0,<4.0.0"
+files = [
+    {file = "pathable-0.4.3-py3-none-any.whl", hash = "sha256:cdd7b1f9d7d5c8b8d3315dbf5a86b2596053ae845f056f57d97c0eefff84da14"},
+    {file = "pathable-0.4.3.tar.gz", hash = "sha256:5c869d315be50776cc8a993f3af43e0c60dc01506b399643f919034ebf4cdcab"},
+]
+
 [[package]]
 name = "pathspec"
 version = "0.11.2"
 description = "Utility library for gitignore style pattern matching of file paths."
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2483,22 +2451,10 @@ files = [
     {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"},
 ]
 
-[[package]]
-name = "petname"
-version = "2.6"
-description = "Generate human-readable, random object names"
-category = "main"
-optional = false
-python-versions = "*"
-files = [
-    {file = "petname-2.6.tar.gz", hash = "sha256:981c31ef772356a373640d1bb7c67c102e0159eda14578c67a1c99d5b34c9e4c"},
-]
-
 [[package]]
 name = "pexpect"
 version = "4.8.0"
 description = "Pexpect allows easy control of interactive console applications."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -2513,7 +2469,6 @@ ptyprocess = ">=0.5"
 name = "pickleshare"
 version = "0.7.5"
 description = "Tiny 'shelve'-like database with concurrency support"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -2525,7 +2480,6 @@ files = [
 name = "pillow"
 version = "10.1.0"
 description = "Python Imaging Library (Fork)"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -2593,7 +2547,6 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa
 name = "platformdirs"
 version = "3.11.0"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2609,7 +2562,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-co
 name = "pluggy"
 version = "1.3.0"
 description = "plugin and hook calling mechanisms for python"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -2625,7 +2577,6 @@ testing = ["pytest", "pytest-benchmark"]
 name = "ply"
 version = "3.11"
 description = "Python Lex & Yacc"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -2637,7 +2588,6 @@ files = [
 name = "prompt-toolkit"
 version = "3.0.39"
 description = "Library for building powerful interactive command lines in Python"
-category = "main"
 optional = false
 python-versions = ">=3.7.0"
 files = [
@@ -2652,7 +2602,6 @@ wcwidth = "*"
 name = "protobuf"
 version = "4.24.4"
 description = ""
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2675,7 +2624,6 @@ files = [
 name = "psycopg"
 version = "3.1.12"
 description = "PostgreSQL database adapter for Python"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2699,7 +2647,6 @@ test = ["anyio (>=3.6.2,<4.0)", "mypy (>=1.4.1)", "pproxy (>=2.7)", "pytest (>=6
 name = "ptyprocess"
 version = "0.7.0"
 description = "Run a subprocess in a pseudo terminal"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -2711,7 +2658,6 @@ files = [
 name = "pure-eval"
 version = "0.2.2"
 description = "Safely evaluate AST nodes without side effects"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -2722,23 +2668,10 @@ files = [
 [package.extras]
 tests = ["pytest"]
 
-[[package]]
-name = "py"
-version = "1.11.0"
-description = "library with cross-python path, ini-parsing, io, code, log facilities"
-category = "main"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
-files = [
-    {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
-    {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
-]
-
 [[package]]
 name = "pyasn1"
 version = "0.5.0"
 description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
-category = "main"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
 files = [
@@ -2750,7 +2683,6 @@ files = [
 name = "pyasn1-modules"
 version = "0.3.0"
 description = "A collection of ASN.1-based protocols modules"
-category = "main"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
 files = [
@@ -2765,7 +2697,6 @@ pyasn1 = ">=0.4.6,<0.6.0"
 name = "pycparser"
 version = "2.21"
 description = "C parser in Python"
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
@@ -2777,7 +2708,6 @@ files = [
 name = "pydantic"
 version = "2.5.0"
 description = "Data validation using Python type hints"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2797,7 +2727,6 @@ email = ["email-validator (>=2.0.0)"]
 name = "pydantic-core"
 version = "2.14.1"
 description = ""
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2911,7 +2840,6 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
 name = "pygments"
 version = "2.16.1"
 description = "Pygments is a syntax highlighting package written in Python."
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2926,7 +2854,6 @@ plugins = ["importlib-metadata"]
 name = "pyjwt"
 version = "1.7.1"
 description = "JSON Web Token implementation in Python"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -2943,7 +2870,6 @@ test = ["pytest (>=4.0.1,<5.0.0)", "pytest-cov (>=2.6.0,<3.0.0)", "pytest-runner
 name = "pylint"
 version = "2.17.7"
 description = "python code static checker"
-category = "main"
 optional = false
 python-versions = ">=3.7.2"
 files = [
@@ -2968,7 +2894,6 @@ testutils = ["gitpython (>3)"]
 name = "pylint-django"
 version = "2.5.5"
 description = "A Pylint plugin to help Pylint understand the Django web framework"
-category = "main"
 optional = false
 python-versions = ">=3.7,<4.0"
 files = [
@@ -2987,7 +2912,6 @@ with-django = ["Django (>=2.2)"]
 name = "pylint-plugin-utils"
 version = "0.8.2"
 description = "Utilities and helpers for writing Pylint plugins"
-category = "main"
 optional = false
 python-versions = ">=3.7,<4.0"
 files = [
@@ -3002,7 +2926,6 @@ pylint = ">=1.7"
 name = "pymemcache"
 version = "4.0.0"
 description = "A comprehensive, fast, pure Python memcached client"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -3014,7 +2937,6 @@ files = [
 name = "pymongo"
 version = "3.13.0"
 description = "Python driver for MongoDB <http://www.mongodb.org>"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -3143,7 +3065,6 @@ zstd = ["zstandard"]
 name = "pyopenssl"
 version = "23.3.0"
 description = "Python wrapper module around the OpenSSL library"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -3162,7 +3083,6 @@ test = ["flaky", "pretend", "pytest (>=3.0.1)"]
 name = "pyparsing"
 version = "3.1.1"
 description = "pyparsing module - Classes and methods to define and execute parsing grammars"
-category = "main"
 optional = false
 python-versions = ">=3.6.8"
 files = [
@@ -3173,11 +3093,51 @@ files = [
 [package.extras]
 diagrams = ["jinja2", "railroad-diagrams"]
 
+[[package]]
+name = "pyrsistent"
+version = "0.20.0"
+description = "Persistent/Functional/Immutable data structures"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "pyrsistent-0.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8c3aba3e01235221e5b229a6c05f585f344734bd1ad42a8ac51493d74722bbce"},
+    {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1beb78af5423b879edaf23c5591ff292cf7c33979734c99aa66d5914ead880f"},
+    {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21cc459636983764e692b9eba7144cdd54fdec23ccdb1e8ba392a63666c60c34"},
+    {file = "pyrsistent-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5ac696f02b3fc01a710427585c855f65cd9c640e14f52abe52020722bb4906b"},
+    {file = "pyrsistent-0.20.0-cp310-cp310-win32.whl", hash = "sha256:0724c506cd8b63c69c7f883cc233aac948c1ea946ea95996ad8b1380c25e1d3f"},
+    {file = "pyrsistent-0.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:8441cf9616d642c475684d6cf2520dd24812e996ba9af15e606df5f6fd9d04a7"},
+    {file = "pyrsistent-0.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0f3b1bcaa1f0629c978b355a7c37acd58907390149b7311b5db1b37648eb6958"},
+    {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cdd7ef1ea7a491ae70d826b6cc64868de09a1d5ff9ef8d574250d0940e275b8"},
+    {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cae40a9e3ce178415040a0383f00e8d68b569e97f31928a3a8ad37e3fde6df6a"},
+    {file = "pyrsistent-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6288b3fa6622ad8a91e6eb759cfc48ff3089e7c17fb1d4c59a919769314af224"},
+    {file = "pyrsistent-0.20.0-cp311-cp311-win32.whl", hash = "sha256:7d29c23bdf6e5438c755b941cef867ec2a4a172ceb9f50553b6ed70d50dfd656"},
+    {file = "pyrsistent-0.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:59a89bccd615551391f3237e00006a26bcf98a4d18623a19909a2c48b8e986ee"},
+    {file = "pyrsistent-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e"},
+    {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e"},
+    {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3"},
+    {file = "pyrsistent-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d"},
+    {file = "pyrsistent-0.20.0-cp312-cp312-win32.whl", hash = "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174"},
+    {file = "pyrsistent-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d"},
+    {file = "pyrsistent-0.20.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:79ed12ba79935adaac1664fd7e0e585a22caa539dfc9b7c7c6d5ebf91fb89054"},
+    {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f920385a11207dc372a028b3f1e1038bb244b3ec38d448e6d8e43c6b3ba20e98"},
+    {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f5c2d012671b7391803263419e31b5c7c21e7c95c8760d7fc35602353dee714"},
+    {file = "pyrsistent-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef3992833fbd686ee783590639f4b8343a57f1f75de8633749d984dc0eb16c86"},
+    {file = "pyrsistent-0.20.0-cp38-cp38-win32.whl", hash = "sha256:881bbea27bbd32d37eb24dd320a5e745a2a5b092a17f6debc1349252fac85423"},
+    {file = "pyrsistent-0.20.0-cp38-cp38-win_amd64.whl", hash = "sha256:6d270ec9dd33cdb13f4d62c95c1a5a50e6b7cdd86302b494217137f760495b9d"},
+    {file = "pyrsistent-0.20.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ca52d1ceae015859d16aded12584c59eb3825f7b50c6cfd621d4231a6cc624ce"},
+    {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b318ca24db0f0518630e8b6f3831e9cba78f099ed5c1d65ffe3e023003043ba0"},
+    {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed2c3216a605dc9a6ea50c7e84c82906e3684c4e80d2908208f662a6cbf9022"},
+    {file = "pyrsistent-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e14c95c16211d166f59c6611533d0dacce2e25de0f76e4c140fde250997b3ca"},
+    {file = "pyrsistent-0.20.0-cp39-cp39-win32.whl", hash = "sha256:f058a615031eea4ef94ead6456f5ec2026c19fb5bd6bfe86e9665c4158cf802f"},
+    {file = "pyrsistent-0.20.0-cp39-cp39-win_amd64.whl", hash = "sha256:58b8f6366e152092194ae68fefe18b9f0b4f89227dfd86a07770c3d86097aebf"},
+    {file = "pyrsistent-0.20.0-py3-none-any.whl", hash = "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b"},
+    {file = "pyrsistent-0.20.0.tar.gz", hash = "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4"},
+]
+
 [[package]]
 name = "pytas"
 version = "1.7.0"
 description = "Python package for TAS integration"
-category = "main"
 optional = false
 python-versions = "*"
 files = []
@@ -3196,7 +3156,6 @@ resolved_reference = "1e2e4e85b895cc381677a4c9d8e1fa7d8c7b86bc"
 name = "pytest"
 version = "7.4.3"
 description = "pytest: simple powerful testing with Python"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -3217,7 +3176,6 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no
 name = "pytest-asyncio"
 version = "0.14.0"
 description = "Pytest support for asyncio."
-category = "main"
 optional = false
 python-versions = ">= 3.5"
 files = [
@@ -3235,7 +3193,6 @@ testing = ["async-generator (>=1.3)", "coverage", "hypothesis (>=5.7.1)"]
 name = "pytest-cov"
 version = "2.12.1"
 description = "Pytest plugin for measuring coverage."
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 files = [
@@ -3255,7 +3212,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale
 name = "pytest-django"
 version = "4.5.2"
 description = "A Django plugin for pytest."
-category = "main"
 optional = false
 python-versions = ">=3.5"
 files = [
@@ -3274,7 +3230,6 @@ testing = ["Django", "django-configurations (>=2.0)"]
 name = "pytest-mock"
 version = "3.12.0"
 description = "Thin-wrapper around the mock package for easier use with pytest"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -3292,7 +3247,6 @@ dev = ["pre-commit", "pytest-asyncio", "tox"]
 name = "python-dateutil"
 version = "2.8.2"
 description = "Extensions to the standard Python datetime module"
-category = "main"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
 files = [
@@ -3303,26 +3257,10 @@ files = [
 [package.dependencies]
 six = ">=1.5"
 
-[[package]]
-name = "python-dotenv"
-version = "1.0.0"
-description = "Read key-value pairs from a .env file and set them as environment variables"
-category = "main"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"},
-    {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"},
-]
-
-[package.extras]
-cli = ["click (>=5.0)"]
-
 [[package]]
 name = "python-magic"
 version = "0.4.27"
 description = "File type identification using libmagic"
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 files = [
@@ -3334,7 +3272,6 @@ files = [
 name = "pytz"
 version = "2023.3.post1"
 description = "World timezone definitions, modern and historical"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -3346,7 +3283,6 @@ files = [
 name = "pyyaml"
 version = "6.0.1"
 description = "YAML parser and emitter for Python"
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -3355,6 +3291,7 @@ files = [
     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+    {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
     {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
     {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
     {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
@@ -3362,8 +3299,16 @@ files = [
     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+    {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
     {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
     {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+    {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
+    {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+    {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
+    {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
+    {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
+    {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
+    {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
     {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
     {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
     {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
@@ -3380,6 +3325,7 @@ files = [
     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+    {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
     {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
     {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
     {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
@@ -3387,6 +3333,7 @@ files = [
     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+    {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
     {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
     {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
     {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
@@ -3396,7 +3343,6 @@ files = [
 name = "redis"
 version = "5.0.1"
 description = "Python client for Redis database and key-value store"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -3415,7 +3361,6 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"
 name = "reportlab"
 version = "4.0.6"
 description = "The Reportlab Toolkit"
-category = "main"
 optional = false
 python-versions = ">=3.7,<4"
 files = [
@@ -3435,7 +3380,6 @@ renderpm = ["rl-renderPM (>=4.0.3,<4.1)"]
 name = "requests"
 version = "2.31.0"
 description = "Python HTTP for Humans."
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -3457,7 +3401,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 name = "requests-mock"
 version = "1.11.0"
 description = "Mock out responses from the requests package"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -3477,7 +3420,6 @@ test = ["fixtures", "mock", "purl", "pytest", "requests-futures", "sphinx", "tes
 name = "requests-oauthlib"
 version = "1.3.1"
 description = "OAuthlib authentication support for Requests."
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
@@ -3496,7 +3438,6 @@ rsa = ["oauthlib[signedtoken] (>=3.0.0)"]
 name = "requests-toolbelt"
 version = "0.10.1"
 description = "A utility belt for advanced users of python-requests"
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
@@ -3511,7 +3452,6 @@ requests = ">=2.0.1,<3.0.0"
 name = "rsa"
 version = "4.9"
 description = "Pure-Python RSA implementation"
-category = "main"
 optional = false
 python-versions = ">=3.6,<4"
 files = [
@@ -3526,7 +3466,6 @@ pyasn1 = ">=0.1.3"
 name = "rt"
 version = "1.0.13"
 description = "Python interface to Request Tracker API"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -3541,7 +3480,6 @@ six = "*"
 name = "service-identity"
 version = "23.1.0"
 description = "Service identity verification for pyOpenSSL & cryptography."
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -3566,7 +3504,6 @@ tests = ["coverage[toml] (>=5.0.2)", "pytest"]
 name = "setuptools"
 version = "68.2.2"
 description = "Easily download, build, install, upgrade, and uninstall Python packages"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -3583,7 +3520,6 @@ testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jar
 name = "six"
 version = "1.16.0"
 description = "Python 2 and 3 compatibility utilities"
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 files = [
@@ -3595,7 +3531,6 @@ files = [
 name = "soupsieve"
 version = "2.5"
 description = "A modern CSS selector implementation for Beautiful Soup."
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -3607,7 +3542,6 @@ files = [
 name = "sqlparse"
 version = "0.4.4"
 description = "A non-validating SQL parser."
-category = "main"
 optional = false
 python-versions = ">=3.5"
 files = [
@@ -3624,7 +3558,6 @@ test = ["pytest", "pytest-cov"]
 name = "stack-data"
 version = "0.6.3"
 description = "Extract data from python stack frames and tracebacks for informative displays"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -3644,7 +3577,6 @@ tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"]
 name = "stone"
 version = "3.3.1"
 description = "Stone is an interface description language (IDL) for APIs."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -3661,7 +3593,6 @@ six = ">=1.12.0"
 name = "svglib"
 version = "1.5.1"
 description = "A pure-Python library for reading and converting SVG"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -3678,7 +3609,6 @@ tinycss2 = ">=0.6.0"
 name = "tablib"
 version = "3.5.0"
 description = "Format agnostic tabular data library (XLS, JSON, YAML, CSV, etc.)"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -3700,11 +3630,37 @@ xls = ["xlrd", "xlwt"]
 xlsx = ["openpyxl (>=2.6.0)"]
 yaml = ["pyyaml"]
 
+[[package]]
+name = "tapipy"
+version = "1.6.1"
+description = "Python lib for interacting with an instance of the Tapis API Framework"
+optional = false
+python-versions = ">=3.7,<4.0"
+files = [
+    {file = "tapipy-1.6.1-py3-none-any.whl", hash = "sha256:4898ed0ba11a56fc4405d405e3a8f8447af8e08c26a403ff0af1c870ad9a41eb"},
+    {file = "tapipy-1.6.1.tar.gz", hash = "sha256:2d5a23acca2be59f14a75ae56a746a3959ee56d50d6c8edfd39f3932b448a51c"},
+]
+
+[package.dependencies]
+atomicwrites = ">=1.4.0,<2.0.0"
+certifi = ">=2020.11.8"
+cloudpickle = ">=1.6.0"
+cryptography = ">=3.3.2"
+jsonschema = ">=3.2.0"
+openapi_core = "0.16.0"
+openapi_spec_validator = ">=0.5.0,<0.6.0"
+PyJWT = ">=1.7.1"
+python_dateutil = ">=2.5.3,<3.0.0"
+pyyaml = ">=5.4"
+requests = ">=2.20.0,<3.0.0"
+setuptools = ">=21.0.0"
+six = ">=1.10,<2.0"
+urllib3 = ">=1.26.5,<2.0.0"
+
 [[package]]
 name = "tinycss2"
 version = "1.2.1"
 description = "A tiny CSS parser"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -3723,7 +3679,6 @@ test = ["flake8", "isort", "pytest"]
 name = "toml"
 version = "0.10.2"
 description = "Python Library for Tom's Obvious, Minimal Language"
-category = "main"
 optional = false
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 files = [
@@ -3735,7 +3690,6 @@ files = [
 name = "tomlkit"
 version = "0.12.1"
 description = "Style preserving TOML library"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -3747,7 +3701,6 @@ files = [
 name = "traitlets"
 version = "5.12.0"
 description = "Traitlets Python configuration system"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -3763,7 +3716,6 @@ test = ["argcomplete (>=3.0.3)", "mypy (>=1.6.0)", "pre-commit", "pytest (>=7.0,
 name = "twisted"
 version = "23.8.0"
 description = "An asynchronous networking framework written in Python"
-category = "main"
 optional = false
 python-versions = ">=3.7.1"
 files = [
@@ -3804,7 +3756,6 @@ windows-platform = ["pywin32 (!=226)", "pywin32 (!=226)", "twisted[all-non-platf
 name = "twisted-iocpsupport"
 version = "1.0.4"
 description = "An extension for use in the twisted I/O Completion Ports reactor."
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -3833,7 +3784,6 @@ files = [
 name = "txaio"
 version = "23.1.1"
 description = "Compatibility API between asyncio/Twisted/Trollius"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -3846,23 +3796,10 @@ all = ["twisted (>=20.3.0)", "zope.interface (>=5.2.0)"]
 dev = ["pep8 (>=1.6.2)", "pyenchant (>=1.6.6)", "pytest (>=2.6.4)", "pytest-cov (>=1.8.1)", "sphinx (>=1.2.3)", "sphinx-rtd-theme (>=0.1.9)", "sphinxcontrib-spelling (>=2.1.2)", "tox (>=2.1.1)", "tox-gh-actions (>=2.2.0)", "twine (>=1.6.5)", "wheel"]
 twisted = ["twisted (>=20.3.0)", "zope.interface (>=5.2.0)"]
 
-[[package]]
-name = "types-python-dateutil"
-version = "2.8.19.14"
-description = "Typing stubs for python-dateutil"
-category = "main"
-optional = false
-python-versions = "*"
-files = [
-    {file = "types-python-dateutil-2.8.19.14.tar.gz", hash = "sha256:1f4f10ac98bb8b16ade9dbee3518d9ace017821d94b057a425b069f834737f4b"},
-    {file = "types_python_dateutil-2.8.19.14-py3-none-any.whl", hash = "sha256:f977b8de27787639986b4e28963263fd0e5158942b3ecef91b9335c130cb1ce9"},
-]
-
 [[package]]
 name = "typing-extensions"
 version = "4.8.0"
 description = "Backported and Experimental Type Hints for Python 3.8+"
-category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -3874,7 +3811,6 @@ files = [
 name = "tzdata"
 version = "2023.3"
 description = "Provider of IANA time zone data"
-category = "main"
 optional = false
 python-versions = ">=2"
 files = [
@@ -3886,7 +3822,6 @@ files = [
 name = "unidecode"
 version = "1.1.2"
 description = "ASCII transliterations of Unicode text"
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
@@ -3898,7 +3833,6 @@ files = [
 name = "uritemplate"
 version = "4.1.1"
 description = "Implementation of RFC 6570 URI Templates"
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -3910,7 +3844,6 @@ files = [
 name = "urllib3"
 version = "1.26.18"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
 files = [
@@ -3927,7 +3860,6 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
 name = "uwsgi"
 version = "2.0.22"
 description = "The uWSGI server"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -3938,7 +3870,6 @@ files = [
 name = "uwsgitop"
 version = "0.11"
 description = "uWSGI top-like interface"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -3949,7 +3880,6 @@ files = [
 name = "vine"
 version = "5.0.0"
 description = "Promises, promises, promises."
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -3961,7 +3891,6 @@ files = [
 name = "wcwidth"
 version = "0.2.8"
 description = "Measures the displayed width of unicode strings in a terminal"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -3973,7 +3902,6 @@ files = [
 name = "webencodings"
 version = "0.5.1"
 description = "Character encoding aliases for legacy web content"
-category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -3982,27 +3910,26 @@ files = [
 ]
 
 [[package]]
-name = "websocket-client"
-version = "1.6.4"
-description = "WebSocket client for Python with low level API options"
-category = "main"
+name = "werkzeug"
+version = "3.0.1"
+description = "The comprehensive WSGI web application library."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "websocket-client-1.6.4.tar.gz", hash = "sha256:b3324019b3c28572086c4a319f91d1dcd44e6e11cd340232978c684a7650d0df"},
-    {file = "websocket_client-1.6.4-py3-none-any.whl", hash = "sha256:084072e0a7f5f347ef2ac3d8698a5e0b4ffbfcab607628cadabc650fc9a83a24"},
+    {file = "werkzeug-3.0.1-py3-none-any.whl", hash = "sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10"},
+    {file = "werkzeug-3.0.1.tar.gz", hash = "sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc"},
 ]
 
+[package.dependencies]
+MarkupSafe = ">=2.1.1"
+
 [package.extras]
-docs = ["Sphinx (>=6.0)", "sphinx-rtd-theme (>=1.1.0)"]
-optional = ["python-socks", "wsaccel"]
-test = ["websockets"]
+watchdog = ["watchdog (>=2.3)"]
 
 [[package]]
 name = "wrapt"
 version = "1.15.0"
 description = "Module for decorators, wrappers and monkey patching."
-category = "main"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
 files = [
@@ -4087,7 +4014,6 @@ files = [
 name = "zope-interface"
 version = "6.1"
 description = "Interfaces for Python"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -4140,4 +4066,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.11"
-content-hash = "520a4dd93287162675b6fb3d8706fc0034342db00718f0345e115313b3bd5bde"
+content-hash = "afa925fb8ac88226c3b5c8be707883fd2ddaf484aa82a1d9f70c2ca07b6b25ce"
diff --git a/pyproject.toml b/pyproject.toml
index 6ef6a610da..76b92b06f4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,6 @@ authors = ["DesignSafe-CI <designsafe-ci@tacc.utexas.edu>"]
 
 [tool.poetry.dependencies]
 python = "^3.11"
-agavepy = "1.0.0a12"
 pytas = {git = "https://bitbucket.org/taccaci/pytas.git", tag = "v1.7.0"}
 attrdict = { git = "https://github.com/DesignSafe-CI/AttrDict", rev = "83b779ee82d5b0e33be695d398162b8f2430ff33" }
 Django = "^4.2"
@@ -75,6 +74,7 @@ django-select2 = "6.3.1"
 djangocms-admin-style = "~3.2.6"
 pydantic = "^2.5.0"
 networkx = "^3.2.1"
+tapipy = "^1.6.1"
 
 [build-system]
 requires = ["poetry-core>=1.0.0"]

From 2771edc96f9fc972fe344b623facc5d264129993 Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Wed, 28 Feb 2024 15:21:00 -0600
Subject: [PATCH 02/20] add docstrings to test methods

---
 designsafe/apps/auth/unit_test.py | 30 ++++++++++++++++--------------
 1 file changed, 16 insertions(+), 14 deletions(-)

diff --git a/designsafe/apps/auth/unit_test.py b/designsafe/apps/auth/unit_test.py
index ad86c7b049..7d84b24f8b 100644
--- a/designsafe/apps/auth/unit_test.py
+++ b/designsafe/apps/auth/unit_test.py
@@ -1,20 +1,23 @@
+
+import pytest
 from django.test import TransactionTestCase, override_settings
 from django.contrib.auth import get_user_model
 from mock import patch, MagicMock
-from portal.apps.auth.backends import TapisOAuthBackend
 from requests import Response
-from portal.apps.auth.views import launch_setup_checks
-import pytest
+from designsafe.apps.auth.backends import TapisOAuthBackend
+# from designsafe.apps.auth.views import launch_setup_checks
+from tapipy.tapis import TapisResult
+
 
 pytestmark = pytest.mark.django_db
 
 
-def test_launch_setup_checks(mocker, regular_user, settings):
-    mocker.patch("portal.apps.auth.views.new_user_setup_check")
-    mock_execute = mocker.patch("portal.apps.auth.views.execute_setup_steps")
-    regular_user.profile.setup_complete = False
-    launch_setup_checks(regular_user)
-    mock_execute.apply_async.assert_called_with(args=["username"])
+# def test_launch_setup_checks(mocker, regular_user, settings):
+#     mocker.patch("designsafe.apps.auth.views.new_user_setup_check")
+#     mock_execute = mocker.patch("designsafe.apps.auth.views.execute_setup_steps")
+#     regular_user.profile.setup_complete = False
+#     launch_setup_checks(regular_user)
+#     mock_execute.apply_async.assert_called_with(args=["username"])
 
 
 class TestTapisOAuthBackend(TransactionTestCase):
@@ -23,12 +26,12 @@ def setUp(self):
         self.backend = TapisOAuthBackend()
         self.mock_response = MagicMock(autospec=Response)
         self.mock_requests_patcher = patch(
-            "portal.apps.auth.backends.requests.get", return_value=self.mock_response
+            "designsafe.apps.auth.backends.Tapis.authenticator.get_userinfo", return_value=self.mock_response
         )
         self.mock_requests = self.mock_requests_patcher.start()
 
         self.mock_user_data_patcher = patch(
-            "portal.apps.auth.backends.get_user_data",
+            "designsafe.apps.auth.backends.get_user_data",
             return_value={
                 "username": "testuser",
                 "firstName": "test",
@@ -64,7 +67,7 @@ def test_bad_response_status(self):
 
     @override_settings(PORTAL_USER_ACCOUNT_SETUP_STEPS=[])
     def test_new_user(self):
-        # Test that a new user is created and returned
+        """Test that a new user is created and returned"""
         self.mock_response.json.return_value = {
             "status": "success",
             "result": {"username": "testuser"},
@@ -74,8 +77,7 @@ def test_new_user(self):
 
     @override_settings(PORTAL_USER_ACCOUNT_SETUP_STEPS=[])
     def test_update_existing_user(self):
-        # Test that an existing user's information is
-        # updated with from info from the Tapis backend response
+        """Test that an existing user's information is updated with from info from the Tapis backend response"""
 
         # Create a pre-existing user with the same username
         user = get_user_model().objects.create_user(

From 378c7cd123a9ec675ec90898721d4597f0f8cba2 Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Wed, 28 Feb 2024 15:21:21 -0600
Subject: [PATCH 03/20] update service account to use Tapis

---
 designsafe/apps/api/agave/__init__.py | 18 +++++++++---------
 1 file changed, 9 insertions(+), 9 deletions(-)

diff --git a/designsafe/apps/api/agave/__init__.py b/designsafe/apps/api/agave/__init__.py
index 8e05b4d037..bff5fddd9d 100644
--- a/designsafe/apps/api/agave/__init__.py
+++ b/designsafe/apps/api/agave/__init__.py
@@ -5,13 +5,11 @@
 """
 import logging
 import requests
-from agavepy.agave import Agave, load_resource
+from tapipy.tapis import Tapis
 from django.conf import settings
 
 logger = logging.getLogger(__name__)
 
-AGAVE_RESOURCES = load_resource(getattr(settings, 'AGAVE_TENANT_BASEURL'))
-
 def get_service_account_client():
     """Return service account agave client.
 
@@ -23,15 +21,17 @@ def get_service_account_client():
              There might be some issues because of permissionas,
              but it might be a bit safer."""
 
-    return Agave(api_server=settings.AGAVE_TENANT_BASEURL,
-                 token=settings.AGAVE_SUPER_TOKEN,
-                 resources=AGAVE_RESOURCES)
+    return Tapis(
+        base_url=settings.TAPIS_TENANT_BASEURL,
+        access_token=settings.TAPIS_ADMIN_JWT)
+
 
+# TODOV3: Remove sandbox account code
 def get_sandbox_service_account_client():
     """Return sandbox service account"""
-    return Agave(api_server=settings.AGAVE_SANDBOX_TENANT_BASEURL,
-                 token=settings.AGAVE_SANDBOX_SUPER_TOKEN,
-                 resources=AGAVE_RESOURCES)
+    return Tapis(
+        base_url=settings.TAPIS_TENANT_BASEURL,
+        access_token=settings.TAPIS_ADMIN_JWT)
 
 def service_account():
     """Return prod or sandbox service client depending on setting.AGAVE_USE_SANDBOX"""

From 73806dc18a01d88eb6f42f03229c81d9399cbf22 Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Wed, 28 Feb 2024 15:22:15 -0600
Subject: [PATCH 04/20] remove future imports

---
 designsafe/apps/api/search/searchmanager/cms.py    |  3 +--
 designsafe/apps/data/models/elasticsearch.py       | 11 +++++------
 designsafe/apps/projects/managers/base.py          |  3 +--
 designsafe/apps/projects/managers/datacite.py      |  1 -
 designsafe/apps/projects/models/elasticsearch.py   | 11 ++++-------
 designsafe/apps/workspace/models/elasticsearch.py  |  8 ++++----
 designsafe/libs/elasticsearch/docs.py              |  2 +-
 designsafe/libs/elasticsearch/docs/base.py         | 10 +++++-----
 designsafe/libs/elasticsearch/docs/files.py        | 14 +++++++-------
 .../libs/elasticsearch/docs/publication_legacy.py  | 10 +++++-----
 designsafe/libs/elasticsearch/docs/publications.py |  6 +++---
 designsafe/libs/elasticsearch/indices.py           |  2 +-
 designsafe/libs/elasticsearch/utils.py             |  8 ++++----
 13 files changed, 41 insertions(+), 48 deletions(-)

diff --git a/designsafe/apps/api/search/searchmanager/cms.py b/designsafe/apps/api/search/searchmanager/cms.py
index 515c5b18ad..410c85745d 100644
--- a/designsafe/apps/api/search/searchmanager/cms.py
+++ b/designsafe/apps/api/search/searchmanager/cms.py
@@ -5,12 +5,11 @@
 
 
 import logging
-from future.utils import python_2_unicode_compatible
 from elasticsearch_dsl import Q, Index
 from django.conf import settings
 from designsafe.apps.api.search.searchmanager.base import BaseSearchManager
 
-@python_2_unicode_compatible
+
 class CMSSearchManager(BaseSearchManager):
     """ Search manager handling CMS data.
     """
diff --git a/designsafe/apps/data/models/elasticsearch.py b/designsafe/apps/data/models/elasticsearch.py
index fa17ebbb26..c9eec0af5b 100644
--- a/designsafe/apps/data/models/elasticsearch.py
+++ b/designsafe/apps/data/models/elasticsearch.py
@@ -1,5 +1,4 @@
 
-from future.utils import python_2_unicode_compatible
 import logging
 import json
 import datetime
@@ -152,7 +151,7 @@ class Index:
     class Meta:
         dynamic = MetaField('strict')
 
-@python_2_unicode_compatible
+
 class IndexedPublication(Document):
     revision = Long()
     revisionText = Text(analyzer='english')
@@ -374,7 +373,7 @@ def from_id(cls, project_id, revision=None, using='default'):
         except Exception as e:
             raise e
         if res.hits.total.value > 1:
-            id_filter = Q('term', **{'_id': res[0].meta.id}) 
+            id_filter = Q('term', **{'_id': res[0].meta.id})
             # Delete all files indexed with the same system/path, except the first result
             delete_query = id_filter & ~id_filter
             cls.search(using=using).filter(delete_query).delete()
@@ -399,7 +398,7 @@ def max_revision(cls, project_id, using='default'):
     class Index:
         name = settings.ES_INDICES['publications']['alias']
 
-@python_2_unicode_compatible
+
 class IndexedCMSPage(Document):
     body = Text(analyzer='english')
     description = Text(analyzer='english')
@@ -418,7 +417,7 @@ class Index:
     class Meta:
         dynamic = MetaField('strict')
 
-@python_2_unicode_compatible
+
 class IndexedPublicationLegacy(Document):
     startDate = Date()
     endDate = Date()
@@ -513,7 +512,7 @@ def from_id(cls, project_id):
         except Exception as e:
             raise e
         if res.hits.total.value > 1:
-            id_filter = Q('term', **{'_id': res[0].meta.id}) 
+            id_filter = Q('term', **{'_id': res[0].meta.id})
             # Delete all files indexed with the same system/path, except the first result
             delete_query = id_filter & ~id_filter
             cls.search().filter(delete_query).delete()
diff --git a/designsafe/apps/projects/managers/base.py b/designsafe/apps/projects/managers/base.py
index 71dc5d72dd..e5074a7ad1 100644
--- a/designsafe/apps/projects/managers/base.py
+++ b/designsafe/apps/projects/managers/base.py
@@ -6,14 +6,13 @@
 from __future__ import unicode_literals, absolute_import
 import logging
 import json
-from future.utils import python_2_unicode_compatible
 from designsafe.apps.projects.models.utils import lookup_model as project_lookup_model
 
 
 LOG = logging.getLogger(__name__)
 
 
-@python_2_unicode_compatible
+
 class ProjectsManager(object):
     """Base projects manager."""
 
diff --git a/designsafe/apps/projects/managers/datacite.py b/designsafe/apps/projects/managers/datacite.py
index 444fb485ce..611bd18268 100644
--- a/designsafe/apps/projects/managers/datacite.py
+++ b/designsafe/apps/projects/managers/datacite.py
@@ -7,7 +7,6 @@
 from __future__ import unicode_literals, absolute_import
 import json
 import logging
-# from future.utils import python_2_unicode_compatible
 from django.conf import settings
 import requests
 from requests import HTTPError
diff --git a/designsafe/apps/projects/models/elasticsearch.py b/designsafe/apps/projects/models/elasticsearch.py
index 1a0e7faf51..1c77416e4c 100644
--- a/designsafe/apps/projects/models/elasticsearch.py
+++ b/designsafe/apps/projects/models/elasticsearch.py
@@ -1,5 +1,4 @@
 from __future__ import unicode_literals, absolute_import
-from future.utils import python_2_unicode_compatible
 import logging
 import json
 from django.conf import settings
@@ -17,7 +16,6 @@
 logger = logging.getLogger(__name__)
 #pylint: enable=invalid-name
 
-@python_2_unicode_compatible
 class IndexedProject(Document):
     uuid = Text(fields={'_exact': Keyword()})
     schemaId = Text(fields={'_exact': Keyword()})
@@ -59,11 +57,11 @@ class IndexedProject(Document):
                 'path': Text(fields={'_exact': Keyword()})
 
             }, multi=True),
-            
+
             # 'nhEventStart': Date(),
             # 'nhEventEnd': Date(),
             'nhTypes': Text(fields={'_exact': Keyword()}),
-            'nhType': Text(fields={'_exact': Keyword()}),   
+            'nhType': Text(fields={'_exact': Keyword()}),
             'nhTypeOther': Text(fields={'_exact': Keyword()}),
             'nhEvent': Text(fields={'_exact': Keyword()}),
             'nhLocation': Text(fields={'_exact': Keyword()}),
@@ -111,12 +109,11 @@ class IndexedProject(Document):
         })
 
     class Index:
-        name = settings.ES_INDICES['projects']['alias'] 
+        name = settings.ES_INDICES['projects']['alias']
 
     class Meta:
         dynamic = MetaField('strict')
 
-@python_2_unicode_compatible
 class IndexedEntity(Document):
     uuid = Text(fields={'_exact': Keyword()})
     schemaId = Text(fields={'_exact': Keyword()})
@@ -142,6 +139,6 @@ class IndexedEntity(Document):
 
     class Index:
         name = settings.ES_INDICES['project_entities']['alias']
-        
+
     class Meta:
         dynamic = MetaField('strict')
diff --git a/designsafe/apps/workspace/models/elasticsearch.py b/designsafe/apps/workspace/models/elasticsearch.py
index a802e15f9f..f590c0989e 100644
--- a/designsafe/apps/workspace/models/elasticsearch.py
+++ b/designsafe/apps/workspace/models/elasticsearch.py
@@ -1,5 +1,5 @@
 
-from future.utils import python_2_unicode_compatible
+
 import logging
 import json
 from django.conf import settings
@@ -17,7 +17,7 @@
 logger = logging.getLogger(__name__)
 #pylint: enable=invalid-name
 
-@python_2_unicode_compatible
+
 class IndexedApp(DocType):
     uuid = Text(fields={'_exact': Keyword()})
     schemaId = Text(fields={'_exact': Keyword()})
@@ -42,7 +42,7 @@ class IndexedApp(DocType):
         })
 
     class Index:
-        name = settings.ES_INDICES['project_entities']['alias'] 
-        
+        name = settings.ES_INDICES['project_entities']['alias']
+
     class Meta:
         dynamic = MetaField('strict')
diff --git a/designsafe/libs/elasticsearch/docs.py b/designsafe/libs/elasticsearch/docs.py
index 2dad2365d0..13e426ce41 100644
--- a/designsafe/libs/elasticsearch/docs.py
+++ b/designsafe/libs/elasticsearch/docs.py
@@ -3,7 +3,7 @@
    :synopsis: Wrapper classes for ES different doc types.
 """
 
-from future.utils import python_2_unicode_compatible
+
 import logging
 import json
 from django.conf import settings
diff --git a/designsafe/libs/elasticsearch/docs/base.py b/designsafe/libs/elasticsearch/docs/base.py
index c50d85f8f2..1b422da64a 100644
--- a/designsafe/libs/elasticsearch/docs/base.py
+++ b/designsafe/libs/elasticsearch/docs/base.py
@@ -1,8 +1,8 @@
 
-from future.utils import python_2_unicode_compatible
+
 import logging
 
-@python_2_unicode_compatible
+
 class BaseESResource(object):
     """Base class used to represent an Elastic Search resource.
 
@@ -14,7 +14,7 @@ class BaseESResource(object):
     """
     def __init__(self, wrapped_doc=None, **kwargs):
         self._wrap(wrapped_doc, **kwargs)
-        
+
     def to_dict(self):
         """Return wrapped doc as dict"""
         return self._wrapped.to_dict()
@@ -32,7 +32,7 @@ def __getattr__(self, name):
         """
         _wrapped = object.__getattribute__(self, '_wrapped')
         if _wrapped and hasattr(_wrapped, name):
-            return getattr(_wrapped, name) 
+            return getattr(_wrapped, name)
         else:
             return object.__getattribute__(self, name)
 
@@ -43,4 +43,4 @@ def __setattr__(self, name, value):
             return
         else:
             object.__setattr__(self, name, value)
-            return
\ No newline at end of file
+            return
diff --git a/designsafe/libs/elasticsearch/docs/files.py b/designsafe/libs/elasticsearch/docs/files.py
index ffdb04e193..c61e585cea 100644
--- a/designsafe/libs/elasticsearch/docs/files.py
+++ b/designsafe/libs/elasticsearch/docs/files.py
@@ -3,7 +3,7 @@
    :synopsis: Wrapper classes for ES ``files`` doc type.
 """
 
-from future.utils import python_2_unicode_compatible
+
 import logging
 import os
 from django.conf import settings
@@ -15,7 +15,7 @@
 logger = logging.getLogger(__name__)
 #pylint: enable=invalid-name
 
-@python_2_unicode_compatible
+
 class BaseESFile(BaseESResource):
     """Wrapper class for Elastic Search indexed file.
 
@@ -61,25 +61,25 @@ def _index_cls(cls, reindex):
 
     def children(self, limit=100):
         """
-        Yield all children (i.e. documents whose basePath matches self.path) by 
+        Yield all children (i.e. documents whose basePath matches self.path) by
         paginating with the search_after api.
 
         """
         res, search_after = self._index_cls(self._reindex).children(
                                                 self.username,
                                                 self.system,
-                                                self.path, 
+                                                self.path,
                                                 limit=limit)
         for doc in res:
                 yield BaseESFile(self.username, wrapped_doc=doc)
 
         while not len(res) < limit: # If the number or results doesn't match the limit, we're done paginating.
-            # Retrieve the sort key from the last element then use  
+            # Retrieve the sort key from the last element then use
             # search_after to get the next page of results
             res, search_after = self._index_cls(self._reindex).children(
                                                 self.username,
                                                 self.system,
-                                                self.path, 
+                                                self.path,
                                                 limit=limit,
                                                 search_after=search_after)
             for doc in res:
@@ -101,4 +101,4 @@ def delete(self):
             for child in children:
                 if child.path != self.path:
                     child.delete()
-        self._wrapped.delete()
\ No newline at end of file
+        self._wrapped.delete()
diff --git a/designsafe/libs/elasticsearch/docs/publication_legacy.py b/designsafe/libs/elasticsearch/docs/publication_legacy.py
index 1255100ff2..80185aa833 100644
--- a/designsafe/libs/elasticsearch/docs/publication_legacy.py
+++ b/designsafe/libs/elasticsearch/docs/publication_legacy.py
@@ -3,7 +3,7 @@
    :synopsis: Wrapper classes for ES ``files`` doc type.
 """
 
-from future.utils import python_2_unicode_compatible
+
 import logging
 import os
 import zipfile
@@ -17,7 +17,7 @@
 logger = logging.getLogger(__name__)
 #pylint: enable=invalid-name
 
-@python_2_unicode_compatible
+
 class BaseESPublicationLegacy(BaseESResource):
     """Wrapper class for Elastic Search indexed NEES publication.
 
@@ -67,10 +67,10 @@ def to_file(self):
         publication_dict = self.to_dict()
 
         project_dict = {}
-        for key in ['deleted', 'description', 'endDate', 'facility', 'name', 
+        for key in ['deleted', 'description', 'endDate', 'facility', 'name',
             'organization', 'pis', 'project', 'projectPath', 'publications',
             'startDate', 'system', 'title', 'sponsor']:
-            
+
             if key in publication_dict:
                 project_dict[key] = publication_dict[key]
 
@@ -97,5 +97,5 @@ def to_file(self):
                          'experiments': experiments,
                          'project': project_dict
                      }}
-                
+
         return dict_obj
diff --git a/designsafe/libs/elasticsearch/docs/publications.py b/designsafe/libs/elasticsearch/docs/publications.py
index 396eb1a32d..e822f11487 100644
--- a/designsafe/libs/elasticsearch/docs/publications.py
+++ b/designsafe/libs/elasticsearch/docs/publications.py
@@ -5,7 +5,7 @@
 """
 
 import logging
-from future.utils import python_2_unicode_compatible
+
 from designsafe.apps.data.models.elasticsearch import IndexedPublication
 from designsafe.libs.elasticsearch.docs.base import BaseESResource
 from designsafe.libs.elasticsearch.exceptions import DocumentNotFound
@@ -16,7 +16,7 @@
 # pylint: enable=invalid-name
 
 
-@python_2_unicode_compatible
+
 class BaseESPublication(BaseESResource):
     """Wrapper class for Elastic Search indexed publication.
 
@@ -157,7 +157,7 @@ def to_file(self):
             except:
                 dict_obj['meta']['piLabel'] = '({pi})'.format(pi=pi)
         return dict_obj
-    
+
 
     def entity_keys(self, publishable=False):
         """Type specific keys for publication"""
diff --git a/designsafe/libs/elasticsearch/indices.py b/designsafe/libs/elasticsearch/indices.py
index 8e51a1ba3d..2ae93abe64 100644
--- a/designsafe/libs/elasticsearch/indices.py
+++ b/designsafe/libs/elasticsearch/indices.py
@@ -3,7 +3,7 @@
    :synopsis: Wrapper classes for ES different doc types.
 """
 
-from future.utils import python_2_unicode_compatible
+
 import logging
 import json
 import six
diff --git a/designsafe/libs/elasticsearch/utils.py b/designsafe/libs/elasticsearch/utils.py
index c97412663f..a1838022b7 100644
--- a/designsafe/libs/elasticsearch/utils.py
+++ b/designsafe/libs/elasticsearch/utils.py
@@ -1,4 +1,4 @@
-from future.utils import python_2_unicode_compatible
+
 import urllib.request, urllib.parse, urllib.error
 from elasticsearch import Elasticsearch
 import logging
@@ -165,7 +165,7 @@ def iterate_level(client, system, path, limit=100):
             break
 
 # pylint: disable=too-many-locals
-@python_2_unicode_compatible
+
 def walk_levels(client, system, path, bottom_up=False, ignore_hidden=False, paths_to_ignore=None):
     """Walk a pth in an Agave storgae system.
 
@@ -298,14 +298,14 @@ def index_level(path, folders, files, systemId, reindex=False):
             logger.debug(children_paths)
             delete_recursive(hit.system, hit.path)
 
-@python_2_unicode_compatible
+
 def repair_path(name, path):
     if not path.endswith(name):
         path = path + '/' + name
     path = path.strip('/')
     return '/{path}'.format(path=path)
 
-@python_2_unicode_compatible
+
 def repair_paths(limit=1000):
     from designsafe.apps.data.models.elasticsearch import IndexedFile
     from elasticsearch import Elasticsearch

From e82d8d5b0b00713d01ae3c08f75d6625b2b42d6f Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Wed, 28 Feb 2024 15:28:23 -0600
Subject: [PATCH 05/20] uncomment agave imports

---
 designsafe/apps/accounts/tasks.py                | 16 ++++++++--------
 .../apps/api/notifications/views/webhooks.py     |  2 +-
 designsafe/apps/api/tests.py                     |  2 +-
 designsafe/apps/applications/views.py            |  4 ++--
 designsafe/apps/auth/tasks.py                    |  4 ++--
 designsafe/apps/data/models/agave/files.py       |  2 +-
 designsafe/apps/data/views/mixins.py             |  4 ++--
 designsafe/apps/notifications/views.py           |  2 +-
 designsafe/apps/search/views.py                  |  4 ++--
 designsafe/apps/workspace/tasks.py               |  2 +-
 designsafe/apps/workspace/views.py               |  2 +-
 11 files changed, 22 insertions(+), 22 deletions(-)

diff --git a/designsafe/apps/accounts/tasks.py b/designsafe/apps/accounts/tasks.py
index 040b5d10e8..1ed33b62f5 100644
--- a/designsafe/apps/accounts/tasks.py
+++ b/designsafe/apps/accounts/tasks.py
@@ -2,7 +2,7 @@
 import io
 import logging
 from django.conf import settings
-from agavepy.agave import Agave, AgaveException
+# from agavepy.agave import Agave, AgaveException
 from celery import shared_task
 from requests import HTTPError
 from django.contrib.auth import get_user_model
@@ -17,8 +17,8 @@
 @shared_task(default_retry_delay=1*30, max_retries=3)
 def create_report(username, list_name):
     """
-    This task runs a celery task that creates a report of all DesignSafe users. 
-    It pulls data from both TAS and the Django user model, writes them to a CSV, and 
+    This task runs a celery task that creates a report of all DesignSafe users.
+    It pulls data from both TAS and the Django user model, writes them to a CSV, and
     imports the CSV to the top-level of the user's My Data directory.
     """
 
@@ -47,20 +47,20 @@ def create_report(username, list_name):
             try:
                 user_profile = TASUser(username=user)
                 designsafe_user = get_user_model().objects.get(username=user)
-                
+
                 if hasattr(designsafe_user, "profile"):
 
                     #making nh_interests QuerySet into list
                     interests = designsafe_user.profile.nh_interests.all().values('description')
                     nh_interests = [interest['description'] for interest in interests]
-                    
+
                     #making research_activities QuerySet into list
                     activities = designsafe_user.profile.research_activities.all().values('description')
                     research_activities = [activity['description'] for activity in activities]
 
                     # order of items as required by user
                     writer.writerow([user_profile.lastName if user_profile.lastName else user_profile.lastName,
-                        user_profile.firstName if user_profile.firstName else user_profile.firstName, 
+                        user_profile.firstName if user_profile.firstName else user_profile.firstName,
                         user_profile.email,
                         user_profile.phone,
                         user_profile.institution,
@@ -92,10 +92,10 @@ def create_report(username, list_name):
            systemId=settings.AGAVE_STORAGE_SYSTEM,
            fileToUpload=csv_file
            )
-       
+
         csv_file.close()
 
     except (HTTPError, AgaveException):
         logger.exception('Failed to create user report.',
                          extra={'user': username,
-                                'systemId': settings.AGAVE_STORAGE_SYSTEM})
\ No newline at end of file
+                                'systemId': settings.AGAVE_STORAGE_SYSTEM})
diff --git a/designsafe/apps/api/notifications/views/webhooks.py b/designsafe/apps/api/notifications/views/webhooks.py
index 3dd9470757..3f922d98fe 100644
--- a/designsafe/apps/api/notifications/views/webhooks.py
+++ b/designsafe/apps/api/notifications/views/webhooks.py
@@ -11,7 +11,7 @@
 
 from celery import shared_task
 from requests import ConnectionError, HTTPError
-from agavepy.agave import Agave, AgaveException
+# from agavepy.agave import Agave, AgaveException
 
 from designsafe.apps.api.notifications.models import Notification
 
diff --git a/designsafe/apps/api/tests.py b/designsafe/apps/api/tests.py
index 01df8f33fb..38a7168eb7 100644
--- a/designsafe/apps/api/tests.py
+++ b/designsafe/apps/api/tests.py
@@ -4,7 +4,7 @@
 
 from designsafe.apps.projects.models.agave.experimental import ExperimentalProject, ModelConfig, FileModel
 
-from agavepy.agave import Agave
+# from agavepy.agave import Agave
 import mock
 import json
 
diff --git a/designsafe/apps/applications/views.py b/designsafe/apps/applications/views.py
index c82237bf6b..455e30d07d 100644
--- a/designsafe/apps/applications/views.py
+++ b/designsafe/apps/applications/views.py
@@ -1,4 +1,4 @@
-from agavepy.agave import Agave, AgaveException, load_resource
+# from agavepy.agave import Agave, AgaveException, load_resource
 from designsafe.apps.licenses.models import LICENSE_TYPES, get_license_info
 from designsafe.apps.notifications.views import get_number_unread_notifications
 from designsafe.libs.common.decorators import profile as profile_fn
@@ -17,7 +17,7 @@
 logger = logging.getLogger(__name__)
 metrics = logging.getLogger('metrics')
 
-AGAVE_RESOURCES = load_resource(getattr(settings, 'AGAVE_TENANT_BASEURL'))
+# AGAVE_RESOURCES = load_resource(getattr(settings, 'AGAVE_TENANT_BASEURL'))
 
 
 @login_required
diff --git a/designsafe/apps/auth/tasks.py b/designsafe/apps/auth/tasks.py
index 99f07e4738..1d32771a63 100644
--- a/designsafe/apps/auth/tasks.py
+++ b/designsafe/apps/auth/tasks.py
@@ -2,7 +2,7 @@
 import requests
 from django.conf import settings
 from django.core.mail import send_mail
-from agavepy.agave import Agave, AgaveException
+# from agavepy.agave import Agave, AgaveException
 from designsafe.apps.api.tasks import agave_indexer
 from designsafe.apps.api.notifications.models import Notification
 from celery import shared_task
@@ -97,7 +97,7 @@ def new_user_alert(username):
                                                     'Name: ' + user.first_name + ' ' + user.last_name + '\n' +
                                                     'Id: ' + str(user.id) + '\n',
               settings.DEFAULT_FROM_EMAIL, settings.NEW_ACCOUNT_ALERT_EMAILS.split(','),)
-    
+
     tram_headers = {"tram-services-key": settings.TRAM_SERVICES_KEY}
     tram_body = {"project_id": settings.TRAM_PROJECT_ID,
                  "email": user.email}
diff --git a/designsafe/apps/data/models/agave/files.py b/designsafe/apps/data/models/agave/files.py
index 235fb834f0..d6c6abde9a 100644
--- a/designsafe/apps/data/models/agave/files.py
+++ b/designsafe/apps/data/models/agave/files.py
@@ -9,7 +9,7 @@
 from designsafe.apps.data.models.agave.base import BaseAgaveResource
 from designsafe.apps.data.models.agave.metadata import BaseMetadataResource, BaseMetadataPermissionResource
 from designsafe.apps.data.models.agave.systems import roles as system_roles_list
-from agavepy.agave import AgaveException
+# from agavepy.agave import AgaveException
 #  from agavepy.async import AgaveAsyncResponse, TimeoutError, Error
 from designsafe.apps.api import tasks
 
diff --git a/designsafe/apps/data/views/mixins.py b/designsafe/apps/data/views/mixins.py
index b8dc484dbf..6cd5322c0d 100644
--- a/designsafe/apps/data/views/mixins.py
+++ b/designsafe/apps/data/views/mixins.py
@@ -3,7 +3,7 @@
 from django.contrib.auth.decorators import login_required
 from django.core.serializers.json import DjangoJSONEncoder
 from django.http import HttpResponse, StreamingHttpResponse
-from agavepy.agave import Agave, AgaveException
+# from agavepy.agave import Agave, AgaveException
 from django.contrib.auth import get_user_model
 from django.conf import settings
 import datetime
@@ -17,7 +17,7 @@
 
 class JSONResponseMixin(object):
     """
-    View mixin to return a JSON response. 
+    View mixin to return a JSON response.
     We're building one so we can put any extra code in here.
     """
 
diff --git a/designsafe/apps/notifications/views.py b/designsafe/apps/notifications/views.py
index e08a20ded0..380992f87f 100644
--- a/designsafe/apps/notifications/views.py
+++ b/designsafe/apps/notifications/views.py
@@ -7,7 +7,7 @@
 from django.views.decorators.csrf import csrf_exempt
 from django.views.decorators.http import require_POST
 from django.contrib.auth import get_user_model
-from agavepy.agave import AgaveException
+# from agavepy.agave import AgaveException
 from requests import HTTPError
 from designsafe.apps.api.notifications.models import Notification
 from designsafe.apps.notifications.models import Notification as LegacyNotification
diff --git a/designsafe/apps/search/views.py b/designsafe/apps/search/views.py
index 1ceb1e1eb8..e020284299 100644
--- a/designsafe/apps/search/views.py
+++ b/designsafe/apps/search/views.py
@@ -3,7 +3,7 @@
 from django.shortcuts import render
 from django.views.decorators.csrf import csrf_exempt
 from django.views.decorators.http import require_POST
-from agavepy.agave import AgaveException
+# from agavepy.agave import AgaveException
 from requests import HTTPError
 from django.contrib.auth import get_user_model
 
@@ -18,4 +18,4 @@
 
 def index(request):
     logger.debug('search index')
-    return render(request, 'designsafe/apps/search/index.html')
\ No newline at end of file
+    return render(request, 'designsafe/apps/search/index.html')
diff --git a/designsafe/apps/workspace/tasks.py b/designsafe/apps/workspace/tasks.py
index f3b3319eaf..c1644c75fa 100644
--- a/designsafe/apps/workspace/tasks.py
+++ b/designsafe/apps/workspace/tasks.py
@@ -9,7 +9,7 @@
 from designsafe.apps.api.agave import impersonate_service_account
 from designsafe.apps.api.notifications.models import Notification
 from django.db import transaction
-from agavepy.agave import AgaveException
+# from agavepy.agave import AgaveException
 from celery import shared_task
 from requests import ConnectionError, HTTPError
 import logging
diff --git a/designsafe/apps/workspace/views.py b/designsafe/apps/workspace/views.py
index 20325b89cf..0b6b09dd11 100644
--- a/designsafe/apps/workspace/views.py
+++ b/designsafe/apps/workspace/views.py
@@ -1,4 +1,4 @@
-from agavepy.agave import AgaveException, Agave
+# from agavepy.agave import AgaveException, Agave
 from django.shortcuts import render, redirect
 from django.conf import settings
 from django.contrib.auth.decorators import login_required

From 3f53b2702a13b012408f5cb3744c3d85e53d9f23 Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Wed, 28 Feb 2024 15:29:15 -0600
Subject: [PATCH 06/20] add TapisOAuthToken model migration

---
 ..._tapisoauthtoken_delete_agaveoauthtoken.py | 30 +++++++++++++++++++
 1 file changed, 30 insertions(+)
 create mode 100644 designsafe/apps/auth/migrations/0003_tapisoauthtoken_delete_agaveoauthtoken.py

diff --git a/designsafe/apps/auth/migrations/0003_tapisoauthtoken_delete_agaveoauthtoken.py b/designsafe/apps/auth/migrations/0003_tapisoauthtoken_delete_agaveoauthtoken.py
new file mode 100644
index 0000000000..18ffb59c36
--- /dev/null
+++ b/designsafe/apps/auth/migrations/0003_tapisoauthtoken_delete_agaveoauthtoken.py
@@ -0,0 +1,30 @@
+# Generated by Django 4.2.6 on 2024-02-28 21:26
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('designsafe_auth', '0002_auto_20160209_0427'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='TapisOAuthToken',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('access_token', models.CharField(max_length=2048)),
+                ('refresh_token', models.CharField(max_length=2048)),
+                ('expires_in', models.BigIntegerField()),
+                ('created', models.BigIntegerField()),
+                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='tapis_oauth', to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.DeleteModel(
+            name='AgaveOAuthToken',
+        ),
+    ]

From f1d29ffbbb378ba9680a1e83b5e99d7769b8bbbb Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Wed, 28 Feb 2024 15:43:04 -0600
Subject: [PATCH 07/20] fix bugs

---
 .docs/source/designsafe.apps.auth.rst  |  8 --------
 designsafe/apps/auth/backends.py       | 10 +++++-----
 designsafe/apps/auth/urls.py           |  3 ++-
 designsafe/settings/common_settings.py |  3 +--
 designsafe/settings/test_settings.py   |  1 -
 5 files changed, 8 insertions(+), 17 deletions(-)

diff --git a/.docs/source/designsafe.apps.auth.rst b/.docs/source/designsafe.apps.auth.rst
index 0d872b14a2..ccdbdd0f2a 100644
--- a/.docs/source/designsafe.apps.auth.rst
+++ b/.docs/source/designsafe.apps.auth.rst
@@ -28,14 +28,6 @@ designsafe.apps.auth.backends module
     :undoc-members:
     :show-inheritance:
 
-designsafe.apps.auth.context_processors module
-----------------------------------------------
-
-.. automodule:: designsafe.apps.auth.context_processors
-    :members:
-    :undoc-members:
-    :show-inheritance:
-
 designsafe.apps.auth.middleware module
 --------------------------------------
 
diff --git a/designsafe/apps/auth/backends.py b/designsafe/apps/auth/backends.py
index 8e933c8966..aef3c89fa9 100644
--- a/designsafe/apps/auth/backends.py
+++ b/designsafe/apps/auth/backends.py
@@ -8,8 +8,8 @@
 from tapipy.tapis import Tapis
 from tapipy.errors import BaseTapyException
 from designsafe.apps.accounts.models import DesignSafeProfile, NotificationPreferences
-from designsafe.apps.users.utils import get_user_data
-from desingsafe.apps.auth.models.TapisOAuthToken import get_masked_token
+from designsafe.apps.api.users.utils import get_user_data
+from designsafe.apps.auth.models import TapisOAuthToken
 from django.contrib.auth.signals import user_logged_out
 from django.contrib import messages
 from django.core.exceptions import ValidationError
@@ -36,7 +36,7 @@ def on_user_logged_out(sender, request, user, **kwargs):
         login_provider = "TACC"
 
     logger.info(
-        "Revoking tapis token: %s", get_masked_token(user.tapis_oauth.access_token)
+        "Revoking tapis token: %s", TapisOAuthToken().get_masked_token(user.tapis_oauth.access_token)
     )
     backend = TapisOAuthBackend()
     TapisOAuthBackend.revoke(backend, user.tapis_oauth.access_token)
@@ -139,7 +139,7 @@ def authenticate(self, *args, **kwargs):
             token = kwargs["token"]
 
             logger.info(
-                'Attempting login via Tapis with token "%s"' % get_masked_token(token)
+                'Attempting login via Tapis with token "%s"' % TapisOAuthToken().get_masked_token(token)
             )
             client = Tapis(base_url=settings.TAPIS_TENANT_BASEURL, access_token=token)
 
@@ -188,7 +188,7 @@ def authenticate(self, *args, **kwargs):
 
     def revoke(self, token):
         self.logger.info(
-            "Attempting to revoke Tapis token %s" % get_masked_token(token)
+            "Attempting to revoke Tapis token %s" % TapisOAuthToken().get_masked_token(token)
         )
 
         client = Tapis(base_url=settings.TAPIS_TENANT_BASEURL, access_token=token)
diff --git a/designsafe/apps/auth/urls.py b/designsafe/apps/auth/urls.py
index cc6a5ef832..a14d41f958 100644
--- a/designsafe/apps/auth/urls.py
+++ b/designsafe/apps/auth/urls.py
@@ -6,8 +6,9 @@
 from django.urls import path
 from designsafe.apps.auth import views
 
-app_name = "portal_auth"
+app_name = "designsafe_auth"
 urlpatterns = [
+    path('login/', views.tapis_oauth, name="login"),
     path("logged-out/", views.logged_out, name="logout"),
     path("tapis/", views.tapis_oauth, name="tapis_oauth"),
     path("tapis/callback/", views.tapis_oauth_callback, name="tapis_oauth_callback"),
diff --git a/designsafe/settings/common_settings.py b/designsafe/settings/common_settings.py
index ec2917098d..b40189154e 100644
--- a/designsafe/settings/common_settings.py
+++ b/designsafe/settings/common_settings.py
@@ -115,7 +115,7 @@
 )
 
 AUTHENTICATION_BACKENDS = (
-    'designsafe.apps.auth.backends.AgaveOAuthBackend',
+    'designsafe.apps.auth.backends.TapisOAuthBackend',
     'designsafe.apps.auth.backends.TASBackend',
     'django.contrib.auth.backends.ModelBackend',
 )
@@ -177,7 +177,6 @@
                 'designsafe.context_processors.site_verification',
                 'designsafe.context_processors.debug',
                 'designsafe.context_processors.messages',
-                'designsafe.apps.auth.context_processors.auth',
                 'designsafe.apps.cms_plugins.context_processors.cms_section',
             ],
         },
diff --git a/designsafe/settings/test_settings.py b/designsafe/settings/test_settings.py
index 496e2d8fce..373b5a32d1 100644
--- a/designsafe/settings/test_settings.py
+++ b/designsafe/settings/test_settings.py
@@ -167,7 +167,6 @@
                 'designsafe.context_processors.site_verification',
                 'designsafe.context_processors.debug',
                 'designsafe.context_processors.messages',
-                'designsafe.apps.auth.context_processors.auth',
                 'designsafe.apps.cms_plugins.context_processors.cms_section',
             ],
         },

From f2a988d7d6b4ab874336c114788919255754e184 Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Wed, 28 Feb 2024 15:48:06 -0600
Subject: [PATCH 08/20] remove attrdict for agavepy

---
 poetry.lock    | 20 +-------------------
 pyproject.toml |  1 -
 2 files changed, 1 insertion(+), 20 deletions(-)

diff --git a/poetry.lock b/poetry.lock
index 57c63b4fdf..02e72643b0 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -104,24 +104,6 @@ files = [
     {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"},
 ]
 
-[[package]]
-name = "attrdict"
-version = "2.0.1"
-description = "A dict with attribute-style access"
-optional = false
-python-versions = "*"
-files = []
-develop = false
-
-[package.dependencies]
-six = "*"
-
-[package.source]
-type = "git"
-url = "https://github.com/DesignSafe-CI/AttrDict"
-reference = "83b779ee82d5b0e33be695d398162b8f2430ff33"
-resolved_reference = "83b779ee82d5b0e33be695d398162b8f2430ff33"
-
 [[package]]
 name = "attrs"
 version = "23.1.0"
@@ -4066,4 +4048,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.11"
-content-hash = "afa925fb8ac88226c3b5c8be707883fd2ddaf484aa82a1d9f70c2ca07b6b25ce"
+content-hash = "307e3b629eb7cf6da0b854f396084fa0ff4feda5c1d8b34666d32f4b71ffece2"
diff --git a/pyproject.toml b/pyproject.toml
index 76b92b06f4..7bb5da495b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,6 @@ authors = ["DesignSafe-CI <designsafe-ci@tacc.utexas.edu>"]
 [tool.poetry.dependencies]
 python = "^3.11"
 pytas = {git = "https://bitbucket.org/taccaci/pytas.git", tag = "v1.7.0"}
-attrdict = { git = "https://github.com/DesignSafe-CI/AttrDict", rev = "83b779ee82d5b0e33be695d398162b8f2430ff33" }
 Django = "^4.2"
 daphne = "^4.0.0"
 debugpy = "^1.8.0"

From 41ac9a10165fd572337ee6369b569a6128147a2d Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Wed, 28 Feb 2024 16:40:39 -0600
Subject: [PATCH 09/20] pylint: ignore test files

---
 .pylintrc | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.pylintrc b/.pylintrc
index cbf324a0ef..ac05af4669 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -52,7 +52,7 @@ ignore=CVS,tests.py
 # ignore-list. The regex matches against paths and can be in Posix or Windows
 # format. Because '\\' represents the directory delimiter on Windows systems,
 # it can't be used as an escape character.
-ignore-paths=^.*migrations/.*$,^.*_tests/.*$
+ignore-paths=^.*migrations/.*$,^.*_tests/.*$,^.*_unit_test.*$
 
 # Files or directories matching the regular expression patterns are skipped.
 # The regex matches against base names, not paths. The default value ignores

From 3d32c937d94effc99045f3af9b12e0806bd0f1c9 Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Thu, 29 Feb 2024 13:59:06 -0600
Subject: [PATCH 10/20] fix ignore unit test regex

---
 .pylintrc | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.pylintrc b/.pylintrc
index ac05af4669..492dab158b 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -52,7 +52,7 @@ ignore=CVS,tests.py
 # ignore-list. The regex matches against paths and can be in Posix or Windows
 # format. Because '\\' represents the directory delimiter on Windows systems,
 # it can't be used as an escape character.
-ignore-paths=^.*migrations/.*$,^.*_tests/.*$,^.*_unit_test.*$
+ignore-paths=^.*migrations/.*$,^.*_tests/.*$,^.*unit_test.*$
 
 # Files or directories matching the regular expression patterns are skipped.
 # The regex matches against base names, not paths. The default value ignores

From 4783ced742fb96bd2e61cf15cdbb2cb427f6c334 Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Thu, 29 Feb 2024 14:43:26 -0600
Subject: [PATCH 11/20] fix login url

---
 designsafe/apps/auth/urls.py | 2 +-
 designsafe/urls.py           | 4 +++-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/designsafe/apps/auth/urls.py b/designsafe/apps/auth/urls.py
index a14d41f958..678ee5642f 100644
--- a/designsafe/apps/auth/urls.py
+++ b/designsafe/apps/auth/urls.py
@@ -8,7 +8,7 @@
 
 app_name = "designsafe_auth"
 urlpatterns = [
-    path('login/', views.tapis_oauth, name="login"),
+    path('/', views.tapis_oauth, name="login"),
     path("logged-out/", views.logged_out, name="logout"),
     path("tapis/", views.tapis_oauth, name="tapis_oauth"),
     path("tapis/callback/", views.tapis_oauth_callback, name="tapis_oauth_callback"),
diff --git a/designsafe/urls.py b/designsafe/urls.py
index c4918d6639..e2086c8de0 100644
--- a/designsafe/urls.py
+++ b/designsafe/urls.py
@@ -28,7 +28,8 @@
 from django.contrib import admin
 from django.views.generic import RedirectView, TemplateView
 from django.urls import reverse, path
-from django.http import HttpResponse, HttpResponseRedirect
+from django.http import HttpResponseRedirect
+from designsafe.apps.auth.views import tapis_oauth as login
 from django.contrib.auth.views import LogoutView as des_logout
 from designsafe.views import project_version as des_version, redirect_old_nees
 from impersonate import views as impersonate_views
@@ -147,6 +148,7 @@
     # auth
     url(r'^auth/', include(('designsafe.apps.auth.urls', 'designsafe.apps.auth'), namespace='designsafe_auth')),
 
+    url(r'^login/$', login, name='login'),
     url(r'^logout/$', des_logout.as_view(), name='logout'),
 
     # help

From e296436af7baa9cca685eec1cc3da07b4a24e217 Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Thu, 29 Feb 2024 14:52:09 -0600
Subject: [PATCH 12/20] formatting; remove AgaveOAuthToken references

---
 designsafe/apps/api/notifications/tests.py    | 136 +++++++++++-------
 ..._tapisoauthtoken_delete_agaveoauthtoken.py |  33 +++--
 designsafe/apps/auth/unit_test.py             |   7 +-
 designsafe/apps/auth/views_unit_test.py       |   1 +
 designsafe/apps/workspace/tests.py            |  71 ++++-----
 5 files changed, 150 insertions(+), 98 deletions(-)

diff --git a/designsafe/apps/api/notifications/tests.py b/designsafe/apps/api/notifications/tests.py
index cf93dfc8fc..cf692feaba 100644
--- a/designsafe/apps/api/notifications/tests.py
+++ b/designsafe/apps/api/notifications/tests.py
@@ -1,15 +1,12 @@
-import requests
 import json
 import os
 from django.test import TestCase
 from django.test import Client
 from django.contrib.auth import get_user_model
 from django.db.models.signals import post_save
-from mock import Mock, patch
-from designsafe.apps.auth.models import AgaveOAuthToken
+from mock import patch
 from urllib.parse import urlencode
 from unittest import skip
-from django.dispatch import receiver
 from django.urls import reverse
 from designsafe.apps.api.notifications.models import Notification
 from .receivers import send_notification_ws
@@ -19,42 +16,41 @@
 
 logger = logging.getLogger(__name__)
 
-FILEDIR_PENDING = os.path.join(os.path.dirname(__file__), './json/pending.json')
-FILEDIR_SUBMITTING = os.path.join(os.path.dirname(__file__), './json/submitting.json')
-FILEDIR_PENDING2 = os.path.join(os.path.dirname(__file__), './json/pending2.json')
+FILEDIR_PENDING = os.path.join(os.path.dirname(__file__), "./json/pending.json")
+FILEDIR_SUBMITTING = os.path.join(os.path.dirname(__file__), "./json/submitting.json")
+FILEDIR_PENDING2 = os.path.join(os.path.dirname(__file__), "./json/pending2.json")
 
 webhook_body_pending = json.dumps(json.load(open(FILEDIR_PENDING)))
 webhook_body_pending2 = json.dumps(json.load(open(FILEDIR_PENDING2)))
 webhook_body_submitting = json.dumps(json.load(open(FILEDIR_SUBMITTING)))
 
 
-
 # Create your tests here.
 @skip("Need to mock websocket call to redis")
 class NotificationsTestCase(TestCase):
-    fixtures = ['user-data.json', 'agave-oauth-token-data.json']
+    fixtures = ["user-data.json", "agave-oauth-token-data.json"]
 
     def setUp(self):
-        self.wh_url = reverse('designsafe_api:jobs_wh_handler')
+        self.wh_url = reverse("designsafe_api:jobs_wh_handler")
         user = get_user_model().objects.get(pk=2)
-        user.set_password('password')
+        user.set_password("password")
         user.save()
         self.user = user
         self.client = Client()
 
-        with open('designsafe/apps/api/fixtures/agave-model-config-meta.json') as f:
+        with open("designsafe/apps/api/fixtures/agave-model-config-meta.json") as f:
             model_config_meta = json.load(f)
         self.model_config_meta = model_config_meta
 
-        with open('designsafe/apps/api/fixtures/agave-file-meta.json') as f:
+        with open("designsafe/apps/api/fixtures/agave-file-meta.json") as f:
             file_meta = json.load(f)
         self.file_meta = file_meta
 
-        with open('designsafe/apps/api/fixtures/agave-experiment-meta.json') as f:
+        with open("designsafe/apps/api/fixtures/agave-experiment-meta.json") as f:
             experiment_meta = json.load(f)
         self.experiment_meta = experiment_meta
 
-        with open('designsafe/apps/api/fixtures/agave-project-meta.json') as f:
+        with open("designsafe/apps/api/fixtures/agave-project-meta.json") as f:
             project_meta = json.load(f)
         self.project_meta = project_meta
 
@@ -62,42 +58,58 @@ def test_current_user_is_ds_user(self):
         """
         just making sure the db setup worked.
         """
-        self.assertEqual(self.user.username, 'ds_user')
+        self.assertEqual(self.user.username, "ds_user")
 
     def test_submitting_webhook_returns_200_and_creates_notification(self):
-        r = self.client.post(self.wh_url, webhook_body_pending, content_type='application/json')
+        r = self.client.post(
+            self.wh_url, webhook_body_pending, content_type="application/json"
+        )
         self.assertEqual(r.status_code, 200)
         n = Notification.objects.last()
-        status_from_notification = n.to_dict()['extra']['status']
-        self.assertEqual(status_from_notification, 'PENDING')
+        status_from_notification = n.to_dict()["extra"]["status"]
+        self.assertEqual(status_from_notification, "PENDING")
 
     def test_2_webhooks_same_status_same_jobId_should_give_1_notification(self):
-        r = self.client.post(self.wh_url, webhook_body_pending, content_type='application/json')
-
-        #assert that sending the same status twice doesn't trigger a second notification.
-        r2 = self.client.post(self.wh_url, webhook_body_pending, content_type='application/json')
+        r = self.client.post(
+            self.wh_url, webhook_body_pending, content_type="application/json"
+        )
+
+        # assert that sending the same status twice doesn't trigger a second notification.
+        r2 = self.client.post(
+            self.wh_url, webhook_body_pending, content_type="application/json"
+        )
         self.assertEqual(Notification.objects.count(), 1)
 
     def test_2_webhooks_different_status_same_jobId_should_give_2_notifications(self):
-        r1 = self.client.post(self.wh_url, webhook_body_pending, content_type='application/json')
+        r1 = self.client.post(
+            self.wh_url, webhook_body_pending, content_type="application/json"
+        )
 
-        r2 = self.client.post(self.wh_url, webhook_body_submitting, content_type='application/json')
+        r2 = self.client.post(
+            self.wh_url, webhook_body_submitting, content_type="application/json"
+        )
         self.assertEqual(Notification.objects.count(), 2)
 
     def test_2_webhooks_same_status_different_jobId_should_give_2_notifications(self):
 
-        r = self.client.post(self.wh_url, webhook_body_pending, content_type='application/json')
-        r2 = self.client.post(self.wh_url, webhook_body_pending2, content_type='application/json')
+        r = self.client.post(
+            self.wh_url, webhook_body_pending, content_type="application/json"
+        )
+        r2 = self.client.post(
+            self.wh_url, webhook_body_pending2, content_type="application/json"
+        )
 
         self.assertEqual(Notification.objects.count(), 2)
 
 
 class TestWebhookViews(TestCase):
-    fixtures = ['user-data', 'agave-oauth-token-data']
+    fixtures = ["user-data", "agave-oauth-token-data"]
 
     def setUp(self):
-        self.wh_url = reverse('designsafe_api:jobs_wh_handler')
-        self.mock_agave_patcher = patch('designsafe.apps.auth.models.AgaveOAuthToken.client', autospec=True)
+        self.wh_url = reverse("designsafe_api:jobs_wh_handler")
+        self.mock_agave_patcher = patch(
+            "designsafe.apps.auth.models.TapisOAuthToken.client", autospec=True
+        )
         self.mock_agave = self.mock_agave_patcher.start()
 
         self.client.force_login(get_user_model().objects.get(username="ds_user"))
@@ -109,7 +121,7 @@ def setUp(self):
             "port": "1234",
             "address": "http://designsafe-exec-01.tacc.utexas.edu:1234",
             "job_uuid": "3373312947011719656-242ac11b-0001-007",
-            "owner": "ds_user"
+            "owner": "ds_user",
         }
 
         self.vnc_event = {
@@ -117,7 +129,7 @@ def setUp(self):
             "host": "stampede2.tacc.utexas.edu",
             "port": "2234",
             "password": "3373312947011719656-242ac11b-0001-007",
-            "owner": "ds_user"
+            "owner": "ds_user",
         }
 
         self.agave_job_running = {"owner": "ds_user", "status": "RUNNING"}
@@ -125,62 +137,80 @@ def setUp(self):
 
     def tearDown(self):
         self.mock_agave_patcher.stop()
-        post_save.connect(send_notification_ws, sender=Notification, dispatch_uid="notification_msg")
+        post_save.connect(
+            send_notification_ws, sender=Notification, dispatch_uid="notification_msg"
+        )
 
     def test_unsupported_event_type(self):
-        response = self.client.post(reverse('interactive_wh_handler'),
-                                    urlencode({'event_type': 'DUMMY'}),
-                                    content_type='application/x-www-form-urlencoded')
+        response = self.client.post(
+            reverse("interactive_wh_handler"),
+            urlencode({"event_type": "DUMMY"}),
+            content_type="application/x-www-form-urlencoded",
+        )
         self.assertTrue(response.status_code == 400)
 
     def test_webhook_job_post(self):
-        job_event = json.load(open(os.path.join(os.path.dirname(__file__), 'json/submitting.json')))
+        job_event = json.load(
+            open(os.path.join(os.path.dirname(__file__), "json/submitting.json"))
+        )
 
-        response = self.client.post(self.wh_url, json.dumps(job_event), content_type='application/json')
+        response = self.client.post(
+            self.wh_url, json.dumps(job_event), content_type="application/json"
+        )
         self.assertEqual(response.status_code, 200)
 
         n = Notification.objects.last()
-        n_status = n.to_dict()['extra']['status']
-        self.assertEqual(n_status, job_event['status'])
+        n_status = n.to_dict()["extra"]["status"]
+        self.assertEqual(n_status, job_event["status"])
 
     def test_webhook_vnc_post(self):
         self.mock_agave.jobs.get.return_value = self.agave_job_running
 
         link_from_event = "https://tap.tacc.utexas.edu/noVNC/?host=stampede2.tacc.utexas.edu&port=2234&autoconnect=true&encrypt=true&resize=scale&password=3373312947011719656-242ac11b-0001-007"
 
-        response = self.client.post(reverse('interactive_wh_handler'), urlencode(self.vnc_event), content_type='application/x-www-form-urlencoded')
+        response = self.client.post(
+            reverse("interactive_wh_handler"),
+            urlencode(self.vnc_event),
+            content_type="application/x-www-form-urlencoded",
+        )
 
         self.assertEqual(response.status_code, 200)
         self.assertTrue(self.mock_agave.meta.addMetadata.called)
         self.assertEqual(Notification.objects.count(), 1)
 
         n = Notification.objects.last()
-        action_link = n.to_dict()['action_link']
+        action_link = n.to_dict()["action_link"]
         self.assertEqual(action_link, link_from_event)
-        self.assertEqual(n.operation, 'web_link')
+        self.assertEqual(n.operation, "web_link")
 
     def test_webhook_web_post(self):
         self.mock_agave.jobs.get.return_value = self.agave_job_running
 
         link_from_event = "http://designsafe-exec-01.tacc.utexas.edu:1234"
 
-        response = self.client.post(reverse('interactive_wh_handler'), urlencode(self.web_event), content_type='application/x-www-form-urlencoded')
+        response = self.client.post(
+            reverse("interactive_wh_handler"),
+            urlencode(self.web_event),
+            content_type="application/x-www-form-urlencoded",
+        )
 
         self.assertEqual(response.status_code, 200)
         self.assertTrue(self.mock_agave.meta.addMetadata.called)
         self.assertEqual(Notification.objects.count(), 1)
 
         n = Notification.objects.last()
-        action_link = n.to_dict()['action_link']
+        action_link = n.to_dict()["action_link"]
         self.assertEqual(action_link, link_from_event)
-        self.assertEqual(n.operation, 'web_link')
+        self.assertEqual(n.operation, "web_link")
 
     def test_webhook_vnc_post_no_matching_job(self):
         self.mock_agave.jobs.get.return_value = self.agave_job_failed
 
-        response = self.client.post(reverse('interactive_wh_handler'),
-                                    urlencode(self.vnc_event),
-                                    content_type='application/x-www-form-urlencoded')
+        response = self.client.post(
+            reverse("interactive_wh_handler"),
+            urlencode(self.vnc_event),
+            content_type="application/x-www-form-urlencoded",
+        )
         # no matching running job so it fails
         self.assertEqual(response.status_code, 400)
         self.assertEqual(Notification.objects.count(), 0)
@@ -188,9 +218,11 @@ def test_webhook_vnc_post_no_matching_job(self):
     def test_webhook_web_post_no_matching_job(self):
         self.mock_agave.jobs.get.return_value = self.agave_job_failed
 
-        response = self.client.post(reverse('interactive_wh_handler'),
-                                    urlencode(self.web_event),
-                                    content_type='application/x-www-form-urlencoded')
+        response = self.client.post(
+            reverse("interactive_wh_handler"),
+            urlencode(self.web_event),
+            content_type="application/x-www-form-urlencoded",
+        )
         # no matching running job so it fails
         self.assertEqual(response.status_code, 400)
         self.assertEqual(Notification.objects.count(), 0)
diff --git a/designsafe/apps/auth/migrations/0003_tapisoauthtoken_delete_agaveoauthtoken.py b/designsafe/apps/auth/migrations/0003_tapisoauthtoken_delete_agaveoauthtoken.py
index 18ffb59c36..186e7754a2 100644
--- a/designsafe/apps/auth/migrations/0003_tapisoauthtoken_delete_agaveoauthtoken.py
+++ b/designsafe/apps/auth/migrations/0003_tapisoauthtoken_delete_agaveoauthtoken.py
@@ -9,22 +9,37 @@ class Migration(migrations.Migration):
 
     dependencies = [
         migrations.swappable_dependency(settings.AUTH_USER_MODEL),
-        ('designsafe_auth', '0002_auto_20160209_0427'),
+        ("designsafe_auth", "0002_auto_20160209_0427"),
     ]
 
     operations = [
         migrations.CreateModel(
-            name='TapisOAuthToken',
+            name="TapisOAuthToken",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('access_token', models.CharField(max_length=2048)),
-                ('refresh_token', models.CharField(max_length=2048)),
-                ('expires_in', models.BigIntegerField()),
-                ('created', models.BigIntegerField()),
-                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='tapis_oauth', to=settings.AUTH_USER_MODEL)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("access_token", models.CharField(max_length=2048)),
+                ("refresh_token", models.CharField(max_length=2048)),
+                ("expires_in", models.BigIntegerField()),
+                ("created", models.BigIntegerField()),
+                (
+                    "user",
+                    models.OneToOneField(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="tapis_oauth",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
             ],
         ),
         migrations.DeleteModel(
-            name='AgaveOAuthToken',
+            name="AgaveOAuthToken",
         ),
     ]
diff --git a/designsafe/apps/auth/unit_test.py b/designsafe/apps/auth/unit_test.py
index 7d84b24f8b..9fde813a4c 100644
--- a/designsafe/apps/auth/unit_test.py
+++ b/designsafe/apps/auth/unit_test.py
@@ -1,12 +1,12 @@
-
 import pytest
 from django.test import TransactionTestCase, override_settings
 from django.contrib.auth import get_user_model
 from mock import patch, MagicMock
 from requests import Response
 from designsafe.apps.auth.backends import TapisOAuthBackend
+
 # from designsafe.apps.auth.views import launch_setup_checks
-from tapipy.tapis import TapisResult
+# from tapipy.tapis import TapisResult
 
 
 pytestmark = pytest.mark.django_db
@@ -26,7 +26,8 @@ def setUp(self):
         self.backend = TapisOAuthBackend()
         self.mock_response = MagicMock(autospec=Response)
         self.mock_requests_patcher = patch(
-            "designsafe.apps.auth.backends.Tapis.authenticator.get_userinfo", return_value=self.mock_response
+            "designsafe.apps.auth.backends.Tapis.authenticator.get_userinfo",
+            return_value=self.mock_response,
         )
         self.mock_requests = self.mock_requests_patcher.start()
 
diff --git a/designsafe/apps/auth/views_unit_test.py b/designsafe/apps/auth/views_unit_test.py
index c09d592dd1..fa08c9fc97 100644
--- a/designsafe/apps/auth/views_unit_test.py
+++ b/designsafe/apps/auth/views_unit_test.py
@@ -1,4 +1,5 @@
 """DesignSafe Auth Tapis OAuth flow view tests"""
+
 import pytest
 from django.conf import settings
 from django.urls import reverse
diff --git a/designsafe/apps/workspace/tests.py b/designsafe/apps/workspace/tests.py
index c1261d9a1c..1e8d7088e5 100644
--- a/designsafe/apps/workspace/tests.py
+++ b/designsafe/apps/workspace/tests.py
@@ -1,5 +1,4 @@
 import json
-import os
 from mock import patch
 from django.test import TestCase
 from .models.app_descriptions import AppDescription
@@ -9,32 +8,38 @@
 
 class AppDescriptionModelTest(TestCase):
 
-    fixtures = ['user-data', 'agave-oauth-token-data']
+    fixtures = ["user-data", "agave-oauth-token-data"]
 
     def setUp(self):
         user = get_user_model().objects.get(pk=2)
-        user.set_password('user/password')
+        user.set_password("user/password")
         user.save()
 
     def test_string_representation(self):
-        descriptionModel = AppDescription(appid='TestApp0.1', appdescription='Test description')
+        descriptionModel = AppDescription(
+            appid="TestApp0.1", appdescription="Test description"
+        )
         self.assertEqual(str(descriptionModel), descriptionModel.appid)
 
     def test_get_app_description(self):
-        AppDescription.objects.create(appid='TestApp0.1', appdescription='Test description')
-        self.client.login(username='ds_user', password='user/password')
-        url = reverse('designsafe_workspace:call_api', args=('description',))
-        response = self.client.get(url, {'app_id': 'TestApp0.1'})
-        self.assertContains(response, 'TestApp0.1')
+        AppDescription.objects.create(
+            appid="TestApp0.1", appdescription="Test description"
+        )
+        self.client.login(username="ds_user", password="user/password")
+        url = reverse("designsafe_workspace:call_api", args=("description",))
+        response = self.client.get(url, {"app_id": "TestApp0.1"})
+        self.assertContains(response, "TestApp0.1")
 
 
 class TestAppsApiViews(TestCase):
-    fixtures = ['user-data', 'agave-oauth-token-data']
+    fixtures = ["user-data", "agave-oauth-token-data"]
 
     @classmethod
     def setUpClass(cls):
         super(TestAppsApiViews, cls).setUpClass()
-        cls.mock_client_patcher = patch('designsafe.apps.auth.models.AgaveOAuthToken.client')
+        cls.mock_client_patcher = patch(
+            "designsafe.apps.auth.models.TapisOAuthToken.client"
+        )
         cls.mock_client = cls.mock_client_patcher.start()
 
     @classmethod
@@ -44,26 +49,20 @@ def tearDownClass(cls):
 
     def setUp(self):
         user = get_user_model().objects.get(pk=2)
-        user.set_password('user/password')
+        user.set_password("user/password")
         user.save()
 
     def test_apps_list(self):
-        self.client.login(username='ds_user', password='user/password')
+        self.client.login(username="ds_user", password="user/password")
         apps = [
-            {
-                "id": "app-one",
-                "executionSystem": "stampede2"
-            },
-            {
-                "id": "app-two",
-                "executionSystem": "stampede2"
-            }
+            {"id": "app-one", "executionSystem": "stampede2"},
+            {"id": "app-two", "executionSystem": "stampede2"},
         ]
 
-        #need to do a return_value on the mock_client because
-        #the calling signature is something like client = Agave(**kwargs).apps.list()
+        # need to do a return_value on the mock_client because
+        # the calling signature is something like client = Agave(**kwargs).apps.list()
         self.mock_client.apps.list.return_value = apps
-        url = reverse('designsafe_workspace:call_api', args=('apps',))
+        url = reverse("designsafe_workspace:call_api", args=("apps",))
         response = self.client.get(url, follow=True)
         data = response.json()
         # If the request is sent successfully, then I expect a response to be returned.
@@ -72,37 +71,41 @@ def test_apps_list(self):
         self.assertTrue(data == apps)
 
     def test_job_submit_notifications(self):
-        with open('designsafe/apps/workspace/fixtures/job-submission.json') as f:
+        with open("designsafe/apps/workspace/fixtures/job-submission.json") as f:
             job_data = json.load(f)
 
         self.mock_client.jobs.submit.return_value = {"status": "ok"}
-        self.client.login(username='ds_user', password='user/password')
+        self.client.login(username="ds_user", password="user/password")
 
-        url = reverse('designsafe_workspace:call_api', args=('jobs',))
-        response = self.client.post(url, json.dumps(job_data), content_type="application/json")
+        url = reverse("designsafe_workspace:call_api", args=("jobs",))
+        response = self.client.post(
+            url, json.dumps(job_data), content_type="application/json"
+        )
         data = response.json()
 
         self.assertTrue(self.mock_client.jobs.submit.called)
-        self.assertEqual(data['status'], 'ok')
+        self.assertEqual(data["status"], "ok")
         self.assertEqual(response.status_code, 200)
 
     def test_job_submit_parse_urls(self):
-        with open('designsafe/apps/workspace/fixtures/job-submission.json') as f:
+        with open("designsafe/apps/workspace/fixtures/job-submission.json") as f:
             job_data = json.load(f)
         # the spaces should get quoted out
         job_data["inputs"]["workingDirectory"] = "agave://test.system/name with spaces"
 
         self.mock_client.jobs.submit.return_value = {"status": "ok"}
-        self.client.login(username='ds_user', password='user/password')
+        self.client.login(username="ds_user", password="user/password")
 
-        url = reverse('designsafe_workspace:call_api', args=('jobs',))
-        response = self.client.post(url, json.dumps(job_data), content_type="application/json")
+        url = reverse("designsafe_workspace:call_api", args=("jobs",))
+        response = self.client.post(
+            url, json.dumps(job_data), content_type="application/json"
+        )
 
         self.assertEqual(response.status_code, 200)
         args, kwargs = self.mock_client.jobs.submit.call_args
         body = kwargs["body"]
         input = body["inputs"]["workingDirectory"]
-        #the spaces should have been quoted
+        # the spaces should have been quoted
         self.assertTrue("%20" in input)
 
     def test_licensed_apps(self):

From e49c23d697ed9d2d9739c8d90082210def138f9e Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Thu, 29 Feb 2024 14:58:01 -0600
Subject: [PATCH 13/20] add test settings for tapisv3

---
 designsafe/settings/test_settings.py | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/designsafe/settings/test_settings.py b/designsafe/settings/test_settings.py
index 373b5a32d1..1789ae1495 100644
--- a/designsafe/settings/test_settings.py
+++ b/designsafe/settings/test_settings.py
@@ -540,6 +540,15 @@
 AGAVE_SUPER_TOKEN = 'example_com_client_token'
 AGAVE_STORAGE_SYSTEM = 'storage.example.com'
 
+# Tapis Client Configuration
+PORTAL_ADMIN_USERNAME = ''
+TAPIS_TENANT_BASEURL = 'https://designsafe.tapis.io'
+TAPIS_CLIENT_ID = 'client_id'
+TAPIS_CLIENT_KEY = 'client_key'
+TAPIS_ADMIN_JWT = 'admin_jwt'
+
+KEY_SERVICE_TOKEN = ''
+
 MIGRATION_MODULES = {
     'data': None,
     'designsafe_data': None,

From 3eac1b803dd92c35efeade3cf6c152456879047b Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Thu, 29 Feb 2024 18:15:40 -0600
Subject: [PATCH 14/20] fix auth/views_unit_test.py

---
 .flake8                                 |  2 ++
 designsafe/apps/auth/backends.py        |  4 ++--
 designsafe/apps/auth/middleware.py      |  6 +++++-
 designsafe/apps/auth/views.py           |  2 +-
 designsafe/apps/auth/views_unit_test.py |  2 +-
 designsafe/conftest.py                  | 16 ++++++++++++++++
 6 files changed, 27 insertions(+), 5 deletions(-)

diff --git a/.flake8 b/.flake8
index d5604d56e3..c3e40888df 100644
--- a/.flake8
+++ b/.flake8
@@ -5,3 +5,5 @@ ignore = E501, H101
 exclude = __pycache__,
           tests.py,
           migrations
+
+extend-ignore = W503
diff --git a/designsafe/apps/auth/backends.py b/designsafe/apps/auth/backends.py
index aef3c89fa9..b668a94b3b 100644
--- a/designsafe/apps/auth/backends.py
+++ b/designsafe/apps/auth/backends.py
@@ -187,10 +187,10 @@ def authenticate(self, *args, **kwargs):
         return user
 
     def revoke(self, token):
-        self.logger.info(
+        logger.info(
             "Attempting to revoke Tapis token %s" % TapisOAuthToken().get_masked_token(token)
         )
 
         client = Tapis(base_url=settings.TAPIS_TENANT_BASEURL, access_token=token)
         response = client.authenticator.revoke_token(token=token)
-        self.logger.info("revoke response is %s" % response)
+        logger.info("revoke response is %s" % response)
diff --git a/designsafe/apps/auth/middleware.py b/designsafe/apps/auth/middleware.py
index 877657d0e1..f013fe701d 100644
--- a/designsafe/apps/auth/middleware.py
+++ b/designsafe/apps/auth/middleware.py
@@ -20,7 +20,11 @@ def __init__(self, get_response):
         self.get_response = get_response
 
     def __call__(self, request):
-        if request.path != reverse("logout") and request.user.is_authenticated:
+        if (
+            request.path != reverse("logout")
+            and request.path != reverse("login")
+            and request.user.is_authenticated
+        ):
             self.process_request(request)
 
         response = self.get_response(request)
diff --git a/designsafe/apps/auth/views.py b/designsafe/apps/auth/views.py
index 5714c81de0..0fb8afb44e 100644
--- a/designsafe/apps/auth/views.py
+++ b/designsafe/apps/auth/views.py
@@ -51,7 +51,7 @@ def tapis_oauth(request):
     tenant_base_url = getattr(settings, "TAPIS_TENANT_BASEURL")
     client_id = getattr(settings, "TAPIS_CLIENT_ID")
 
-    METRICS.info(f"user:{request.user.username} starting oauth redirect login")
+    METRICS.debug(f"user:{request.user.username} starting oauth redirect login")
 
     # Authorization code request
     authorization_url = (
diff --git a/designsafe/apps/auth/views_unit_test.py b/designsafe/apps/auth/views_unit_test.py
index fa08c9fc97..46e77017d9 100644
--- a/designsafe/apps/auth/views_unit_test.py
+++ b/designsafe/apps/auth/views_unit_test.py
@@ -20,7 +20,7 @@ def test_auth_tapis(client, mocker):
 
     tapis_authorize = (
         f"{settings.TAPIS_TENANT_BASEURL}/v3/oauth2/authorize"
-        f"?client_id=test&redirect_uri=http://testserver/auth/tapis/callback/&response_type=code&state={TEST_STATE}"
+        f"?client_id=client_id&redirect_uri=http://testserver/auth/tapis/callback/&response_type=code&state={TEST_STATE}"
     )
 
     assert response.status_code == 302
diff --git a/designsafe/conftest.py b/designsafe/conftest.py
index 1cb2309d9d..aaa811fb3c 100644
--- a/designsafe/conftest.py
+++ b/designsafe/conftest.py
@@ -1,6 +1,9 @@
 """Base User pytest fixtures"""
 
 import pytest
+import os
+import json
+from django.conf import settings
 from designsafe.apps.auth.models import TapisOAuthToken
 
 
@@ -50,3 +53,16 @@ def project_admin_user(django_user_model):
 def authenticated_user(client, regular_user):
     client.force_login(regular_user)
     yield regular_user
+
+
+@pytest.fixture
+def tapis_tokens_create_mock():
+    yield json.load(
+        open(
+            os.path.join(
+                settings.BASE_DIR,
+                "designsafe/fixtures/tapis/auth/create-tokens-response.json",
+            ),
+            "r",
+        )
+    )

From d5cb4da1614ab80d50498e533dd30ae7e3bb361e Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Fri, 1 Mar 2024 12:18:44 -0600
Subject: [PATCH 15/20] fix backends unit test

---
 designsafe/apps/api/users/utils.py            |  34 ++----
 designsafe/apps/auth/backends_unit_test.py    |  97 +++++++++++++++++
 designsafe/apps/auth/unit_test.py             | 101 ------------------
 .../tapis/auth/create-tokens-response.json    |  20 ++++
 4 files changed, 128 insertions(+), 124 deletions(-)
 create mode 100644 designsafe/apps/auth/backends_unit_test.py
 delete mode 100644 designsafe/apps/auth/unit_test.py
 create mode 100644 designsafe/fixtures/tapis/auth/create-tokens-response.json

diff --git a/designsafe/apps/api/users/utils.py b/designsafe/apps/api/users/utils.py
index 0eb576a8e3..bd51e92e52 100644
--- a/designsafe/apps/api/users/utils.py
+++ b/designsafe/apps/api/users/utils.py
@@ -1,30 +1,18 @@
 import logging
 from pytas.http import TASClient
 from django.db.models import Q
-from django.conf import settings
 
 
 logger = logging.getLogger(__name__)
 
 
-def get_tas_client():
-    """Return a TAS Client with pytas"""
-    return TASClient(
-        baseURL=settings.TAS_URL,
-        credentials={
-            'username': settings.TAS_CLIENT_KEY,
-            'password': settings.TAS_CLIENT_SECRET
-        }
-    )
-
-
 def get_user_data(username):
     """Returns user contact information
 
     : returns: user_data
     : rtype: dict
     """
-    tas_client = get_tas_client()
+    tas_client = TASClient()
     user_data = tas_client.get_user(username=username)
     return user_data
 
@@ -32,13 +20,13 @@ def get_user_data(username):
 def list_to_model_queries(q_comps):
     query = None
     if len(q_comps) > 2:
-        query = Q(first_name__icontains = ' '.join(q_comps[:1]))
-        query |= Q(first_name__icontains = ' '.join(q_comps[:2]))
-        query |= Q(last_name__icontains = ' '.join(q_comps[1:]))
-        query |= Q(last_name__icontains = ' '.join(q_comps[2:]))
+        query = Q(first_name__icontains=" ".join(q_comps[:1]))
+        query |= Q(first_name__icontains=" ".join(q_comps[:2]))
+        query |= Q(last_name__icontains=" ".join(q_comps[1:]))
+        query |= Q(last_name__icontains=" ".join(q_comps[2:]))
     else:
-        query = Q(first_name__icontains = q_comps[0])
-        query |= Q(last_name__icontains = q_comps[1])
+        query = Q(first_name__icontains=q_comps[0])
+        query |= Q(last_name__icontains=q_comps[1])
     return query
 
 
@@ -47,12 +35,12 @@ def q_to_model_queries(q):
         return None
 
     query = None
-    if ' ' in q:
+    if " " in q:
         q_comps = q.split()
         query = list_to_model_queries(q_comps)
     else:
-        query = Q(email__icontains = q)
-        query |= Q(first_name__icontains = q)
-        query |= Q(last_name__icontains = q)
+        query = Q(email__icontains=q)
+        query |= Q(first_name__icontains=q)
+        query |= Q(last_name__icontains=q)
 
     return query
diff --git a/designsafe/apps/auth/backends_unit_test.py b/designsafe/apps/auth/backends_unit_test.py
new file mode 100644
index 0000000000..f02d3e4680
--- /dev/null
+++ b/designsafe/apps/auth/backends_unit_test.py
@@ -0,0 +1,97 @@
+import pytest
+from django.contrib.auth import get_user_model
+from mock import Mock
+from designsafe.apps.auth.backends import TapisOAuthBackend
+from tapipy.tapis import TapisResult
+from tapipy.errors import BaseTapyException
+
+pytestmark = pytest.mark.django_db
+
+
+@pytest.fixture
+def user_data_mock(mocker):
+    mock_user_data = mocker.patch(
+        "designsafe.apps.auth.backends.get_user_data",
+        return_value={
+            "username": "testuser",
+            "firstName": "test",
+            "lastName": "user",
+            "email": "new@email.com",
+        },
+    )
+    return mock_user_data
+
+
+@pytest.fixture()
+def tapis_mock(mocker):
+    tapis_patcher = mocker.patch("designsafe.apps.auth.backends.Tapis")
+    mock_tapis = Mock()
+    mock_tapis.authenticator.get_userinfo.return_value = TapisResult(
+        username="testuser"
+    )
+    tapis_patcher.return_value = mock_tapis
+    yield tapis_patcher
+
+
+@pytest.fixture()
+def update_institution_from_tas_mock(mocker):
+    yield mocker.patch("designsafe.apps.auth.backends.update_institution_from_tas")
+
+
+# def test_launch_setup_checks(mocker, regular_user, settings):
+#     mocker.patch("designsafe.apps.auth.views.new_user_setup_check")
+#     mock_execute = mocker.patch("designsafe.apps.auth.views.execute_setup_steps")
+#     regular_user.profile.setup_complete = False
+#     launch_setup_checks(regular_user)
+#     mock_execute.apply_async.assert_called_with(args=["username"])
+
+
+def test_bad_backend_params(tapis_mock):
+    # Test backend authenticate with no backend params
+    backend = TapisOAuthBackend()
+    result = backend.authenticate()
+    assert result is None
+
+    # Test TapisOAuthBackend if params do not indicate tapis
+    result = backend.authenticate(backend="not_tapis")
+    assert result is None
+
+
+def test_bad_response_status(
+    tapis_mock, user_data_mock, update_institution_from_tas_mock
+):
+    """Test that backend failure responses are handled"""
+    backend = TapisOAuthBackend()
+    mock_tapis = Mock()
+    mock_tapis.authenticator.get_userinfo.side_effect = BaseTapyException
+    tapis_mock.return_value = mock_tapis
+    result = backend.authenticate(backend="tapis", token="1234")
+    assert result is None
+
+
+def test_new_user(tapis_mock, user_data_mock, update_institution_from_tas_mock):
+    """Test that a new user is created and returned"""
+    backend = TapisOAuthBackend()
+    result = backend.authenticate(backend="tapis", token="1234")
+    assert result.username == "testuser"
+
+
+def test_update_existing_user(
+    tapis_mock, user_data_mock, update_institution_from_tas_mock
+):
+    """Test that an existing user's information is updated with from info from the Tapis backend response"""
+    backend = TapisOAuthBackend()
+
+    # Create a pre-existing user with the same username
+    user = get_user_model().objects.create_user(
+        username="testuser",
+        first_name="test",
+        last_name="user",
+        email="old@email.com",
+    )
+    result = backend.authenticate(backend="tapis", token="1234")
+    # Result user object should be the same
+    assert result == user
+    # Existing user object should be updated
+    user = get_user_model().objects.get(username="testuser")
+    assert user.email == "new@email.com"
diff --git a/designsafe/apps/auth/unit_test.py b/designsafe/apps/auth/unit_test.py
deleted file mode 100644
index 9fde813a4c..0000000000
--- a/designsafe/apps/auth/unit_test.py
+++ /dev/null
@@ -1,101 +0,0 @@
-import pytest
-from django.test import TransactionTestCase, override_settings
-from django.contrib.auth import get_user_model
-from mock import patch, MagicMock
-from requests import Response
-from designsafe.apps.auth.backends import TapisOAuthBackend
-
-# from designsafe.apps.auth.views import launch_setup_checks
-# from tapipy.tapis import TapisResult
-
-
-pytestmark = pytest.mark.django_db
-
-
-# def test_launch_setup_checks(mocker, regular_user, settings):
-#     mocker.patch("designsafe.apps.auth.views.new_user_setup_check")
-#     mock_execute = mocker.patch("designsafe.apps.auth.views.execute_setup_steps")
-#     regular_user.profile.setup_complete = False
-#     launch_setup_checks(regular_user)
-#     mock_execute.apply_async.assert_called_with(args=["username"])
-
-
-class TestTapisOAuthBackend(TransactionTestCase):
-    def setUp(self):
-        super(TestTapisOAuthBackend, self).setUp()
-        self.backend = TapisOAuthBackend()
-        self.mock_response = MagicMock(autospec=Response)
-        self.mock_requests_patcher = patch(
-            "designsafe.apps.auth.backends.Tapis.authenticator.get_userinfo",
-            return_value=self.mock_response,
-        )
-        self.mock_requests = self.mock_requests_patcher.start()
-
-        self.mock_user_data_patcher = patch(
-            "designsafe.apps.auth.backends.get_user_data",
-            return_value={
-                "username": "testuser",
-                "firstName": "test",
-                "lastName": "user",
-                "email": "new@email.com",
-            },
-        )
-        self.mock_user_data = self.mock_user_data_patcher.start()
-
-    def tearDown(self):
-        super(TestTapisOAuthBackend, self).tearDown()
-        self.mock_requests_patcher.stop()
-        self.mock_user_data_patcher.stop()
-
-    def test_bad_backend_params(self):
-        # Test backend authenticate with no params
-        result = self.backend.authenticate()
-        self.assertIsNone(result)
-        # Test TapisOAuthBackend if params do not indicate tapis
-        result = self.backend.authenticate(backend="not_tapis")
-        self.assertIsNone(result)
-
-    def test_bad_response_status(self):
-        # Test that backend failure responses are handled
-
-        # Mock different return values for the backend response
-        self.mock_response.json.return_value = {}
-        result = self.backend.authenticate(backend="tapis", token="1234")
-        self.assertIsNone(result)
-        self.mock_response.json.return_value = {"status": "failure"}
-        result = self.backend.authenticate(backend="tapis", token="1234")
-        self.assertIsNone(result)
-
-    @override_settings(PORTAL_USER_ACCOUNT_SETUP_STEPS=[])
-    def test_new_user(self):
-        """Test that a new user is created and returned"""
-        self.mock_response.json.return_value = {
-            "status": "success",
-            "result": {"username": "testuser"},
-        }
-        result = self.backend.authenticate(backend="tapis", token="1234")
-        self.assertEqual(result.username, "testuser")
-
-    @override_settings(PORTAL_USER_ACCOUNT_SETUP_STEPS=[])
-    def test_update_existing_user(self):
-        """Test that an existing user's information is updated with from info from the Tapis backend response"""
-
-        # Create a pre-existing user with the same username
-        user = get_user_model().objects.create_user(
-            username="testuser",
-            first_name="test",
-            last_name="user",
-            email="old@email.com",
-        )
-        self.mock_response.json.return_value = {
-            "status": "success",
-            "result": {
-                "username": "testuser",
-            },
-        }
-        result = self.backend.authenticate(backend="tapis", token="1234")
-        # Result user object should be the same
-        self.assertEqual(result, user)
-        # Existing user object should be updated
-        user = get_user_model().objects.get(username="testuser")
-        self.assertEqual(user.email, "new@email.com")
diff --git a/designsafe/fixtures/tapis/auth/create-tokens-response.json b/designsafe/fixtures/tapis/auth/create-tokens-response.json
new file mode 100644
index 0000000000..00191a6106
--- /dev/null
+++ b/designsafe/fixtures/tapis/auth/create-tokens-response.json
@@ -0,0 +1,20 @@
+{
+    "message": "Token created successfully.",
+    "metadata": {},
+    "result": {
+        "access_token": {
+            "access_token": "eyJhbGci---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------",
+            "expires_at": "2024-03-01T03:47:18.611914+00:00",
+            "expires_in": 14400,
+            "jti": "108792e6-2a77-41ad-964c-f289cc2198f7"
+        },
+        "refresh_token": {
+            "expires_at": "2025-02-28T23:47:18.711146+00:00",
+            "expires_in": 31536000,
+            "jti": "69992b30-3b3b-477a-ba22-3bd2a8203791",
+            "refresh_token": "eyJhbGci---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------"
+        }
+    },
+    "status": "success",
+    "version": "dev"
+}

From 58ab3e7ff0be0c394e13ae8ce43e019618a7714c Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Fri, 1 Mar 2024 12:22:05 -0600
Subject: [PATCH 16/20] formatting

---
 .../migrations/0003_tapisoauthtoken_delete_agaveoauthtoken.py    | 1 -
 1 file changed, 1 deletion(-)

diff --git a/designsafe/apps/auth/migrations/0003_tapisoauthtoken_delete_agaveoauthtoken.py b/designsafe/apps/auth/migrations/0003_tapisoauthtoken_delete_agaveoauthtoken.py
index 186e7754a2..29a3f33c85 100644
--- a/designsafe/apps/auth/migrations/0003_tapisoauthtoken_delete_agaveoauthtoken.py
+++ b/designsafe/apps/auth/migrations/0003_tapisoauthtoken_delete_agaveoauthtoken.py
@@ -6,7 +6,6 @@
 
 
 class Migration(migrations.Migration):
-
     dependencies = [
         migrations.swappable_dependency(settings.AUTH_USER_MODEL),
         ("designsafe_auth", "0002_auto_20160209_0427"),

From 697e11f875941cd0721ae09162becc0ec7e79384 Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Mon, 4 Mar 2024 13:54:20 -0600
Subject: [PATCH 17/20] task/DES-2654: Tapis v2/v3 apps model (#1158)

* v3 apps model

* make appId required field

* add AppBundle model

* updated model admin

* update models and add migration

* rework app tray models

* squash appitem and appbundle

* un-squash migrations

* remove faulty app description migration

* Use an inline admin form to manage app variants within bundles (#1164)

Co-authored-by: Sal Tijerina <r.sal.tijerina@gmail.com>

* model adjustments

* model adjustments; add category pop migration

---------

Co-authored-by: Jake Rosenberg <jrosenberg@tacc.utexas.edu>
---
 .flake8                                       |   3 +-
 .pylintrc                                     |   2 +
 designsafe/apps/workspace/admin.py            |  95 ++++++++
 .../apps/workspace/migrations/0001_initial.py |   4 +-
 .../migrations/0002_auto_20200423_1940.py     |   2 +-
 ...try_apptraycategory_appvariant_and_more.py | 228 ++++++++++++++++++
 .../migrations/0004_initial_app_categories.py |  35 +++
 designsafe/apps/workspace/models/__init__.py  |   2 +-
 .../apps/workspace/models/app_entries.py      | 173 +++++++++++++
 designsafe/static/styles/cms-form-styles.css  |   4 +
 10 files changed, 543 insertions(+), 5 deletions(-)
 create mode 100644 designsafe/apps/workspace/migrations/0003_applistingentry_apptraycategory_appvariant_and_more.py
 create mode 100644 designsafe/apps/workspace/migrations/0004_initial_app_categories.py
 create mode 100644 designsafe/apps/workspace/models/app_entries.py
 create mode 100644 designsafe/static/styles/cms-form-styles.css

diff --git a/.flake8 b/.flake8
index c3e40888df..3c81c28f5d 100644
--- a/.flake8
+++ b/.flake8
@@ -1,7 +1,8 @@
 [flake8]
 # E501: line is too long.
 # H101: Use TODO(NAME)
-ignore = E501, H101
+# W503: line break before binary operator. Ingore as black will break this rule.
+ignore = E501, H101, W503
 exclude = __pycache__,
           tests.py,
           migrations
diff --git a/.pylintrc b/.pylintrc
index 492dab158b..5a4da2b364 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -430,9 +430,11 @@ disable=raw-checker-failed,
         useless-suppression,
         deprecated-pragma,
         use-symbolic-message-instead,
+        line-too-long,
         duplicate-code,
         logging-fstring-interpolation
 
+
 # Enable the message, report, category or checker with the given id(s). You can
 # either give multiple identifier separated by comma (,) or put this option
 # multiple time (only on the command line, not in the configuration file where
diff --git a/designsafe/apps/workspace/admin.py b/designsafe/apps/workspace/admin.py
index f7721229ca..b95c773de0 100644
--- a/designsafe/apps/workspace/admin.py
+++ b/designsafe/apps/workspace/admin.py
@@ -1,4 +1,99 @@
+"""Admin layout for Tools & Applications workspace models.
+"""
+
 from django.contrib import admin
+from django.db import models
+from django.forms import CheckboxSelectMultiple
 from designsafe.apps.workspace.models.app_descriptions import AppDescription
+from designsafe.apps.workspace.models.app_entries import (
+    AppListingEntry,
+    AppVariant,
+    AppTrayCategory,
+)
 
 admin.site.register(AppDescription)
+admin.site.register(AppTrayCategory)
+
+
+class AppVariantInline(admin.StackedInline):
+    """Admin layout for app variants."""
+
+    extra = 0
+    model = AppVariant
+    fk_name = "bundle"
+
+    def get_fieldsets(self, request, obj=None):
+        return [
+            (
+                "Tapis App information",
+                {
+                    "fields": (
+                        "app_type",
+                        "app_id",
+                        "version",
+                        "license_type",
+                    )
+                },
+            ),
+            (
+                "Display information",
+                {
+                    "fields": (
+                        "label",
+                        "enabled",
+                    )
+                },
+            ),
+            (
+                "HTML App Body for app_type: HTML",
+                {
+                    "classes": ["collapse"],
+                    "fields": ["html"],
+                },
+            ),
+        ]
+
+
+@admin.register(AppListingEntry)
+class AppTrayEntryAdmin(admin.ModelAdmin):
+    """Admin layout for AppTrayEntry items."""
+
+    class Media:
+        css = {"all": ("styles/cms-form-styles.css",)}
+
+    inlines = [AppVariantInline]
+
+    def get_fieldsets(self, request, obj=None):
+        default_fieldset = [
+            (
+                "Portal Display Options",
+                {
+                    "fields": [
+                        "category",
+                        "label",
+                        "icon",
+                        "enabled",
+                    ],
+                },
+            ),
+        ]
+
+        cms_fieldset = [
+            (
+                "CMS Display Options",
+                {
+                    "fields": [
+                        "href",
+                        "popular",
+                        "not_bundled",
+                        "related_apps",
+                    ],
+                },
+            ),
+        ]
+
+        return default_fieldset + cms_fieldset
+
+    formfield_overrides = {
+        models.ManyToManyField: {"widget": CheckboxSelectMultiple},
+    }
diff --git a/designsafe/apps/workspace/migrations/0001_initial.py b/designsafe/apps/workspace/migrations/0001_initial.py
index ce5d324624..7f3fc4f043 100644
--- a/designsafe/apps/workspace/migrations/0001_initial.py
+++ b/designsafe/apps/workspace/migrations/0001_initial.py
@@ -16,8 +16,8 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             name='AppDescription',
             fields=[
-                ('appId', models.CharField(max_length=120, primary_key=True, serialize=False, unique=True)),
-                ('appDescription', models.TextField(help_text=b'App dropdown description text for apps that have a dropdown.')),
+                ('appid', models.CharField(max_length=120, primary_key=True, serialize=False, unique=True)),
+                ('appdescription', models.TextField(help_text=b'App dropdown description text for apps that have a dropdown.')),
             ],
         ),
     ]
diff --git a/designsafe/apps/workspace/migrations/0002_auto_20200423_1940.py b/designsafe/apps/workspace/migrations/0002_auto_20200423_1940.py
index a717cf3763..4e23933c39 100644
--- a/designsafe/apps/workspace/migrations/0002_auto_20200423_1940.py
+++ b/designsafe/apps/workspace/migrations/0002_auto_20200423_1940.py
@@ -14,7 +14,7 @@ class Migration(migrations.Migration):
     operations = [
         migrations.AlterField(
             model_name='appdescription',
-            name='appDescription',
+            name='appdescription',
             field=models.TextField(help_text='App dropdown description text for apps that have a dropdown.'),
         ),
     ]
diff --git a/designsafe/apps/workspace/migrations/0003_applistingentry_apptraycategory_appvariant_and_more.py b/designsafe/apps/workspace/migrations/0003_applistingentry_apptraycategory_appvariant_and_more.py
new file mode 100644
index 0000000000..6994cca463
--- /dev/null
+++ b/designsafe/apps/workspace/migrations/0003_applistingentry_apptraycategory_appvariant_and_more.py
@@ -0,0 +1,228 @@
+# Generated by Django 4.2.6 on 2024-03-01 20:10
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("workspace", "0002_auto_20200423_1940"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="AppListingEntry",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "label",
+                    models.CharField(
+                        help_text="The display name of this bundle in the Apps Tray. Not used if this entry is a single app ID.",
+                        max_length=64,
+                    ),
+                ),
+                (
+                    "icon",
+                    models.CharField(
+                        blank=True,
+                        choices=[
+                            ("adcirc", "ADCIRC"),
+                            ("ansys", "Ansys"),
+                            ("blender", "Blender"),
+                            ("clawpack", "Clawpack"),
+                            ("compress", "Compress"),
+                            ("dakota", "Dakota"),
+                            ("extract", "Extract"),
+                            ("hazmapper", "Hazmapper"),
+                            ("jupyter", "Jupyter"),
+                            ("ls-dyna", "LS-DYNA"),
+                            ("matlab", "MATLAB"),
+                            ("ngl", "NGL"),
+                            ("openfoam", "OpenFOAM"),
+                            ("opensees", "OpenSees"),
+                            ("paraview", "Paraview"),
+                            ("qgis", "QGIS"),
+                            ("rwhale", "rWHALE"),
+                            ("stko", "STKO"),
+                            ("swbatch", "swbatch"),
+                            ("visit", "VisIt"),
+                        ],
+                        help_text="The icon associated with this app.",
+                        max_length=64,
+                    ),
+                ),
+                (
+                    "enabled",
+                    models.BooleanField(
+                        default=True, help_text="App bundle visibility in app tray."
+                    ),
+                ),
+                (
+                    "popular",
+                    models.BooleanField(
+                        default=False,
+                        help_text="Mark as popular on tools & apps overview.",
+                    ),
+                ),
+                (
+                    "not_bundled",
+                    models.BooleanField(
+                        default=False,
+                        help_text="Select if this entry represents a single app ID and not a bundle.",
+                    ),
+                ),
+                (
+                    "href",
+                    models.CharField(
+                        blank=True,
+                        help_text="Link to overview page for this app.",
+                        max_length=128,
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name_plural": "App Listing Entries",
+            },
+        ),
+        migrations.CreateModel(
+            name="AppTrayCategory",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "category",
+                    models.CharField(
+                        help_text="A category for the app tray.",
+                        max_length=64,
+                        unique=True,
+                    ),
+                ),
+                (
+                    "priority",
+                    models.IntegerField(
+                        default=0,
+                        help_text="Category priority, where higher number tabs appear before lower ones.",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name_plural": "App Categories",
+            },
+        ),
+        migrations.CreateModel(
+            name="AppVariant",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "app_id",
+                    models.CharField(
+                        help_text="The id of this app or app bundle. The id appears in the unique url path to the app.",
+                        max_length=64,
+                    ),
+                ),
+                (
+                    "app_type",
+                    models.CharField(
+                        choices=[
+                            ("tapis", "Tapis App"),
+                            ("html", "HTML or External app"),
+                        ],
+                        default="tapis",
+                        help_text="Application type.",
+                        max_length=10,
+                    ),
+                ),
+                (
+                    "label",
+                    models.CharField(
+                        blank=True,
+                        help_text="The display name of this app in the Apps Tray. If not defined, uses notes.label from app definition.",
+                        max_length=64,
+                    ),
+                ),
+                (
+                    "license_type",
+                    models.CharField(
+                        choices=[("OS", "Open Source"), ("LS", "Licensed")],
+                        default="OS",
+                        help_text="License Type.",
+                        max_length=2,
+                    ),
+                ),
+                (
+                    "html",
+                    models.TextField(
+                        blank=True,
+                        default="",
+                        help_text="HTML definition to display when app is loaded.",
+                    ),
+                ),
+                (
+                    "version",
+                    models.CharField(
+                        blank=True,
+                        default="",
+                        help_text="The version number of the app. The app id + version denotes a unique app.",
+                        max_length=64,
+                    ),
+                ),
+                (
+                    "enabled",
+                    models.BooleanField(
+                        default=True, help_text="App variant visibility in app tray."
+                    ),
+                ),
+                (
+                    "bundle",
+                    models.ForeignKey(
+                        blank=True,
+                        help_text="Bundle that the app belongs to.",
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="workspace.applistingentry",
+                    ),
+                ),
+            ],
+        ),
+        migrations.AddField(
+            model_name="applistingentry",
+            name="category",
+            field=models.ForeignKey(
+                help_text="The category for this app entry.",
+                on_delete=django.db.models.deletion.CASCADE,
+                to="workspace.apptraycategory",
+            ),
+        ),
+        migrations.AddField(
+            model_name="applistingentry",
+            name="related_apps",
+            field=models.ManyToManyField(
+                blank=True,
+                help_text="Related apps that will display on app overview page.",
+                to="workspace.applistingentry",
+            ),
+        ),
+    ]
diff --git a/designsafe/apps/workspace/migrations/0004_initial_app_categories.py b/designsafe/apps/workspace/migrations/0004_initial_app_categories.py
new file mode 100644
index 0000000000..72667dbcb7
--- /dev/null
+++ b/designsafe/apps/workspace/migrations/0004_initial_app_categories.py
@@ -0,0 +1,35 @@
+from django.db import migrations
+
+
+APP_CATEGORIES = [
+    ("5", "Simulation"),
+    ("4", "Sim Center Tools"),
+    ("3", "Visualization"),
+    ("2", "Analysis"),
+    ("1", "Hazard Apps"),
+    ("0", "Utilities"),
+]
+
+
+def populate_categories(apps, schema_editor):
+    AppTrayCategory = apps.get_model("workspace", "AppTrayCategory")
+    for priority, category in APP_CATEGORIES:
+        AppTrayCategory.objects.create(
+            priority=priority,
+            category=category,
+        )
+
+
+def reverse_func(apps, schema_editor):
+    AppTrayCategory = apps.get_model("workspace", "AppTrayCategory")
+    AppTrayCategory.objects.all().delete()
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("workspace", "0003_applistingentry_apptraycategory_appvariant_and_more"),
+    ]
+
+    operations = [
+        migrations.RunPython(populate_categories, reverse_func),
+    ]
diff --git a/designsafe/apps/workspace/models/__init__.py b/designsafe/apps/workspace/models/__init__.py
index bed7aa34d0..9402842dc2 100644
--- a/designsafe/apps/workspace/models/__init__.py
+++ b/designsafe/apps/workspace/models/__init__.py
@@ -1,4 +1,4 @@
-from django.db import models
+
 from django.dispatch import receiver
 from designsafe.apps.signals.signals import generic_event
 from designsafe.apps.notifications.models import Notification
diff --git a/designsafe/apps/workspace/models/app_entries.py b/designsafe/apps/workspace/models/app_entries.py
new file mode 100644
index 0000000000..ec3d559593
--- /dev/null
+++ b/designsafe/apps/workspace/models/app_entries.py
@@ -0,0 +1,173 @@
+"""Models for the Tools & Applications workspace.
+"""
+
+from django.db import models
+
+APP_ICONS = [
+    ("adcirc", "ADCIRC"),
+    ("ansys", "Ansys"),
+    ("blender", "Blender"),
+    ("clawpack", "Clawpack"),
+    ("compress", "Compress"),
+    ("dakota", "Dakota"),
+    ("extract", "Extract"),
+    ("hazmapper", "Hazmapper"),
+    ("jupyter", "Jupyter"),
+    ("ls-dyna", "LS-DYNA"),
+    ("matlab", "MATLAB"),
+    ("ngl", "NGL"),
+    ("openfoam", "OpenFOAM"),
+    ("opensees", "OpenSees"),
+    ("paraview", "Paraview"),
+    ("qgis", "QGIS"),
+    ("rwhale", "rWHALE"),
+    ("stko", "STKO"),
+    ("swbatch", "swbatch"),
+    ("visit", "VisIt"),
+]
+
+LICENSE_TYPES = [("OS", "Open Source"), ("LS", "Licensed")]
+
+
+class AppTrayCategory(models.Model):
+    """Categories in which AppTrayEntry items are organized."""
+
+    category = models.CharField(
+        help_text="A category for the app tray.", max_length=64, unique=True
+    )
+    priority = models.IntegerField(
+        help_text="Category priority, where higher number tabs appear before lower ones.",
+        default=0,
+    )
+
+    def __str__(self):
+        return f"{self.category}"
+
+    class Meta:
+        verbose_name_plural = "App Categories"
+
+
+class AppListingEntry(models.Model):
+    """Entries for the Tools & Applications workspace, including Tapis, HTML, and Bundled apps.
+
+    ENTRY_TYPES:
+        A Tapis App is corresponds to a valid app id registered in the Tapis tenant.
+
+        An HTML or External app is typically an HTML body with a link to an external resource.
+
+        An App Listing Entry (bundle) is both:
+            1) a card on the apps CMS layout page that links to an overview page, and
+            2) a binned app in the apps workspace, where each app variant is a dropdown item.
+
+        Note: If an App Listing Entry has only one variant, it will be treated as a single app, and not a bundle.
+    """
+
+    # Basic display options
+    category = models.ForeignKey(
+        AppTrayCategory,
+        help_text="The category for this app entry.",
+        on_delete=models.CASCADE,
+    )
+    label = models.CharField(
+        help_text="The display name of this bundle in the Apps Tray. Not used if this entry is a single app ID.",
+        max_length=64,
+    )
+    icon = models.CharField(
+        help_text="The icon associated with this app.",
+        max_length=64,
+        choices=APP_ICONS,
+        blank=True,
+    )
+    enabled = models.BooleanField(
+        help_text="App bundle visibility in app tray.", default=True
+    )
+
+    # CMS Display Options
+    related_apps = models.ManyToManyField(
+        "self",
+        help_text="Related apps that will display on app overview page.",
+        blank=True,
+    )
+    popular = models.BooleanField(
+        help_text="Mark as popular on tools & apps overview.", default=False
+    )
+
+    not_bundled = models.BooleanField(
+        help_text="Select if this entry represents a single app ID and not a bundle.",
+        default=False,
+    )
+
+    href = models.CharField(
+        help_text="Link to overview page for this app.", max_length=128, blank=True
+    )
+
+    def __str__(self):
+        return (
+            f"{self.category} "
+            f"{self.label + ': ' if self.label else ''}"
+            f" ({'ENABLED' if self.enabled else 'DISABLED'})"
+        )
+
+    class Meta:
+        verbose_name_plural = "App Listing Entries"
+
+
+class AppVariant(models.Model):
+    """Model to represent a variant of an app, e.g. a software version or execution environment"""
+
+    APP_TYPES = [
+        ("tapis", "Tapis App"),
+        ("html", "HTML or External app"),
+    ]
+
+    app_id = models.CharField(
+        help_text="The id of this app or app bundle. The id appears in the unique url path to the app.",
+        max_length=64,
+    )
+
+    app_type = models.CharField(
+        help_text="Application type.",
+        max_length=10,
+        choices=APP_TYPES,
+        default="tapis",
+    )
+
+    label = models.CharField(
+        help_text="The display name of this app in the Apps Tray. If not defined, uses notes.label from app definition.",
+        max_length=64,
+        blank=True,
+    )
+
+    license_type = models.CharField(
+        max_length=2, choices=LICENSE_TYPES, help_text="License Type.", default="OS"
+    )
+
+    bundle = models.ForeignKey(
+        AppListingEntry,
+        help_text="Bundle that the app belongs to.",
+        on_delete=models.CASCADE,
+        blank=True,
+        null=True,
+    )
+
+    # HTML Apps
+    html = models.TextField(
+        help_text="HTML definition to display when app is loaded.",
+        default="",
+        blank=True,
+    )
+
+    # Tapis Apps
+    version = models.CharField(
+        help_text="The version number of the app. The app id + version denotes a unique app.",
+        default="",
+        max_length=64,
+        blank=True,
+    )
+
+    enabled = models.BooleanField(
+        help_text="App variant visibility in app tray.", default=True
+    )
+
+    def __str__(self):
+        return f"{self.bundle.label} {self.app_id} {self.version}  ({'ENABLED' if self.enabled else 'DISABLED'})"
diff --git a/designsafe/static/styles/cms-form-styles.css b/designsafe/static/styles/cms-form-styles.css
new file mode 100644
index 0000000000..f8800a15f6
--- /dev/null
+++ b/designsafe/static/styles/cms-form-styles.css
@@ -0,0 +1,4 @@
+.djangocms-admin-style .inline-deletelink {
+    text-indent: 0px !important;
+    width: fit-content !important;
+  }

From 9850f096d6369d997fda270d4629c3adbc4d2b61 Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Tue, 5 Mar 2024 14:10:41 -0600
Subject: [PATCH 18/20] fix fixture import

---
 .../apps/accounts/fixtures/user-data.json     | 64 --------------
 designsafe/apps/api/fixtures/user-data.json   | 69 ---------------
 .../fixtures/agave-oauth-token-data.json      | 28 ------
 .../api/notifications/fixtures/user-data.json | 69 ---------------
 designsafe/apps/api/notifications/tests.py    |  4 +-
 designsafe/apps/api/tests.py                  |  2 +-
 .../box_integration/fixtures/user-data.json   | 69 ---------------
 .../apps/dashboard/fixtures/user-data.json    | 20 -----
 designsafe/apps/rapid/fixtures/user-data.json | 64 --------------
 .../apps/token_access/fixtures/user-data.json | 85 -------------------
 .../fixtures/agave-oauth-token-data.json      | 28 ------
 .../apps/workspace/fixtures/user-data.json    | 46 ----------
 designsafe/apps/workspace/tests.py            |  4 +-
 designsafe/fixtures/auth.json                 | 35 ++++++++
 designsafe/fixtures/user-data.json            | 67 +++++++++++++++
 designsafe/settings/test_settings.py          |  4 +
 16 files changed, 111 insertions(+), 547 deletions(-)
 delete mode 100644 designsafe/apps/accounts/fixtures/user-data.json
 delete mode 100644 designsafe/apps/api/fixtures/user-data.json
 delete mode 100644 designsafe/apps/api/notifications/fixtures/agave-oauth-token-data.json
 delete mode 100644 designsafe/apps/api/notifications/fixtures/user-data.json
 delete mode 100644 designsafe/apps/box_integration/fixtures/user-data.json
 delete mode 100644 designsafe/apps/dashboard/fixtures/user-data.json
 delete mode 100644 designsafe/apps/rapid/fixtures/user-data.json
 delete mode 100644 designsafe/apps/token_access/fixtures/user-data.json
 delete mode 100644 designsafe/apps/workspace/fixtures/agave-oauth-token-data.json
 delete mode 100644 designsafe/apps/workspace/fixtures/user-data.json
 create mode 100644 designsafe/fixtures/auth.json
 create mode 100644 designsafe/fixtures/user-data.json

diff --git a/designsafe/apps/accounts/fixtures/user-data.json b/designsafe/apps/accounts/fixtures/user-data.json
deleted file mode 100644
index d3167a1a0b..0000000000
--- a/designsafe/apps/accounts/fixtures/user-data.json
+++ /dev/null
@@ -1,64 +0,0 @@
-[
-  {
-    "fields": {
-      "username": "ds_admin",
-      "first_name": "DesignSafe",
-      "last_name": "Admin",
-      "is_active": true,
-      "is_superuser": true,
-      "is_staff": true,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "admin@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 1
-  },
-  {
-    "fields": {
-      "username": "envision",
-      "first_name": "DesignSafe",
-      "last_name": "Admin",
-      "is_active": true,
-      "is_superuser": true,
-      "is_staff": true,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "admin@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 3
-  },
-  {
-    "fields": {
-      "username": "ds_user",
-      "first_name": "DesignSafe",
-      "last_name": "User",
-      "is_active": true,
-      "is_superuser": false,
-      "is_staff": false,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "user@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 2
-  },
-  {
-    "fields": {
-      "announcements": true,
-      "user": 2
-    },
-    "model": "designsafe_accounts.notificationpreferences",
-    "pk": 1
-  }
-]
diff --git a/designsafe/apps/api/fixtures/user-data.json b/designsafe/apps/api/fixtures/user-data.json
deleted file mode 100644
index 42379513c1..0000000000
--- a/designsafe/apps/api/fixtures/user-data.json
+++ /dev/null
@@ -1,69 +0,0 @@
-[
-  {
-    "fields": {
-      "username": "ds_admin",
-      "first_name": "DesignSafe",
-      "last_name": "Admin",
-      "is_active": true,
-      "is_superuser": true,
-      "is_staff": true,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "admin@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 1
-  },
-  {
-    "fields": {
-      "username": "envision",
-      "first_name": "DesignSafe",
-      "last_name": "Admin",
-      "is_active": true,
-      "is_superuser": true,
-      "is_staff": true,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "admin@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 3
-  },
-  {
-    "fields": {
-      "username": "ds_user",
-      "first_name": "DesignSafe",
-      "last_name": "User",
-      "is_active": true,
-      "is_superuser": false,
-      "is_staff": false,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "user@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 2
-  },
-  {
-    "fields": {
-      "user_id": "2",
-      "token_type": "Bearer",
-      "scope": "PRODUCTION",
-      "access_token": "fakeaccesstoken",
-      "refresh_token": "fakerefreshtoken",
-      "expires_in": "14400",
-      "created": "1459433273"
-    },
-    "model": "designsafe_auth.agaveoauthtoken",
-    "pk": 1
-  }
-]
diff --git a/designsafe/apps/api/notifications/fixtures/agave-oauth-token-data.json b/designsafe/apps/api/notifications/fixtures/agave-oauth-token-data.json
deleted file mode 100644
index c5204eb8a8..0000000000
--- a/designsafe/apps/api/notifications/fixtures/agave-oauth-token-data.json
+++ /dev/null
@@ -1,28 +0,0 @@
-[
-{
-    "fields": {
-        "created": 1461727485,
-        "access_token": "dc48198091d73c8933c2c0ee96afb01b",
-        "expires_in": 14400,
-        "token_type": "bearer",
-        "user": 1,
-        "scope": "default",
-        "refresh_token": "2f715c8eb6962a883c7cd29af7d1165"
-    },
-    "model": "designsafe_auth.agaveoauthtoken",
-    "pk": 1
-},
-{
-    "fields": {
-        "created": 1463178660,
-        "access_token": "7834a55e92f3f9b86dc1627bff8d43",
-        "expires_in": 14400,
-        "token_type": "bearer",
-        "user": 2,
-        "scope": "default",
-        "refresh_token": "dc1c5b9a5124f88147c783e35b5ca9c"
-    },
-    "model": "designsafe_auth.agaveoauthtoken",
-    "pk": 2
-}
-]
diff --git a/designsafe/apps/api/notifications/fixtures/user-data.json b/designsafe/apps/api/notifications/fixtures/user-data.json
deleted file mode 100644
index 42379513c1..0000000000
--- a/designsafe/apps/api/notifications/fixtures/user-data.json
+++ /dev/null
@@ -1,69 +0,0 @@
-[
-  {
-    "fields": {
-      "username": "ds_admin",
-      "first_name": "DesignSafe",
-      "last_name": "Admin",
-      "is_active": true,
-      "is_superuser": true,
-      "is_staff": true,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "admin@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 1
-  },
-  {
-    "fields": {
-      "username": "envision",
-      "first_name": "DesignSafe",
-      "last_name": "Admin",
-      "is_active": true,
-      "is_superuser": true,
-      "is_staff": true,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "admin@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 3
-  },
-  {
-    "fields": {
-      "username": "ds_user",
-      "first_name": "DesignSafe",
-      "last_name": "User",
-      "is_active": true,
-      "is_superuser": false,
-      "is_staff": false,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "user@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 2
-  },
-  {
-    "fields": {
-      "user_id": "2",
-      "token_type": "Bearer",
-      "scope": "PRODUCTION",
-      "access_token": "fakeaccesstoken",
-      "refresh_token": "fakerefreshtoken",
-      "expires_in": "14400",
-      "created": "1459433273"
-    },
-    "model": "designsafe_auth.agaveoauthtoken",
-    "pk": 1
-  }
-]
diff --git a/designsafe/apps/api/notifications/tests.py b/designsafe/apps/api/notifications/tests.py
index cf692feaba..74d9704a8e 100644
--- a/designsafe/apps/api/notifications/tests.py
+++ b/designsafe/apps/api/notifications/tests.py
@@ -28,7 +28,7 @@
 # Create your tests here.
 @skip("Need to mock websocket call to redis")
 class NotificationsTestCase(TestCase):
-    fixtures = ["user-data.json", "agave-oauth-token-data.json"]
+    fixtures = ["user-data.json", "auth.json"]
 
     def setUp(self):
         self.wh_url = reverse("designsafe_api:jobs_wh_handler")
@@ -103,7 +103,7 @@ def test_2_webhooks_same_status_different_jobId_should_give_2_notifications(self
 
 
 class TestWebhookViews(TestCase):
-    fixtures = ["user-data", "agave-oauth-token-data"]
+    fixtures = ["user-data", "auth"]
 
     def setUp(self):
         self.wh_url = reverse("designsafe_api:jobs_wh_handler")
diff --git a/designsafe/apps/api/tests.py b/designsafe/apps/api/tests.py
index 38a7168eb7..1ad34c673e 100644
--- a/designsafe/apps/api/tests.py
+++ b/designsafe/apps/api/tests.py
@@ -17,7 +17,7 @@
 
 # Create your tests here.
 class ProjectDataModelsTestCase(TestCase):
-    fixtures = ['user-data.json', 'agave-oauth-token-data.json']
+    fixtures = ['user-data.json', 'auth.json']
 
     def setUp(self):
         user = get_user_model().objects.get(pk=2)
diff --git a/designsafe/apps/box_integration/fixtures/user-data.json b/designsafe/apps/box_integration/fixtures/user-data.json
deleted file mode 100644
index 42379513c1..0000000000
--- a/designsafe/apps/box_integration/fixtures/user-data.json
+++ /dev/null
@@ -1,69 +0,0 @@
-[
-  {
-    "fields": {
-      "username": "ds_admin",
-      "first_name": "DesignSafe",
-      "last_name": "Admin",
-      "is_active": true,
-      "is_superuser": true,
-      "is_staff": true,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "admin@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 1
-  },
-  {
-    "fields": {
-      "username": "envision",
-      "first_name": "DesignSafe",
-      "last_name": "Admin",
-      "is_active": true,
-      "is_superuser": true,
-      "is_staff": true,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "admin@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 3
-  },
-  {
-    "fields": {
-      "username": "ds_user",
-      "first_name": "DesignSafe",
-      "last_name": "User",
-      "is_active": true,
-      "is_superuser": false,
-      "is_staff": false,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "user@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 2
-  },
-  {
-    "fields": {
-      "user_id": "2",
-      "token_type": "Bearer",
-      "scope": "PRODUCTION",
-      "access_token": "fakeaccesstoken",
-      "refresh_token": "fakerefreshtoken",
-      "expires_in": "14400",
-      "created": "1459433273"
-    },
-    "model": "designsafe_auth.agaveoauthtoken",
-    "pk": 1
-  }
-]
diff --git a/designsafe/apps/dashboard/fixtures/user-data.json b/designsafe/apps/dashboard/fixtures/user-data.json
deleted file mode 100644
index ce1d49e299..0000000000
--- a/designsafe/apps/dashboard/fixtures/user-data.json
+++ /dev/null
@@ -1,20 +0,0 @@
-[
-  {
-    "fields": {
-      "username": "ds_user",
-      "first_name": "DesignSafe",
-      "last_name": "User",
-      "is_active": true,
-      "is_superuser": false,
-      "is_staff": false,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "user@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 2
-  }
-]
diff --git a/designsafe/apps/rapid/fixtures/user-data.json b/designsafe/apps/rapid/fixtures/user-data.json
deleted file mode 100644
index d3167a1a0b..0000000000
--- a/designsafe/apps/rapid/fixtures/user-data.json
+++ /dev/null
@@ -1,64 +0,0 @@
-[
-  {
-    "fields": {
-      "username": "ds_admin",
-      "first_name": "DesignSafe",
-      "last_name": "Admin",
-      "is_active": true,
-      "is_superuser": true,
-      "is_staff": true,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "admin@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 1
-  },
-  {
-    "fields": {
-      "username": "envision",
-      "first_name": "DesignSafe",
-      "last_name": "Admin",
-      "is_active": true,
-      "is_superuser": true,
-      "is_staff": true,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "admin@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 3
-  },
-  {
-    "fields": {
-      "username": "ds_user",
-      "first_name": "DesignSafe",
-      "last_name": "User",
-      "is_active": true,
-      "is_superuser": false,
-      "is_staff": false,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "user@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 2
-  },
-  {
-    "fields": {
-      "announcements": true,
-      "user": 2
-    },
-    "model": "designsafe_accounts.notificationpreferences",
-    "pk": 1
-  }
-]
diff --git a/designsafe/apps/token_access/fixtures/user-data.json b/designsafe/apps/token_access/fixtures/user-data.json
deleted file mode 100644
index dd8fc339c5..0000000000
--- a/designsafe/apps/token_access/fixtures/user-data.json
+++ /dev/null
@@ -1,85 +0,0 @@
-[
-  {
-    "fields": {
-      "username": "ds_admin",
-      "first_name": "DesignSafe",
-      "last_name": "Admin",
-      "is_active": true,
-      "is_superuser": true,
-      "is_staff": true,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "admin@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 1
-  },
-  {
-    "fields": {
-      "username": "envision",
-      "first_name": "DesignSafe",
-      "last_name": "Admin",
-      "is_active": true,
-      "is_superuser": true,
-      "is_staff": true,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "admin@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 3
-  },
-  {
-    "fields": {
-      "username": "ds_user",
-      "first_name": "DesignSafe",
-      "last_name": "User",
-      "is_active": true,
-      "is_superuser": false,
-      "is_staff": false,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "user@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 2
-  },
-  {
-    "fields": {
-      "nickname": "Test Token",
-      "user": 2,
-      "created": "2016-09-06T00:00:00.000Z"
-    },
-    "model": "token_access.token",
-    "pk": "5da84493fa0037de0945631d1f9df5c00cdcac49"
-  },
-
-  {
-    "model": "designsafe_accounts.designsafeprofile",
-    "pk": 5610,
-    "fields": {
-      "user": 2,
-      "ethnicity": "Asian",
-      "gender": "Male",
-      "agree_to_account_limit": "2020-07-02T23:41:19.342Z",
-      "bio": null,
-      "website": null,
-      "orcid_id": null,
-      "professional_level": null,
-      "update_required": true,
-      "last_updated": "2020-07-02T23:41:19.343Z",
-      "nh_interests": [],
-      "nh_technical_domains": [],
-      "research_activities": []
-    }
-  }
-]
\ No newline at end of file
diff --git a/designsafe/apps/workspace/fixtures/agave-oauth-token-data.json b/designsafe/apps/workspace/fixtures/agave-oauth-token-data.json
deleted file mode 100644
index c5204eb8a8..0000000000
--- a/designsafe/apps/workspace/fixtures/agave-oauth-token-data.json
+++ /dev/null
@@ -1,28 +0,0 @@
-[
-{
-    "fields": {
-        "created": 1461727485,
-        "access_token": "dc48198091d73c8933c2c0ee96afb01b",
-        "expires_in": 14400,
-        "token_type": "bearer",
-        "user": 1,
-        "scope": "default",
-        "refresh_token": "2f715c8eb6962a883c7cd29af7d1165"
-    },
-    "model": "designsafe_auth.agaveoauthtoken",
-    "pk": 1
-},
-{
-    "fields": {
-        "created": 1463178660,
-        "access_token": "7834a55e92f3f9b86dc1627bff8d43",
-        "expires_in": 14400,
-        "token_type": "bearer",
-        "user": 2,
-        "scope": "default",
-        "refresh_token": "dc1c5b9a5124f88147c783e35b5ca9c"
-    },
-    "model": "designsafe_auth.agaveoauthtoken",
-    "pk": 2
-}
-]
diff --git a/designsafe/apps/workspace/fixtures/user-data.json b/designsafe/apps/workspace/fixtures/user-data.json
deleted file mode 100644
index 1d36bec677..0000000000
--- a/designsafe/apps/workspace/fixtures/user-data.json
+++ /dev/null
@@ -1,46 +0,0 @@
-[
-  {
-    "fields": {
-      "username": "ds_admin",
-      "first_name": "DesignSafe",
-      "last_name": "Admin",
-      "is_active": true,
-      "is_superuser": true,
-      "is_staff": true,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "admin@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 1
-  },
-  {
-    "fields": {
-      "username": "ds_user",
-      "first_name": "DesignSafe",
-      "last_name": "User",
-      "is_active": true,
-      "is_superuser": false,
-      "is_staff": false,
-      "last_login": "2016-03-01T00:00:00.000Z",
-      "groups": [],
-      "user_permissions": [],
-      "password": "",
-      "email": "user@designsafe-ci.org",
-      "date_joined": "2016-03-01T00:00:00.000Z"
-    },
-    "model": "auth.user",
-    "pk": 2
-  },
-  {
-    "fields": {
-      "announcements": true,
-      "user": 2
-    },
-    "model": "designsafe_accounts.notificationpreferences",
-    "pk": 1
-  }
-]
diff --git a/designsafe/apps/workspace/tests.py b/designsafe/apps/workspace/tests.py
index 1e8d7088e5..a808ac506d 100644
--- a/designsafe/apps/workspace/tests.py
+++ b/designsafe/apps/workspace/tests.py
@@ -8,7 +8,7 @@
 
 class AppDescriptionModelTest(TestCase):
 
-    fixtures = ["user-data", "agave-oauth-token-data"]
+    fixtures = ["user-data", "auth"]
 
     def setUp(self):
         user = get_user_model().objects.get(pk=2)
@@ -32,7 +32,7 @@ def test_get_app_description(self):
 
 
 class TestAppsApiViews(TestCase):
-    fixtures = ["user-data", "agave-oauth-token-data"]
+    fixtures = ["user-data", "auth"]
 
     @classmethod
     def setUpClass(cls):
diff --git a/designsafe/fixtures/auth.json b/designsafe/fixtures/auth.json
new file mode 100644
index 0000000000..b338bfb9e9
--- /dev/null
+++ b/designsafe/fixtures/auth.json
@@ -0,0 +1,35 @@
+[
+    {
+        "model": "designsafe_auth.tapisoauthtoken",
+        "pk": 1,
+        "fields": {
+            "user": 1,
+            "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJqdGkiOiJkMGU1YWZiZi05Yzk3LTQyOTMtOTNlMS1jYWIyYzAxY2JhMDAiLCJpc3MiOiJodHRwczovL2Rldi5kZXZlbG9wLnRhcGlzLmlvL3YzL3Rva2VucyIsInN1YiI6InRlc3R1c2VyMjAwQGRldiIsInRhcGlzL3RlbmFudF9pZCI6ImRldiIsInRhcGlzL3Rva2VuX3R5cGUiOiJhY2Nlc3MiLCJ0YXBpcy9kZWxlZ2F0aW9uIjpmYWxzZSwidGFwaXMvZGVsZWdhdGlvbl9zdWIiOm51bGwsInRhcGlzL3VzZXJuYW1lIjoidGVzdHVzZXIyMDAiLCJ0YXBpcy9hY2NvdW50X3R5cGUiOiJ1c2VyIiwiZXhwIjoxNjU2MDE5MzM1fQ.2mevJWnoS-nlUNfna17berL1HKCHKaPuX6BGi8RZQTQV2meFRLNhAu8B0nDJvROTqYiHna23N2h_FEgS51kRhpwL8N3zTuguh2cT090GxzCFw1QnI1V2rNK4zZjvxagciJxov8SbaOgta6H6_AUentKi_NFjpYTerPRjCDkuCwYitvGOJdzTUFY7cn8SX6JQvlRkcwQ7I0bfC5JN5m5Q0trPD5r2-VDIElI5JVY_isMMT9O5-lT1HTIN1BCYoOnLPgza6vkZeWdArsW9bcvpMANjDlK3mWFtc1fEybN6O3c9RaxRj8GO8zNoyngNH7h6DXeEGdsVJcrt9VWI-nW8iA",
+            "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJqdGkiOiIwYTYzNTAxOS1mNTllLTQxMjItOGUwNi0zZmRkYTNmMzYzNWEiLCJpc3MiOiJodHRwczovL2Rldi5kZXZlbG9wLnRhcGlzLmlvL3YzL3Rva2VucyIsInN1YiI6InRlc3R1c2VyMjAwQGRldiIsInRhcGlzL2luaXRpYWxfdHRsIjo2MDAsInRhcGlzL3RlbmFudF9pZCI6ImRldiIsInRhcGlzL3Rva2VuX3R5cGUiOiJyZWZyZXNoIiwiZXhwIjoxNjU2MDE5MzM1LCJ0YXBpcy9hY2Nlc3NfdG9rZW4iOnsianRpIjoiZDBlNWFmYmYtOWM5Ny00MjkzLTkzZTEtY2FiMmMwMWNiYTAwIiwiaXNzIjoiaHR0cHM6Ly9kZXYuZGV2ZWxvcC50YXBpcy5pby92My90b2tlbnMiLCJzdWIiOiJ0ZXN0dXNlcjIwMEBkZXYiLCJ0YXBpcy90ZW5hbnRfaWQiOiJkZXYiLCJ0YXBpcy90b2tlbl90eXBlIjoiYWNjZXNzIiwidGFwaXMvZGVsZWdhdGlvbiI6ZmFsc2UsInRhcGlzL2RlbGVnYXRpb25fc3ViIjpudWxsLCJ0YXBpcy91c2VybmFtZSI6InRlc3R1c2VyMjAwIiwidGFwaXMvYWNjb3VudF90eXBlIjoidXNlciIsInR0bCI6NjAwfX0.WI9vfN6SPNJwDR9uOaJ16quGzyKl-RWoaDwbOaQa1gpSQoutw8lBqsifzUb0WEJ9fqg8ZWAwbuu-IJikXTiwOiUqWy-09yHxNtCFpBARpY-jurMe20HbDCSlPGICpf8Bend-3tMSnf5c9JyuAgbVx1fnqSjhY3V7yiTVzCur-mOWqI47TiflDnddPscyQj7HBawwadinSiSwQKbnXw2FNkRIdKRrCEOaecKaZ-Hb69vHbi-A3D-HP80nhZzuQW8vzg0L_3cyGOh_Y-8qu22_21UfJwS_nWEizjrs9WTU5hCGpn2Da8U035gk01eC4S9J_WIhZjUhBRneB14QfgTNvg",
+            "expires_in": 1325391984000,
+            "created": 1536692280
+        }
+    },
+    {
+        "model": "designsafe_auth.tapisoauthtoken",
+        "pk": 2,
+        "fields": {
+            "user": 2,
+            "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJqdGkiOiJkMGU1YWZiZi05Yzk3LTQyOTMtOTNlMS1jYWIyYzAxY2JhMDAiLCJpc3MiOiJodHRwczovL2Rldi5kZXZlbG9wLnRhcGlzLmlvL3YzL3Rva2VucyIsInN1YiI6InRlc3R1c2VyMjAwQGRldiIsInRhcGlzL3RlbmFudF9pZCI6ImRldiIsInRhcGlzL3Rva2VuX3R5cGUiOiJhY2Nlc3MiLCJ0YXBpcy9kZWxlZ2F0aW9uIjpmYWxzZSwidGFwaXMvZGVsZWdhdGlvbl9zdWIiOm51bGwsInRhcGlzL3VzZXJuYW1lIjoidGVzdHVzZXIyMDAiLCJ0YXBpcy9hY2NvdW50X3R5cGUiOiJ1c2VyIiwiZXhwIjoxNjU2MDE5MzM1fQ.2mevJWnoS-nlUNfna17berL1HKCHKaPuX6BGi8RZQTQV2meFRLNhAu8B0nDJvROTqYiHna23N2h_FEgS51kRhpwL8N3zTuguh2cT090GxzCFw1QnI1V2rNK4zZjvxagciJxov8SbaOgta6H6_AUentKi_NFjpYTerPRjCDkuCwYitvGOJdzTUFY7cn8SX6JQvlRkcwQ7I0bfC5JN5m5Q0trPD5r2-VDIElI5JVY_isMMT9O5-lT1HTIN1BCYoOnLPgza6vkZeWdArsW9bcvpMANjDlK3mWFtc1fEybN6O3c9RaxRj8GO8zNoyngNH7h6DXeEGdsVJcrt9VWI-nW8iA",
+            "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJqdGkiOiIwYTYzNTAxOS1mNTllLTQxMjItOGUwNi0zZmRkYTNmMzYzNWEiLCJpc3MiOiJodHRwczovL2Rldi5kZXZlbG9wLnRhcGlzLmlvL3YzL3Rva2VucyIsInN1YiI6InRlc3R1c2VyMjAwQGRldiIsInRhcGlzL2luaXRpYWxfdHRsIjo2MDAsInRhcGlzL3RlbmFudF9pZCI6ImRldiIsInRhcGlzL3Rva2VuX3R5cGUiOiJyZWZyZXNoIiwiZXhwIjoxNjU2MDE5MzM1LCJ0YXBpcy9hY2Nlc3NfdG9rZW4iOnsianRpIjoiZDBlNWFmYmYtOWM5Ny00MjkzLTkzZTEtY2FiMmMwMWNiYTAwIiwiaXNzIjoiaHR0cHM6Ly9kZXYuZGV2ZWxvcC50YXBpcy5pby92My90b2tlbnMiLCJzdWIiOiJ0ZXN0dXNlcjIwMEBkZXYiLCJ0YXBpcy90ZW5hbnRfaWQiOiJkZXYiLCJ0YXBpcy90b2tlbl90eXBlIjoiYWNjZXNzIiwidGFwaXMvZGVsZWdhdGlvbiI6ZmFsc2UsInRhcGlzL2RlbGVnYXRpb25fc3ViIjpudWxsLCJ0YXBpcy91c2VybmFtZSI6InRlc3R1c2VyMjAwIiwidGFwaXMvYWNjb3VudF90eXBlIjoidXNlciIsInR0bCI6NjAwfX0.WI9vfN6SPNJwDR9uOaJ16quGzyKl-RWoaDwbOaQa1gpSQoutw8lBqsifzUb0WEJ9fqg8ZWAwbuu-IJikXTiwOiUqWy-09yHxNtCFpBARpY-jurMe20HbDCSlPGICpf8Bend-3tMSnf5c9JyuAgbVx1fnqSjhY3V7yiTVzCur-mOWqI47TiflDnddPscyQj7HBawwadinSiSwQKbnXw2FNkRIdKRrCEOaecKaZ-Hb69vHbi-A3D-HP80nhZzuQW8vzg0L_3cyGOh_Y-8qu22_21UfJwS_nWEizjrs9WTU5hCGpn2Da8U035gk01eC4S9J_WIhZjUhBRneB14QfgTNvg",
+            "expires_in": 1325391984000,
+            "created": 1536700041
+        }
+    },
+    {
+        "model": "designsafe_auth.tapisoauthtoken",
+        "pk": 3,
+        "fields": {
+            "user": 3,
+            "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJqdGkiOiJkMGU1YWZiZi05Yzk3LTQyOTMtOTNlMS1jYWIyYzAxY2JhMDAiLCJpc3MiOiJodHRwczovL2Rldi5kZXZlbG9wLnRhcGlzLmlvL3YzL3Rva2VucyIsInN1YiI6InRlc3R1c2VyMjAwQGRldiIsInRhcGlzL3RlbmFudF9pZCI6ImRldiIsInRhcGlzL3Rva2VuX3R5cGUiOiJhY2Nlc3MiLCJ0YXBpcy9kZWxlZ2F0aW9uIjpmYWxzZSwidGFwaXMvZGVsZWdhdGlvbl9zdWIiOm51bGwsInRhcGlzL3VzZXJuYW1lIjoidGVzdHVzZXIyMDAiLCJ0YXBpcy9hY2NvdW50X3R5cGUiOiJ1c2VyIiwiZXhwIjoxNjU2MDE5MzM1fQ.2mevJWnoS-nlUNfna17berL1HKCHKaPuX6BGi8RZQTQV2meFRLNhAu8B0nDJvROTqYiHna23N2h_FEgS51kRhpwL8N3zTuguh2cT090GxzCFw1QnI1V2rNK4zZjvxagciJxov8SbaOgta6H6_AUentKi_NFjpYTerPRjCDkuCwYitvGOJdzTUFY7cn8SX6JQvlRkcwQ7I0bfC5JN5m5Q0trPD5r2-VDIElI5JVY_isMMT9O5-lT1HTIN1BCYoOnLPgza6vkZeWdArsW9bcvpMANjDlK3mWFtc1fEybN6O3c9RaxRj8GO8zNoyngNH7h6DXeEGdsVJcrt9VWI-nW8iA",
+            "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJqdGkiOiIwYTYzNTAxOS1mNTllLTQxMjItOGUwNi0zZmRkYTNmMzYzNWEiLCJpc3MiOiJodHRwczovL2Rldi5kZXZlbG9wLnRhcGlzLmlvL3YzL3Rva2VucyIsInN1YiI6InRlc3R1c2VyMjAwQGRldiIsInRhcGlzL2luaXRpYWxfdHRsIjo2MDAsInRhcGlzL3RlbmFudF9pZCI6ImRldiIsInRhcGlzL3Rva2VuX3R5cGUiOiJyZWZyZXNoIiwiZXhwIjoxNjU2MDE5MzM1LCJ0YXBpcy9hY2Nlc3NfdG9rZW4iOnsianRpIjoiZDBlNWFmYmYtOWM5Ny00MjkzLTkzZTEtY2FiMmMwMWNiYTAwIiwiaXNzIjoiaHR0cHM6Ly9kZXYuZGV2ZWxvcC50YXBpcy5pby92My90b2tlbnMiLCJzdWIiOiJ0ZXN0dXNlcjIwMEBkZXYiLCJ0YXBpcy90ZW5hbnRfaWQiOiJkZXYiLCJ0YXBpcy90b2tlbl90eXBlIjoiYWNjZXNzIiwidGFwaXMvZGVsZWdhdGlvbiI6ZmFsc2UsInRhcGlzL2RlbGVnYXRpb25fc3ViIjpudWxsLCJ0YXBpcy91c2VybmFtZSI6InRlc3R1c2VyMjAwIiwidGFwaXMvYWNjb3VudF90eXBlIjoidXNlciIsInR0bCI6NjAwfX0.WI9vfN6SPNJwDR9uOaJ16quGzyKl-RWoaDwbOaQa1gpSQoutw8lBqsifzUb0WEJ9fqg8ZWAwbuu-IJikXTiwOiUqWy-09yHxNtCFpBARpY-jurMe20HbDCSlPGICpf8Bend-3tMSnf5c9JyuAgbVx1fnqSjhY3V7yiTVzCur-mOWqI47TiflDnddPscyQj7HBawwadinSiSwQKbnXw2FNkRIdKRrCEOaecKaZ-Hb69vHbi-A3D-HP80nhZzuQW8vzg0L_3cyGOh_Y-8qu22_21UfJwS_nWEizjrs9WTU5hCGpn2Da8U035gk01eC4S9J_WIhZjUhBRneB14QfgTNvg",
+            "expires_in": 1325391984000,
+            "created": 1536700084
+        }
+    }
+]
diff --git a/designsafe/fixtures/user-data.json b/designsafe/fixtures/user-data.json
new file mode 100644
index 0000000000..103ad32194
--- /dev/null
+++ b/designsafe/fixtures/user-data.json
@@ -0,0 +1,67 @@
+[
+    {
+      "fields": {
+        "username": "ds_admin",
+        "first_name": "DesignSafe",
+        "last_name": "Admin",
+        "is_active": true,
+        "is_superuser": true,
+        "is_staff": true,
+        "last_login": "2016-03-01T00:00:00.000Z",
+        "groups": [],
+        "user_permissions": [],
+        "password": "",
+        "email": "admin@designsafe-ci.org",
+        "date_joined": "2016-03-01T00:00:00.000Z"
+      },
+      "model": "auth.user",
+      "pk": 1
+    },
+    {
+      "fields": {
+        "username": "envision",
+        "first_name": "DesignSafe",
+        "last_name": "Admin",
+        "is_active": true,
+        "is_superuser": true,
+        "is_staff": true,
+        "last_login": "2016-03-01T00:00:00.000Z",
+        "groups": [],
+        "user_permissions": [],
+        "password": "",
+        "email": "admin@designsafe-ci.org",
+        "date_joined": "2016-03-01T00:00:00.000Z"
+      },
+      "model": "auth.user",
+      "pk": 3
+    },
+    {
+      "fields": {
+        "username": "ds_user",
+        "first_name": "DesignSafe",
+        "last_name": "User",
+        "is_active": true,
+        "is_superuser": false,
+        "is_staff": false,
+        "last_login": "2016-03-01T00:00:00.000Z",
+        "groups": [],
+        "user_permissions": [],
+        "password": "",
+        "email": "user@designsafe-ci.org",
+        "date_joined": "2016-03-01T00:00:00.000Z"
+      },
+      "model": "auth.user",
+      "pk": 2
+    },
+    {
+      "fields": {
+        "user": 2,
+        "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJqdGkiOiJkMGU1YWZiZi05Yzk3LTQyOTMtOTNlMS1jYWIyYzAxY2JhMDAiLCJpc3MiOiJodHRwczovL2Rldi5kZXZlbG9wLnRhcGlzLmlvL3YzL3Rva2VucyIsInN1YiI6InRlc3R1c2VyMjAwQGRldiIsInRhcGlzL3RlbmFudF9pZCI6ImRldiIsInRhcGlzL3Rva2VuX3R5cGUiOiJhY2Nlc3MiLCJ0YXBpcy9kZWxlZ2F0aW9uIjpmYWxzZSwidGFwaXMvZGVsZWdhdGlvbl9zdWIiOm51bGwsInRhcGlzL3VzZXJuYW1lIjoidGVzdHVzZXIyMDAiLCJ0YXBpcy9hY2NvdW50X3R5cGUiOiJ1c2VyIiwiZXhwIjoxNjU2MDE5MzM1fQ.2mevJWnoS-nlUNfna17berL1HKCHKaPuX6BGi8RZQTQV2meFRLNhAu8B0nDJvROTqYiHna23N2h_FEgS51kRhpwL8N3zTuguh2cT090GxzCFw1QnI1V2rNK4zZjvxagciJxov8SbaOgta6H6_AUentKi_NFjpYTerPRjCDkuCwYitvGOJdzTUFY7cn8SX6JQvlRkcwQ7I0bfC5JN5m5Q0trPD5r2-VDIElI5JVY_isMMT9O5-lT1HTIN1BCYoOnLPgza6vkZeWdArsW9bcvpMANjDlK3mWFtc1fEybN6O3c9RaxRj8GO8zNoyngNH7h6DXeEGdsVJcrt9VWI-nW8iA",
+        "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJqdGkiOiIwYTYzNTAxOS1mNTllLTQxMjItOGUwNi0zZmRkYTNmMzYzNWEiLCJpc3MiOiJodHRwczovL2Rldi5kZXZlbG9wLnRhcGlzLmlvL3YzL3Rva2VucyIsInN1YiI6InRlc3R1c2VyMjAwQGRldiIsInRhcGlzL2luaXRpYWxfdHRsIjo2MDAsInRhcGlzL3RlbmFudF9pZCI6ImRldiIsInRhcGlzL3Rva2VuX3R5cGUiOiJyZWZyZXNoIiwiZXhwIjoxNjU2MDE5MzM1LCJ0YXBpcy9hY2Nlc3NfdG9rZW4iOnsianRpIjoiZDBlNWFmYmYtOWM5Ny00MjkzLTkzZTEtY2FiMmMwMWNiYTAwIiwiaXNzIjoiaHR0cHM6Ly9kZXYuZGV2ZWxvcC50YXBpcy5pby92My90b2tlbnMiLCJzdWIiOiJ0ZXN0dXNlcjIwMEBkZXYiLCJ0YXBpcy90ZW5hbnRfaWQiOiJkZXYiLCJ0YXBpcy90b2tlbl90eXBlIjoiYWNjZXNzIiwidGFwaXMvZGVsZWdhdGlvbiI6ZmFsc2UsInRhcGlzL2RlbGVnYXRpb25fc3ViIjpudWxsLCJ0YXBpcy91c2VybmFtZSI6InRlc3R1c2VyMjAwIiwidGFwaXMvYWNjb3VudF90eXBlIjoidXNlciIsInR0bCI6NjAwfX0.WI9vfN6SPNJwDR9uOaJ16quGzyKl-RWoaDwbOaQa1gpSQoutw8lBqsifzUb0WEJ9fqg8ZWAwbuu-IJikXTiwOiUqWy-09yHxNtCFpBARpY-jurMe20HbDCSlPGICpf8Bend-3tMSnf5c9JyuAgbVx1fnqSjhY3V7yiTVzCur-mOWqI47TiflDnddPscyQj7HBawwadinSiSwQKbnXw2FNkRIdKRrCEOaecKaZ-Hb69vHbi-A3D-HP80nhZzuQW8vzg0L_3cyGOh_Y-8qu22_21UfJwS_nWEizjrs9WTU5hCGpn2Da8U035gk01eC4S9J_WIhZjUhBRneB14QfgTNvg",
+        "expires_in": 1325391984000,
+        "created": 1536700084
+      },
+      "model": "designsafe_auth.tapisoauthtoken",
+      "pk": 1
+    }
+  ]
diff --git a/designsafe/settings/test_settings.py b/designsafe/settings/test_settings.py
index 1789ae1495..beda2a591a 100644
--- a/designsafe/settings/test_settings.py
+++ b/designsafe/settings/test_settings.py
@@ -234,6 +234,10 @@
 MEDIA_ROOT = '/srv/www/designsafe/media/'
 MEDIA_URL = '/media/'
 
+FIXTURE_DIRS = [
+    os.path.join(BASE_DIR, 'designsafe', 'fixtures'),
+]
+
 
 #####
 #

From 9221e6c7895960dd47908087e942c73411973c73 Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Tue, 5 Mar 2024 14:31:03 -0600
Subject: [PATCH 19/20] skip tapisv2 tests; add todov3 notes; fix fixture

---
 designsafe/apps/accounts/tests.py             |   2 +-
 .../api/fixtures/agave-oauth-token-data.json  |  28 ---
 designsafe/apps/api/notifications/tests.py    |   1 +
 designsafe/apps/api/projects/tests.py         |  12 +-
 designsafe/apps/workspace/tests.py            |   3 +
 designsafe/fixtures/user-data.json            | 160 +++++++++++-------
 6 files changed, 110 insertions(+), 96 deletions(-)
 delete mode 100644 designsafe/apps/api/fixtures/agave-oauth-token-data.json

diff --git a/designsafe/apps/accounts/tests.py b/designsafe/apps/accounts/tests.py
index 7649deafb5..a0a95b0d9e 100644
--- a/designsafe/apps/accounts/tests.py
+++ b/designsafe/apps/accounts/tests.py
@@ -38,7 +38,7 @@ def test_mailing_list_access(self):
         self.client.login(username='ds_user', password='user/password')
         resp = self.client.get(url)
         self.assertEqual(resp.status_code, 403)
-        self.client.logout()
+
         user = get_user_model().objects.get(pk=2)
         perm = Permission.objects.get(codename='view_notification_subscribers')
         user.user_permissions.add(perm)
diff --git a/designsafe/apps/api/fixtures/agave-oauth-token-data.json b/designsafe/apps/api/fixtures/agave-oauth-token-data.json
deleted file mode 100644
index c5204eb8a8..0000000000
--- a/designsafe/apps/api/fixtures/agave-oauth-token-data.json
+++ /dev/null
@@ -1,28 +0,0 @@
-[
-{
-    "fields": {
-        "created": 1461727485,
-        "access_token": "dc48198091d73c8933c2c0ee96afb01b",
-        "expires_in": 14400,
-        "token_type": "bearer",
-        "user": 1,
-        "scope": "default",
-        "refresh_token": "2f715c8eb6962a883c7cd29af7d1165"
-    },
-    "model": "designsafe_auth.agaveoauthtoken",
-    "pk": 1
-},
-{
-    "fields": {
-        "created": 1463178660,
-        "access_token": "7834a55e92f3f9b86dc1627bff8d43",
-        "expires_in": 14400,
-        "token_type": "bearer",
-        "user": 2,
-        "scope": "default",
-        "refresh_token": "dc1c5b9a5124f88147c783e35b5ca9c"
-    },
-    "model": "designsafe_auth.agaveoauthtoken",
-    "pk": 2
-}
-]
diff --git a/designsafe/apps/api/notifications/tests.py b/designsafe/apps/api/notifications/tests.py
index 74d9704a8e..b0faf72ef8 100644
--- a/designsafe/apps/api/notifications/tests.py
+++ b/designsafe/apps/api/notifications/tests.py
@@ -102,6 +102,7 @@ def test_2_webhooks_same_status_different_jobId_should_give_2_notifications(self
         self.assertEqual(Notification.objects.count(), 2)
 
 
+@skip("TODOv3: Update webhooks with Tapisv3")
 class TestWebhookViews(TestCase):
     fixtures = ["user-data", "auth"]
 
diff --git a/designsafe/apps/api/projects/tests.py b/designsafe/apps/api/projects/tests.py
index c2c63069e7..0b844416a2 100644
--- a/designsafe/apps/api/projects/tests.py
+++ b/designsafe/apps/api/projects/tests.py
@@ -1,18 +1,20 @@
 from designsafe.apps.api.projects.fixtures import exp_instance_meta, exp_instance_resp, exp_entity_meta, exp_entity_json
 import pytest
 
+@pytest.mark.skip(reason="TODOv3: Update projects with Tapisv3")
 @pytest.mark.django_db
-def test_project_instance_get(client, mock_agave_client, authenticated_user):
-    mock_agave_client.meta.getMetadata.return_value = exp_instance_meta
+def test_project_instance_get(client, mock_tapis_client, authenticated_user):
+    mock_tapis_client.meta.getMetadata.return_value = exp_instance_meta
     resp = client.get('/api/projects/1052668239654088215-242ac119-0001-012/')
     actual = resp.json()
     expected = exp_instance_resp
     assert actual == expected
 
+@pytest.mark.skip(reason="TODOv3: Update projects with Tapisv3")
 @pytest.mark.django_db
-def test_project_meta_all(client, mock_agave_client, authenticated_user):
-    mock_agave_client.meta.getMetadata.return_value = exp_instance_meta
-    mock_agave_client.meta.listMetadata.return_value = exp_entity_meta
+def test_project_meta_all(client, mock_tapis_client, authenticated_user):
+    mock_tapis_client.meta.getMetadata.return_value = exp_instance_meta
+    mock_tapis_client.meta.listMetadata.return_value = exp_entity_meta
     resp = client.get('/api/projects/1052668239654088215-242ac119-0001-012/meta/all/')
     actual = resp.json()
     expected = exp_entity_json
diff --git a/designsafe/apps/workspace/tests.py b/designsafe/apps/workspace/tests.py
index a808ac506d..0e09e88518 100644
--- a/designsafe/apps/workspace/tests.py
+++ b/designsafe/apps/workspace/tests.py
@@ -2,10 +2,12 @@
 from mock import patch
 from django.test import TestCase
 from .models.app_descriptions import AppDescription
+from unittest import skip
 from django.urls import reverse
 from django.contrib.auth import get_user_model
 
 
+@skip("TODOv3: Update apps api with Tapisv3")
 class AppDescriptionModelTest(TestCase):
 
     fixtures = ["user-data", "auth"]
@@ -31,6 +33,7 @@ def test_get_app_description(self):
         self.assertContains(response, "TestApp0.1")
 
 
+@skip("TODOv3: Update apps api with Tapisv3")
 class TestAppsApiViews(TestCase):
     fixtures = ["user-data", "auth"]
 
diff --git a/designsafe/fixtures/user-data.json b/designsafe/fixtures/user-data.json
index 103ad32194..5d1cb1c5db 100644
--- a/designsafe/fixtures/user-data.json
+++ b/designsafe/fixtures/user-data.json
@@ -1,67 +1,103 @@
 [
-    {
-      "fields": {
-        "username": "ds_admin",
-        "first_name": "DesignSafe",
-        "last_name": "Admin",
-        "is_active": true,
-        "is_superuser": true,
-        "is_staff": true,
-        "last_login": "2016-03-01T00:00:00.000Z",
-        "groups": [],
-        "user_permissions": [],
-        "password": "",
-        "email": "admin@designsafe-ci.org",
-        "date_joined": "2016-03-01T00:00:00.000Z"
-      },
-      "model": "auth.user",
-      "pk": 1
+  {
+    "fields": {
+      "username": "ds_admin",
+      "first_name": "DesignSafe",
+      "last_name": "Admin",
+      "is_active": true,
+      "is_superuser": true,
+      "is_staff": true,
+      "last_login": "2016-03-01T00:00:00.000Z",
+      "groups": [],
+      "user_permissions": [],
+      "password": "",
+      "email": "admin@designsafe-ci.org",
+      "date_joined": "2016-03-01T00:00:00.000Z"
     },
-    {
-      "fields": {
-        "username": "envision",
-        "first_name": "DesignSafe",
-        "last_name": "Admin",
-        "is_active": true,
-        "is_superuser": true,
-        "is_staff": true,
-        "last_login": "2016-03-01T00:00:00.000Z",
-        "groups": [],
-        "user_permissions": [],
-        "password": "",
-        "email": "admin@designsafe-ci.org",
-        "date_joined": "2016-03-01T00:00:00.000Z"
-      },
-      "model": "auth.user",
-      "pk": 3
+    "model": "auth.user",
+    "pk": 1
+  },
+  {
+    "fields": {
+      "username": "envision",
+      "first_name": "DesignSafe",
+      "last_name": "Admin",
+      "is_active": true,
+      "is_superuser": true,
+      "is_staff": true,
+      "last_login": "2016-03-01T00:00:00.000Z",
+      "groups": [],
+      "user_permissions": [],
+      "password": "",
+      "email": "admin@designsafe-ci.org",
+      "date_joined": "2016-03-01T00:00:00.000Z"
     },
-    {
-      "fields": {
-        "username": "ds_user",
-        "first_name": "DesignSafe",
-        "last_name": "User",
-        "is_active": true,
-        "is_superuser": false,
-        "is_staff": false,
-        "last_login": "2016-03-01T00:00:00.000Z",
-        "groups": [],
-        "user_permissions": [],
-        "password": "",
-        "email": "user@designsafe-ci.org",
-        "date_joined": "2016-03-01T00:00:00.000Z"
-      },
-      "model": "auth.user",
-      "pk": 2
+    "model": "auth.user",
+    "pk": 3
+  },
+  {
+    "fields": {
+      "username": "ds_user",
+      "first_name": "DesignSafe",
+      "last_name": "User",
+      "is_active": true,
+      "is_superuser": false,
+      "is_staff": false,
+      "last_login": "2016-03-01T00:00:00.000Z",
+      "groups": [],
+      "user_permissions": [],
+      "password": "",
+      "email": "user@designsafe-ci.org",
+      "date_joined": "2016-03-01T00:00:00.000Z"
     },
-    {
-      "fields": {
-        "user": 2,
-        "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJqdGkiOiJkMGU1YWZiZi05Yzk3LTQyOTMtOTNlMS1jYWIyYzAxY2JhMDAiLCJpc3MiOiJodHRwczovL2Rldi5kZXZlbG9wLnRhcGlzLmlvL3YzL3Rva2VucyIsInN1YiI6InRlc3R1c2VyMjAwQGRldiIsInRhcGlzL3RlbmFudF9pZCI6ImRldiIsInRhcGlzL3Rva2VuX3R5cGUiOiJhY2Nlc3MiLCJ0YXBpcy9kZWxlZ2F0aW9uIjpmYWxzZSwidGFwaXMvZGVsZWdhdGlvbl9zdWIiOm51bGwsInRhcGlzL3VzZXJuYW1lIjoidGVzdHVzZXIyMDAiLCJ0YXBpcy9hY2NvdW50X3R5cGUiOiJ1c2VyIiwiZXhwIjoxNjU2MDE5MzM1fQ.2mevJWnoS-nlUNfna17berL1HKCHKaPuX6BGi8RZQTQV2meFRLNhAu8B0nDJvROTqYiHna23N2h_FEgS51kRhpwL8N3zTuguh2cT090GxzCFw1QnI1V2rNK4zZjvxagciJxov8SbaOgta6H6_AUentKi_NFjpYTerPRjCDkuCwYitvGOJdzTUFY7cn8SX6JQvlRkcwQ7I0bfC5JN5m5Q0trPD5r2-VDIElI5JVY_isMMT9O5-lT1HTIN1BCYoOnLPgza6vkZeWdArsW9bcvpMANjDlK3mWFtc1fEybN6O3c9RaxRj8GO8zNoyngNH7h6DXeEGdsVJcrt9VWI-nW8iA",
-        "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJqdGkiOiIwYTYzNTAxOS1mNTllLTQxMjItOGUwNi0zZmRkYTNmMzYzNWEiLCJpc3MiOiJodHRwczovL2Rldi5kZXZlbG9wLnRhcGlzLmlvL3YzL3Rva2VucyIsInN1YiI6InRlc3R1c2VyMjAwQGRldiIsInRhcGlzL2luaXRpYWxfdHRsIjo2MDAsInRhcGlzL3RlbmFudF9pZCI6ImRldiIsInRhcGlzL3Rva2VuX3R5cGUiOiJyZWZyZXNoIiwiZXhwIjoxNjU2MDE5MzM1LCJ0YXBpcy9hY2Nlc3NfdG9rZW4iOnsianRpIjoiZDBlNWFmYmYtOWM5Ny00MjkzLTkzZTEtY2FiMmMwMWNiYTAwIiwiaXNzIjoiaHR0cHM6Ly9kZXYuZGV2ZWxvcC50YXBpcy5pby92My90b2tlbnMiLCJzdWIiOiJ0ZXN0dXNlcjIwMEBkZXYiLCJ0YXBpcy90ZW5hbnRfaWQiOiJkZXYiLCJ0YXBpcy90b2tlbl90eXBlIjoiYWNjZXNzIiwidGFwaXMvZGVsZWdhdGlvbiI6ZmFsc2UsInRhcGlzL2RlbGVnYXRpb25fc3ViIjpudWxsLCJ0YXBpcy91c2VybmFtZSI6InRlc3R1c2VyMjAwIiwidGFwaXMvYWNjb3VudF90eXBlIjoidXNlciIsInR0bCI6NjAwfX0.WI9vfN6SPNJwDR9uOaJ16quGzyKl-RWoaDwbOaQa1gpSQoutw8lBqsifzUb0WEJ9fqg8ZWAwbuu-IJikXTiwOiUqWy-09yHxNtCFpBARpY-jurMe20HbDCSlPGICpf8Bend-3tMSnf5c9JyuAgbVx1fnqSjhY3V7yiTVzCur-mOWqI47TiflDnddPscyQj7HBawwadinSiSwQKbnXw2FNkRIdKRrCEOaecKaZ-Hb69vHbi-A3D-HP80nhZzuQW8vzg0L_3cyGOh_Y-8qu22_21UfJwS_nWEizjrs9WTU5hCGpn2Da8U035gk01eC4S9J_WIhZjUhBRneB14QfgTNvg",
-        "expires_in": 1325391984000,
-        "created": 1536700084
-      },
-      "model": "designsafe_auth.tapisoauthtoken",
-      "pk": 1
+    "model": "auth.user",
+    "pk": 2
+  },
+  {
+    "fields": {
+      "user": 2,
+      "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJqdGkiOiJkMGU1YWZiZi05Yzk3LTQyOTMtOTNlMS1jYWIyYzAxY2JhMDAiLCJpc3MiOiJodHRwczovL2Rldi5kZXZlbG9wLnRhcGlzLmlvL3YzL3Rva2VucyIsInN1YiI6InRlc3R1c2VyMjAwQGRldiIsInRhcGlzL3RlbmFudF9pZCI6ImRldiIsInRhcGlzL3Rva2VuX3R5cGUiOiJhY2Nlc3MiLCJ0YXBpcy9kZWxlZ2F0aW9uIjpmYWxzZSwidGFwaXMvZGVsZWdhdGlvbl9zdWIiOm51bGwsInRhcGlzL3VzZXJuYW1lIjoidGVzdHVzZXIyMDAiLCJ0YXBpcy9hY2NvdW50X3R5cGUiOiJ1c2VyIiwiZXhwIjoxNjU2MDE5MzM1fQ.2mevJWnoS-nlUNfna17berL1HKCHKaPuX6BGi8RZQTQV2meFRLNhAu8B0nDJvROTqYiHna23N2h_FEgS51kRhpwL8N3zTuguh2cT090GxzCFw1QnI1V2rNK4zZjvxagciJxov8SbaOgta6H6_AUentKi_NFjpYTerPRjCDkuCwYitvGOJdzTUFY7cn8SX6JQvlRkcwQ7I0bfC5JN5m5Q0trPD5r2-VDIElI5JVY_isMMT9O5-lT1HTIN1BCYoOnLPgza6vkZeWdArsW9bcvpMANjDlK3mWFtc1fEybN6O3c9RaxRj8GO8zNoyngNH7h6DXeEGdsVJcrt9VWI-nW8iA",
+      "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJqdGkiOiIwYTYzNTAxOS1mNTllLTQxMjItOGUwNi0zZmRkYTNmMzYzNWEiLCJpc3MiOiJodHRwczovL2Rldi5kZXZlbG9wLnRhcGlzLmlvL3YzL3Rva2VucyIsInN1YiI6InRlc3R1c2VyMjAwQGRldiIsInRhcGlzL2luaXRpYWxfdHRsIjo2MDAsInRhcGlzL3RlbmFudF9pZCI6ImRldiIsInRhcGlzL3Rva2VuX3R5cGUiOiJyZWZyZXNoIiwiZXhwIjoxNjU2MDE5MzM1LCJ0YXBpcy9hY2Nlc3NfdG9rZW4iOnsianRpIjoiZDBlNWFmYmYtOWM5Ny00MjkzLTkzZTEtY2FiMmMwMWNiYTAwIiwiaXNzIjoiaHR0cHM6Ly9kZXYuZGV2ZWxvcC50YXBpcy5pby92My90b2tlbnMiLCJzdWIiOiJ0ZXN0dXNlcjIwMEBkZXYiLCJ0YXBpcy90ZW5hbnRfaWQiOiJkZXYiLCJ0YXBpcy90b2tlbl90eXBlIjoiYWNjZXNzIiwidGFwaXMvZGVsZWdhdGlvbiI6ZmFsc2UsInRhcGlzL2RlbGVnYXRpb25fc3ViIjpudWxsLCJ0YXBpcy91c2VybmFtZSI6InRlc3R1c2VyMjAwIiwidGFwaXMvYWNjb3VudF90eXBlIjoidXNlciIsInR0bCI6NjAwfX0.WI9vfN6SPNJwDR9uOaJ16quGzyKl-RWoaDwbOaQa1gpSQoutw8lBqsifzUb0WEJ9fqg8ZWAwbuu-IJikXTiwOiUqWy-09yHxNtCFpBARpY-jurMe20HbDCSlPGICpf8Bend-3tMSnf5c9JyuAgbVx1fnqSjhY3V7yiTVzCur-mOWqI47TiflDnddPscyQj7HBawwadinSiSwQKbnXw2FNkRIdKRrCEOaecKaZ-Hb69vHbi-A3D-HP80nhZzuQW8vzg0L_3cyGOh_Y-8qu22_21UfJwS_nWEizjrs9WTU5hCGpn2Da8U035gk01eC4S9J_WIhZjUhBRneB14QfgTNvg",
+      "expires_in": 1325391984000,
+      "created": 1536700084
+    },
+    "model": "designsafe_auth.tapisoauthtoken",
+    "pk": 1
+  },
+  {
+    "fields": {
+      "nickname": "Test Token",
+      "user": 2,
+      "created": "2016-09-06T00:00:00.000Z"
+    },
+    "model": "token_access.token",
+    "pk": "5da84493fa0037de0945631d1f9df5c00cdcac49"
+  },
+  {
+    "model": "designsafe_accounts.designsafeprofile",
+    "pk": 5610,
+    "fields": {
+      "user": 2,
+      "ethnicity": "Asian",
+      "gender": "Male",
+      "agree_to_account_limit": "2020-07-02T23:41:19.342Z",
+      "bio": null,
+      "website": null,
+      "orcid_id": null,
+      "professional_level": null,
+      "update_required": true,
+      "last_updated": "2020-07-02T23:41:19.343Z",
+      "nh_interests": [],
+      "nh_technical_domains": [],
+      "research_activities": []
     }
-  ]
+  },
+  {
+    "fields": {
+      "announcements": true,
+      "user": 2
+    },
+    "model": "designsafe_accounts.notificationpreferences",
+    "pk": 1
+  }
+]

From 094ec40f903bcc2509b1498b4e0bf1d919f977d3 Mon Sep 17 00:00:00 2001
From: Sal Tijerina <r.sal.tijerina@gmail.com>
Date: Tue, 5 Mar 2024 14:33:56 -0600
Subject: [PATCH 20/20] remove unnecessary commented code

---
 designsafe/apps/auth/urls.py | 27 ---------------------------
 1 file changed, 27 deletions(-)

diff --git a/designsafe/apps/auth/urls.py b/designsafe/apps/auth/urls.py
index 678ee5642f..f52fb4ef44 100644
--- a/designsafe/apps/auth/urls.py
+++ b/designsafe/apps/auth/urls.py
@@ -13,30 +13,3 @@
     path("tapis/", views.tapis_oauth, name="tapis_oauth"),
     path("tapis/callback/", views.tapis_oauth_callback, name="tapis_oauth_callback"),
 ]
-
-
-# from django.urls import reverse
-# from django.utils.translation import gettext_lazy as _
-
-# def menu_items(**kwargs):
-#     if 'type' in kwargs and kwargs['type'] == 'account':
-#         return [
-#             {
-#                 'label': _('Login'),
-#                 'url': reverse('login'),
-#                 'children': [],
-#                 'visible': False
-#             },
-#             {
-#                 'label': _('Login'),
-#                 'url': reverse('designsafe_auth:login'),
-#                 'children': [],
-#                 'visible': False
-#             },
-#             {
-#                 'label': _('Agave'),
-#                 'url': reverse('designsafe_auth:agave_session_error'),
-#                 'children': [],
-#                 'visible': False
-#             }
-#         ]