diff --git a/api/.github/dependabot.yml b/api/.github/dependabot.yml deleted file mode 100644 index 9e5479c22..000000000 --- a/api/.github/dependabot.yml +++ /dev/null @@ -1,22 +0,0 @@ -version: 2 -updates: - - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - target-branch: "develop" - labels: - - "github-actions dependencies" - - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - # Raise pull requests for version updates - # to pip against the `develop` branch - target-branch: "develop" - # Labels on pull requests for version updates only - labels: - - "pip dependencies" diff --git a/api/.github/workflows/branch-name-check.yaml b/api/.github/workflows/branch-name-check.yaml deleted file mode 100644 index 77324ca95..000000000 --- a/api/.github/workflows/branch-name-check.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: "branch name check" - -on: - push: - branches-ignore: - - develop - - main - -env: - BRANCH_REGEX: '^((feature|hotfix|bug|docs|dependabot|fix)\/.+)|(release\/v((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?))$' - -jobs: - branch-name-check: - runs-on: ubuntu-20.04 - steps: - - name: checkout - uses: actions/checkout@v3 - - - name: branch name check - run: | - git rev-parse --abbrev-ref HEAD | grep -P "$BRANCH_REGEX" diff --git a/api/.github/workflows/code-checks.yaml b/api/.github/workflows/code-checks.yaml deleted file mode 100644 index 68676755a..000000000 --- a/api/.github/workflows/code-checks.yaml +++ /dev/null @@ -1,52 +0,0 @@ -name: Code checks - -on: - push: - branches: - - main - - develop - pull_request: - branches: - - main - - develop - release: - types: [published] - -jobs: - check-code: - - runs-on: ubuntu-20.04 - services: - postgres: - image: postgres - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - - - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - #Python version should be the same as the base image in Dockerfile - python-version: "3.8.10" - - name: Install dependencies - - run: | - python -m pip install --upgrade pip - pip install black==22.10.0 isort flake8 - if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi - - name: Check Python imports - run: | - isort . --check --diff --skip migrations --skip .venv --profile black -p studio -p projects -p models -p apps -p portal --line-length 79 - - name: Check Python formatting - run: | - black . --check --diff --line-length 79 --exclude migrations - - name: Check Python linting - run: | - flake8 . --exclude migrations diff --git a/api/.gitignore b/api/.gitignore deleted file mode 100644 index b6e47617d..000000000 --- a/api/.gitignore +++ /dev/null @@ -1,129 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ diff --git a/api/LICENSE b/api/LICENSE deleted file mode 100644 index 261eeb9e9..000000000 --- a/api/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/api/MANIFEST.in b/api/MANIFEST.in deleted file mode 100644 index bb3ec5f0d..000000000 --- a/api/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include README.md diff --git a/api/README.md b/api/README.md deleted file mode 100644 index 4ca6ed406..000000000 --- a/api/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# studio-api - -Restful-API Django module for [Studio/STACKn](https://github.com/scaleoutsystems/stackn). To include source package in a Django project: - -``` -$ python3 -m venv .venv -$ source .venv/bin/activate -$ pip install . -``` -And add to installed apps in settings.py: - -``` -INSTALLED_APPS=[ - "rest_framework.authtoken", - "rest_framework", - "api" -] - -REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework.authentication.TokenAuthentication" - ], -} -``` - -For a complete project follow the link above and navigate to settings.py diff --git a/api/openapi/public_apps_api.py b/api/openapi/public_apps_api.py index fc02b9e9b..7e198ffb2 100644 --- a/api/openapi/public_apps_api.py +++ b/api/openapi/public_apps_api.py @@ -1,10 +1,12 @@ +from django.core.exceptions import FieldError from django.db.models import Q from django.http import JsonResponse from django.shortcuts import get_object_or_404 from rest_framework import viewsets from rest_framework.exceptions import NotFound -from apps.models import AppInstance, Apps +from apps.app_registry import APP_REGISTRY +from apps.models import Apps, AppStatus from studio.utils import get_logger logger = get_logger(__name__) @@ -23,38 +25,77 @@ def list(self, request): """ logger.info("PublicAppsAPI. Entered list method.") logger.info("Requested API version %s", request.version) + list_apps = [] - queryset = ( - AppInstance.objects.filter(~Q(state="Deleted"), access="public") - .order_by("-updated_on")[:8] - .values("id", "name", "app_id", "table_field", "description", "updated_on") - ) + # TODO: MAKE SURE THAT THIS IS FILTERED BASED ON ACCESS + for model_class in APP_REGISTRY.iter_orm_models(): + # Loop over all models, and check if they have the access and description field + if hasattr(model_class, "description") and hasattr(model_class, "access"): + queryset = ( + model_class.objects.filter(~Q(app_status__status="Deleted"), access="public") + .order_by("-updated_on")[:8] + .values("id", "name", "app_id", "url", "description", "updated_on", "app_status") + ) + list_apps.extend(list(queryset)) - list_apps = list(queryset) for app in list_apps: - add_data = Apps.objects.get(id=app["app_id"]) - app["app_type"] = add_data.name + app["app_type"] = Apps.objects.get(id=app["app_id"]).name + app["app_status"] = AppStatus.objects.get(pk=app["app_status"]).status + + # Add the previous url key located at app.table_field.url to support clients using the previous schema + app["table_field"] = {"url": app["url"]} + data = {"data": list_apps} logger.info("LIST: %s", data) return JsonResponse(data) - def retrieve(self, request, pk=None): + def retrieve(self, request, app_slug=None, pk=None): """ This endpoint retrieves a single public app instance. :returns dict: A dict of app information. """ logger.info("PublicAppsAPI. Entered retrieve method with pk = %s", pk) logger.info("Requested API version %s", self.request.version) - queryset = AppInstance.objects.all().values( - "id", "name", "app_id", "table_field", "description", "updated_on", "access", "state" - ) - app = get_object_or_404(queryset, pk=pk) - if app["state"] == "Deleted": - raise NotFound("this app has been deleted") - if app["access"] != "public": - raise NotFound() - - add_data = Apps.objects.get(id=app["app_id"]) - app["app_type"] = add_data.name - data = {"app": app} + + model_class = APP_REGISTRY.get_orm_model(app_slug) + + if model_class is None: + logger.error("App slug has no corresponding model class") + raise NotFound("Invalid app slug") + + try: + queryset = model_class.objects.all().values( + "id", "name", "app_id", "url", "description", "updated_on", "access", "app_status" + ) + logger.info("Queryset: %s", queryset) + except FieldError as e: + message = f"Error in field: {e}" + logger.error(f"App type is not available in public view: {message}") + raise NotFound("App type is not available in public view") + + app_instance = get_object_or_404(queryset, pk=pk) + if app_instance is None: + logger.error("App instance is not available") + raise NotFound("App instance is not available") + + app_status_pk = app_instance.get("app_status", None) + logger.info("DID WE GET HERE?!") + if app_status_pk is None: + raise NotFound("App status is not available") + + app_status = AppStatus.objects.get(pk=app_status_pk) + if app_status.status == "Deleted": + logger.error("This app has been deleted") + raise NotFound("This app has been deleted") + + if app_instance.get("access", False) != "public": + logger.error("This app is non-existent or not public") + raise NotFound("This app is non-existent or not public") + + app_instance["app_status"] = app_status.status + + add_data = Apps.objects.get(id=app_instance["app_id"]) + app_instance["app_type"] = add_data.name + data = {"app": app_instance} + logger.info("API call successful") return JsonResponse(data) diff --git a/api/openapi/urls.py b/api/openapi/urls.py index 9c6cf6f16..18cd55ca3 100644 --- a/api/openapi/urls.py +++ b/api/openapi/urls.py @@ -21,7 +21,7 @@ path("api-info", APIInfo.as_view({"get": "get_api_info"})), # The Apps API path("public-apps", PublicAppsAPI.as_view({"get": "list"})), - path("public-apps/", PublicAppsAPI.as_view({"get": "retrieve"})), + path("public-apps//", PublicAppsAPI.as_view({"get": "retrieve"})), # Supplementary lookups API path( "lookups/universities", diff --git a/api/serializers.py b/api/serializers.py index 31362ca27..680175ca3 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,17 +1,9 @@ from django.contrib.auth.models import User from rest_framework.serializers import ModelSerializer -from apps.models import AppCategories, AppInstance, Apps, AppStatus +from apps.models import AppCategories, Apps, AppStatus, BaseAppInstance from models.models import Metadata, Model, ModelLog, ObjectType -from projects.models import ( - S3, - Environment, - Flavor, - MLFlow, - Project, - ProjectTemplate, - ReleaseName, -) +from projects.models import Environment, Flavor, Project, ProjectTemplate class MLModelSerializer(ModelSerializer): @@ -72,23 +64,7 @@ class Meta: ) -class S3serializer(ModelSerializer): - class Meta: - model = S3 - fields = ("name", "access_key", "secret_key", "host", "region") - - -class MLflowSerializer(ModelSerializer): - s3 = S3serializer() - - class Meta: - model = MLFlow - fields = ("name", "mlflow_url", "s3") - - class ProjectSerializer(ModelSerializer): - s3storage = S3serializer() - class Meta: model = Project @@ -130,7 +106,7 @@ class AppInstanceSerializer(ModelSerializer): status = AppStatusSerializer(many=True) class Meta: - model = AppInstance + model = BaseAppInstance fields = ("id", "name", "app", "table_field", "state", "status") @@ -154,14 +130,6 @@ class Meta: fields = "__all__" -class ReleaseNameSerializer(ModelSerializer): - app = AppInstanceSerializer() - - class Meta: - model = ReleaseName - fields = "__all__" - - class ProjectTemplateSerializer(ModelSerializer): class Meta: model = ProjectTemplate diff --git a/api/setup.py b/api/setup.py deleted file mode 100644 index 97138c6e2..000000000 --- a/api/setup.py +++ /dev/null @@ -1,33 +0,0 @@ -from setuptools import setup - -setup( - name="studio-api", - version="0.0.1", - description="""Django app for handling REST-API in Studio""", - url="https://www.scaleoutsystems.com", - include_package_data=True, - package=["api"], - package_dir={"api": "."}, - python_requires=">=3.6,<4", - install_requires=[ - "django==4.1.7", - "requests==2.28.1", - "django-guardian==2.4.0", - "django-tagulous==1.3.3", - "django-filter==22.1", - "drf-nested-routers==0.93.4", - "minio==7.0.2", - "s3fs==2022.1.0", - ], - license="Copyright Scaleout Systems AB. See license for details", - zip_safe=False, - keywords="", - classifiers=[ - "Development Status :: 2 - Pre-Alpha", - "Intended Audience :: Developers", - "Natural Language :: English", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - ], -) diff --git a/api/tests/test_openapi_public_apps.py b/api/tests/test_openapi_public_apps.py index aa034549f..11cdcef11 100644 --- a/api/tests/test_openapi_public_apps.py +++ b/api/tests/test_openapi_public_apps.py @@ -6,7 +6,7 @@ from rest_framework import status from rest_framework.test import APITestCase -from apps.models import AppInstance, Apps +from apps.models import Apps, AppStatus, CustomAppInstance, Subdomain from projects.models import Project from studio.utils import get_logger @@ -27,18 +27,22 @@ class PublicAppsApiTests(APITestCase): def setUpTestData(cls): cls.user = User.objects.create_user(test_user["username"], test_user["email"], test_user["password"]) cls.project = Project.objects.create_project(name="test-perm", owner=cls.user, description="") - cls.app = Apps.objects.create(name="Some App", slug="some-app") + cls.app = Apps.objects.create(name="Some App", slug="customapp") - cls.app_instance = AppInstance.objects.create( + subdomain = Subdomain.objects.create(subdomain="test_internal") + app_status = AppStatus.objects.create(status="Running") + cls.app_instance = CustomAppInstance.objects.create( access="public", owner=cls.user, name="test_app_instance_public", description="My app description", app=cls.app, project=cls.project, - parameters={ + k8s_values={ "environment": {"pk": ""}, }, + subdomain=subdomain, + app_status=app_status, ) def test_public_apps_list(self): @@ -64,10 +68,12 @@ def test_public_apps_list(self): def test_public_apps_single_app(self): """Tests the API resource public-apps get single object""" id = str(self.app_instance.id) - url = os.path.join(self.BASE_API_URL, "public-apps/", id) + app_slug = self.app_instance.app.slug + url = os.path.join(self.BASE_API_URL, "public-apps", app_slug, id) response = self.client.get(url, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) + logger.info(response.content) actual = json.loads(response.content)["app"] logger.info(type(actual)) @@ -84,6 +90,12 @@ def test_public_apps_single_app(self): def test_public_apps_single_app_notfound(self): """Tests the API resource public-apps get single object for a non-existing id""" id = "-1" - url = os.path.join(self.BASE_API_URL, "public-apps/", id) + app_slug = self.app_instance.app.slug + url = os.path.join(self.BASE_API_URL, "public-apps", app_slug, id) + response = self.client.get(url, format="json") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + app_slug = "invalid_app_slug" + url = os.path.join(self.BASE_API_URL, "public-apps", app_slug, id) response = self.client.get(url, format="json") self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/api/urls.py b/api/urls.py index 7046ec5a0..9daf82456 100644 --- a/api/urls.py +++ b/api/urls.py @@ -12,15 +12,15 @@ FlavorsList, MembersList, MetadataList, - MLflowList, ModelList, ModelLogList, ObjectTypeList, ProjectList, ProjectTemplateList, - ReleaseNameList, ResourceList, - S3List, + get_subdomain_input_html, + get_subdomain_is_available, + get_subdomain_is_valid, update_app_status, ) @@ -40,9 +40,6 @@ models_router.register(r"appinstances", AppInstanceList, basename="appinstances") models_router.register(r"flavors", FlavorsList, basename="flavors") models_router.register(r"environments", EnvironmentList, basename="environment") -models_router.register(r"s3", S3List, basename="s3") -models_router.register(r"mlflow", MLflowList, basename="mlflow") -models_router.register(r"releasenames", ReleaseNameList, basename="releasenames") models_router.register(r"modellogs", ModelLogList, basename="modellog") models_router.register(r"metadata", MetadataList, basename="metadata") models_router.register(r"apps", AppList, basename="apps") @@ -57,4 +54,7 @@ path("token-auth/", CustomAuthToken.as_view(), name="api_token_auth"), path("settings/", get_studio_settings), path("app-status/", update_app_status), + path("app-subdomain/validate/", get_subdomain_is_valid), + path("app-subdomain/is-available/", get_subdomain_is_available), + path("htmx/subdomain-input/", get_subdomain_input_html, name="get_subdomain_input_html"), ] diff --git a/api/views.py b/api/views.py index f753e886d..f4cd685cf 100644 --- a/api/views.py +++ b/api/views.py @@ -5,10 +5,11 @@ import pytz from django.conf import settings from django.contrib.auth.models import User -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.models import Q -from django.http import HttpResponse -from django.utils.text import slugify +from django.http import HttpRequest, HttpResponse +from django.template import loader +from django.utils.safestring import mark_safe from django_filters.rest_framework import DjangoFilterBackend from rest_framework import generics from rest_framework.authtoken.models import Token @@ -24,20 +25,17 @@ from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet -from apps.helpers import HandleUpdateStatusResponseCode, handle_update_status_request -from apps.models import AppCategories, AppInstance, Apps, AppStatus +from apps.helpers import ( + HandleUpdateStatusResponseCode, + get_select_options, + handle_update_status_request, +) +from apps.models import AppCategories, Apps, BaseAppInstance, Subdomain from apps.tasks import delete_resource +from apps.types_.subdomain import SubdomainCandidateName from models.models import ObjectType from portal.models import PublishedModel -from projects.models import ( - S3, - Environment, - Flavor, - MLFlow, - ProjectLog, - ProjectTemplate, - ReleaseName, -) +from projects.models import Environment, Flavor, ProjectLog, ProjectTemplate from projects.tasks import create_resources_from_template, delete_project_apps from studio.utils import get_logger @@ -49,7 +47,6 @@ FlavorsSerializer, Metadata, MetadataSerializer, - MLflowSerializer, MLModelSerializer, Model, ModelLog, @@ -58,8 +55,6 @@ Project, ProjectSerializer, ProjectTemplateSerializer, - ReleaseNameSerializer, - S3serializer, UserSerializer, ) @@ -491,7 +486,7 @@ class AppInstanceList( filterset_fields = ["id", "name", "app__category"] def get_queryset(self): - return AppInstance.objects.filter(~Q(state="Deleted"), project__pk=self.kwargs["project_pk"]) + return BaseAppInstance.objects.filter(~Q(state="Deleted"), project__pk=self.kwargs["project_pk"]) def create(self, request, *args, **kwargs): project = Project.objects.get(id=self.kwargs["project_pk"]) @@ -595,103 +590,6 @@ def destroy(self, request, *args, **kwargs): return HttpResponse("Deleted object.", status=200) -class S3List( - GenericViewSet, - CreateModelMixin, - RetrieveModelMixin, - UpdateModelMixin, - ListModelMixin, -): - permission_classes = ( - IsAuthenticated, - ProjectPermission, - ) - serializer_class = S3serializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ["id", "name", "host", "region"] - - def get_queryset(self): - return S3.objects.filter(project__pk=self.kwargs["project_pk"]) - - def destroy(self, request, *args, **kwargs): - try: - obj = self.get_object() - except Exception as e: - logger.error(e, exc_info=True) - return HttpResponse("No such object.", status=400) - obj.delete() - return HttpResponse("Deleted object.", status=200) - - -class MLflowList( - GenericViewSet, - CreateModelMixin, - RetrieveModelMixin, - UpdateModelMixin, - ListModelMixin, -): - permission_classes = ( - IsAuthenticated, - ProjectPermission, - ) - serializer_class = MLflowSerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ["id", "name"] - - def get_queryset(self): - return MLFlow.objects.filter(project__pk=self.kwargs["project_pk"]) - - def destroy(self, request, *args, **kwargs): - try: - obj = self.get_object() - except Exception as e: - logger.error(e, exc_info=True) - return HttpResponse("No such object.", status=400) - obj.delete() - return HttpResponse("Deleted object.", status=200) - - -class ReleaseNameList( - GenericViewSet, - CreateModelMixin, - RetrieveModelMixin, - UpdateModelMixin, - ListModelMixin, -): - permission_classes = ( - IsAuthenticated, - ProjectPermission, - ) - serializer_class = ReleaseNameSerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ["id", "name", "project"] - - def get_queryset(self): - return ReleaseName.objects.filter(project__pk=self.kwargs["project_pk"]) - - def create(self, request, *args, **kwargs): - name = slugify(request.data["name"]) - project = Project.objects.get(id=self.kwargs["project_pk"]) - if ReleaseName.objects.filter(name=name).exists(): - if project.status != "archived": - logger.info("ReleaseName already in use.") - return HttpResponse("Release name already in use.", status=200) - status = "active" - - rn = ReleaseName(name=name, status=status, project=project) - rn.save() - return HttpResponse("Created release name {}.".format(name), status=200) - - def destroy(self, request, *args, **kwargs): - try: - obj = self.get_object() - except Exception as e: - logger.error(e, exc_info=True) - return HttpResponse("No such object.", status=400) - obj.delete() - return HttpResponse("Deleted object.", status=200) - - class AppList( generics.ListAPIView, GenericViewSet, @@ -834,6 +732,128 @@ def create(self, request, *args, **kwargs): return HttpResponse("Created new template: {}.".format(name), status=200) +@api_view(["GET"]) +@permission_classes( + ( + # IsAuthenticated, + ) +) +def get_subdomain_is_valid(request: HttpRequest) -> HttpResponse: + """ + Implementation of the API method at endpoint /api/app-subdomain/validate/ + Supports the GET verb. + + The service contract for the GET action is as follows: + :param str subdomainText: The subdomain text to validate. + :param str app_id: The app id to check if the subdomain is already taken by the app. + :returns: An http status code and dict containing {"isValid": bool, "message": str} + + Example request: /api/app-subdomain/validate/?subdomainText=my-subdomain&app_id=1 + """ + subdomain_text = request.GET.get("subdomainText") + project_id = request.GET.get("project_id") + app_id = request.GET.get("app_id") + if subdomain_text is None: + return Response("Invalid input. Must pass in argument subdomainText.", 400) + + # First validate for valid name + subdomain_candidate = SubdomainCandidateName(subdomain_text, project_id) + + try: + subdomain_candidate.validate_subdomain() + except ValidationError as e: + return Response({"isValid": False, "message": e.message}) + + # Only check if the subdomain is available if the name is a valid subdomain name + msg = "The subdomain is available" + + try: + if app_id != "None" and subdomain_text == BaseAppInstance.objects.get(pk=app_id).subdomain.subdomain: + is_valid = True + msg = "The subdomain is already in use by the app." + else: + is_valid = subdomain_candidate.is_available() + if not is_valid: + msg = "The subdomain is not available" + except Exception as e: + logger.warn(f"Unable to validate subdomain {subdomain_text}. Error={str(e)}") + is_valid = False + msg = "The subdomain is not available. There was an error during checking availability of the subdomain." + + return Response({"isValid": is_valid, "message": msg}) + + +@api_view(["GET"]) +@permission_classes( + ( + # IsAuthenticated, + ) +) +def get_subdomain_is_available(request: HttpRequest) -> HttpResponse: + """ + Implementation of the API method at endpoint /api/app-subdomain/is-available/ + Supports the GET verb. + + The service contract for the GET action is as follows: + :param str subdomainText: The subdomain text to check for availability. + :param str project_id: The project id to check for available subdomains in the project. + :returns: An http status code and dict containing {"isAvailable": bool} + + Example request: /api/app-subdomain/is-available/?subdomainText=my-subdomain&project_id=1 + """ + subdomain_text = request.GET.get("subdomainText") + project_id = request.GET.get("project_id") + if subdomain_text is None: + return Response("Invalid input. Must pass in argument subdomainText.", 400) + + is_available = False + + try: + subdomain_candidate = SubdomainCandidateName(subdomain_text, project_id) + is_available = subdomain_candidate.is_available() + except Exception as e: + logger.warn(f"Unable to validate subdomain {subdomain_text}. Error={str(e)}") + is_available = False + + return Response({"isAvailable": is_available}) + + +@api_view(["GET"]) +@permission_classes( + ( + # IsAuthenticated, + ) +) +def get_subdomain_input_html(request: HttpRequest) -> HttpResponse: + """ + Implementation of the API method at endpoint /api/app-subdomain/subdomain-input/ + Supports the GET verb. + + The service contract for the GET action is as follows: + :param str type: The type of element to return (select, input or newinput). + :param str project_id: The project id to check for available subdomains in the project. + :param str initial_subdomain: The subdomain value for the app that is already created + (will be empty for new apps). + :returns: An http response with the HTML element. + + Example request: /api/app-subdomain/subdomain-input/?type=select&project_id=project_id + &initial_subdomain=initial_subdomain + """ + project_id = request.GET.get("project_id") + request_type = request.GET.get("type") + initial_subdomain = request.GET.get("initial_subdomain") if request.GET.get("initial_subdomain") else "" + # default template and context is for input box + context = {"initial_subdomain": initial_subdomain} + template = "apps/partials/subdomain_input.html" + if request_type == "select": + options_list = get_select_options(project_id, initial_subdomain) + context["options_list"] = options_list + template = "apps/partials/subdomain_select.html" + rendered_template = loader.get_template(template).render(context) + response_html = mark_safe(rendered_template) + return HttpResponse(response_html) + + @api_view(["GET", "POST"]) @permission_classes( ( @@ -842,13 +862,13 @@ def create(self, request, *args, **kwargs): AdminPermission, ) ) -def update_app_status(request): +def update_app_status(request: HttpRequest) -> HttpResponse: """ Manages the app instance status. Implemented as a DRF function based view. Supports GET and POST verbs. - The service contract for the POST actions is as follows: + The service contract for the POST verb is as follows: :param release str: The release id of the app instance, stored in the AppInstance.parameters dict. :param new-status str: The new status code. :param event-ts timestamp: A JSON-formatted timestamp, e.g. 2024-01-25T16:02:50.00Z. diff --git a/apps/.github/dependabot.yml b/apps/.github/dependabot.yml deleted file mode 100644 index 9e5479c22..000000000 --- a/apps/.github/dependabot.yml +++ /dev/null @@ -1,22 +0,0 @@ -version: 2 -updates: - - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - target-branch: "develop" - labels: - - "github-actions dependencies" - - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - # Raise pull requests for version updates - # to pip against the `develop` branch - target-branch: "develop" - # Labels on pull requests for version updates only - labels: - - "pip dependencies" diff --git a/apps/.github/workflows/branch-name-check.yaml b/apps/.github/workflows/branch-name-check.yaml deleted file mode 100644 index 03c9b39bf..000000000 --- a/apps/.github/workflows/branch-name-check.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: "branch name check" - -on: - push: - branches-ignore: - - develop - - main - -env: - BRANCH_REGEX: '^((feature|hotfix|bug|docs|dependabot|fix)\/.+)|(release\/v((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?))$' - -jobs: - branch-name-check: - runs-on: ubuntu-20.04 - steps: - - name: checkout - uses: actions/checkout@v2 - - - name: branch name check - run: | - git rev-parse --abbrev-ref HEAD | grep -P "$BRANCH_REGEX" diff --git a/apps/.github/workflows/code-checks.yaml b/apps/.github/workflows/code-checks.yaml deleted file mode 100644 index 68676755a..000000000 --- a/apps/.github/workflows/code-checks.yaml +++ /dev/null @@ -1,52 +0,0 @@ -name: Code checks - -on: - push: - branches: - - main - - develop - pull_request: - branches: - - main - - develop - release: - types: [published] - -jobs: - check-code: - - runs-on: ubuntu-20.04 - services: - postgres: - image: postgres - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - - - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - #Python version should be the same as the base image in Dockerfile - python-version: "3.8.10" - - name: Install dependencies - - run: | - python -m pip install --upgrade pip - pip install black==22.10.0 isort flake8 - if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi - - name: Check Python imports - run: | - isort . --check --diff --skip migrations --skip .venv --profile black -p studio -p projects -p models -p apps -p portal --line-length 79 - - name: Check Python formatting - run: | - black . --check --diff --line-length 79 --exclude migrations - - name: Check Python linting - run: | - flake8 . --exclude migrations diff --git a/apps/.gitignore b/apps/.gitignore deleted file mode 100644 index b6e47617d..000000000 --- a/apps/.gitignore +++ /dev/null @@ -1,129 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ diff --git a/apps/LICENSE b/apps/LICENSE deleted file mode 100644 index 261eeb9e9..000000000 --- a/apps/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/apps/MANIFEST.in b/apps/MANIFEST.in deleted file mode 100644 index 1add7d9b5..000000000 --- a/apps/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.md -recursive-include apps/templates * diff --git a/apps/README.md b/apps/README.md deleted file mode 100644 index 5cb001d0c..000000000 --- a/apps/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# studio-apps - -Apps Django module for [Studio/STACKn](https://github.com/scaleoutsystems/stackn). To include source package in a Django project: - -``` -$ python3 -m venv .venv -$ source .venv/bin/activate -$ pip install . -``` -And add to installed apps in settings.py: - -``` -INSTALLED_APPS=[ - "apps" -] -``` - -For a complete project follow the link above and navigate to settings.py diff --git a/apps/admin.py b/apps/admin.py index cac291219..48b06aa4e 100644 --- a/apps/admin.py +++ b/apps/admin.py @@ -1,13 +1,41 @@ +import time + from django.contrib import admin, messages +from django.db.models.query import QuerySet from studio.utils import get_logger -from .models import AppCategories, AppInstance, Apps, AppStatus, ResourceData -from .tasks import deploy_resource +from .helpers import get_URI +from .models import ( + AppCategories, + Apps, + AppStatus, + BaseAppInstance, + CustomAppInstance, + DashInstance, + FilemanagerInstance, + JupyterInstance, + NetpolicyInstance, + RStudioInstance, + ShinyInstance, + Subdomain, + TissuumapsInstance, + VolumeInstance, + VSCodeInstance, +) +from .tasks import delete_resource, deploy_resource logger = get_logger(__name__) +class AppStatusAdmin(admin.ModelAdmin): + list_display = ( + "status", + "time", + ) + list_filter = ["status", "time"] + + class AppsAdmin(admin.ModelAdmin): list_display = ( "name", @@ -22,87 +50,186 @@ class AppsAdmin(admin.ModelAdmin): admin.site.register(Apps, AppsAdmin) -class AppInstanceAdmin(admin.ModelAdmin): - list_display = ("name", "display_owner", "display_project", "state", "access", "app", "display_chart") +class BaseAppAdmin(admin.ModelAdmin): + list_display = ("name", "display_owner", "display_project", "display_status", "display_subdomain", "chart") + readonly_fields = ("id",) + list_filter = ["owner", "project", "app_status__status", "chart"] + actions = ["redeploy_apps", "deploy_resources", "delete_resources"] + + def display_status(self, obj): + status_object = obj.app_status + if status_object: + return status_object.status + else: + "No status" + + display_status.short_description = "Status" - list_filter = ["owner", "project", "state"] - actions = ["redeploy_apps", "update_chart"] + def display_subdomain(self, obj): + subdomain_object = obj.subdomain + if subdomain_object: + return subdomain_object.subdomain + else: + "No Subdomain" + + display_subdomain.short_description = "Subdomain" def display_owner(self, obj): return obj.owner.username + display_owner.short_description = "Owner" + def display_project(self, obj): return obj.project.name - def display_chart(self, obj): - return obj.parameters.get("chart", "No chart") + display_project.short_description = "Project" - @admin.action(description="Redeploy apps") - def redeploy_apps(self, request, queryset): + def display_volumes(self, obj): + if obj.volume is None: + return "No Volumes" + elif isinstance(obj.volume, QuerySet): + return [volume.name for volume in obj.volume.all()] + else: + return obj.volume.name + + display_volumes.short_description = "Volumes" + + @admin.action(description="(Re)deploy resources") + def deploy_resources(self, request, queryset): success_count = 0 failure_count = 0 - for appinstance in queryset: - result = deploy_resource(appinstance.pk) - if result.returncode == 0: - success_count += 1 + for instance in queryset: + instance.set_k8s_values() + instance.url = get_URI(instance.k8s_values) + instance.save(update_fields=["k8s_values", "url"]) + + deploy_resource.delay(instance.serialize()) + time.sleep(2) + info_dict = instance.info + if info_dict: + success = info_dict["helm"].get("success", False) + if success: + success_count += 1 + else: + failure_count += 1 else: failure_count += 1 if success_count: - self.message_user(request, f"{success_count} apps successfully redeployed.", messages.SUCCESS) + self.message_user(request, f"{success_count} apps successfully (re)deployed.", messages.SUCCESS) if failure_count: self.message_user( request, f"Failed to redeploy {failure_count} apps. Check logs for details.", messages.ERROR ) - @admin.action(description="Update helm chart definition in parameters") - def update_chart(self, request, queryset): + @admin.action(description="Delete resources") + def delete_resources(self, request, queryset): success_count = 0 failure_count = 0 - for appinstance in queryset: - # First, update charts for the app - try: - parameters = appinstance.parameters - app = Apps.objects.get(slug=parameters["app_slug"]) - parameters.update({"chart": app.chart}) - - # Secondly, update charts for the dependencies - app_deps = parameters.get("apps") - # Loop through the outer dictionary - for app_key, app_dict in app_deps.items(): - # Loop through each project in the projects dictionary - for key, details in app_dict.items(): - slug = details["slug"] - app = Apps.objects.get(slug=slug) - # Update the chart value - details["chart"] = app.chart - app_deps[app_key][key] = details - parameters.update({"apps": app_deps}) - appinstance.save(update_fields=["parameters"]) - success_count += 1 - except Exception as e: - logger.error(f"Failed to update app {appinstance.name}. Error: {e}") + + for instance in queryset: + instance.set_k8s_values() + delete_resource.delay(instance.serialize()) + info_dict = instance.info + if info_dict: + success = info_dict["helm"].get("success", False) + if success: + success_count += 1 + else: + failure_count += 1 + else: failure_count += 1 + if success_count: - self.message_user(request, f"{success_count} apps successfully updated.", messages.SUCCESS) + self.message_user(request, f"{success_count} apps successfully deleted.", messages.SUCCESS) if failure_count: self.message_user( - request, f"Failed to update {failure_count} apps. Check logs for details.", messages.ERROR + request, f"Failed to delete {failure_count} apps. Check logs for details.", messages.ERROR ) -class AppStatusAdmin(admin.ModelAdmin): - list_display = ( - "appinstance", - "status_type", - "time", +@admin.register(BaseAppInstance) +class BaseAppInstanceAdmin(BaseAppAdmin): + list_display = BaseAppAdmin.list_display + ("display_subclass",) + + def display_subclass(self, obj): + subclasses = BaseAppInstance.__subclasses__() + for subclass in subclasses: + app_type = getattr(obj, subclass.__name__.lower(), None) + if app_type: + return app_type.__class__.__name__ + + display_subclass.short_description = "Subclass" + + +@admin.register(RStudioInstance) +class RStudioInstanceAdmin(BaseAppAdmin): + list_display = BaseAppAdmin.list_display + ("access", "display_volumes") + + +@admin.register(VSCodeInstance) +class VSCodeInstanceAdmin(BaseAppAdmin): + list_display = BaseAppAdmin.list_display + ("access", "display_volumes") + + +@admin.register(JupyterInstance) +class JupyterInstanceAdmin(BaseAppAdmin): + list_display = BaseAppAdmin.list_display + ("access", "display_volumes") + + +@admin.register(VolumeInstance) +class VolumeInstanceAdmin(BaseAppAdmin): + list_display = BaseAppAdmin.list_display + ("display_size",) + + def display_size(self, obj): + return f"{str(obj.size)} GB" + + display_size.short_description = "Size" + + +@admin.register(NetpolicyInstance) +class NetpolicyInstanceAdmin(BaseAppAdmin): + list_display = BaseAppAdmin.list_display + + +@admin.register(DashInstance) +class DashInstanceAdmin(BaseAppAdmin): + list_display = BaseAppAdmin.list_display + ("image",) + + +@admin.register(CustomAppInstance) +class CustomAppInstanceAdmin(BaseAppAdmin): + list_display = BaseAppAdmin.list_display + ( + "display_volumes", + "image", + "port", + "user_id", ) - list_filter = ["appinstance", "status_type", "time"] + +@admin.register(ShinyInstance) +class ShinyInstanceAdmin(BaseAppAdmin): + list_display = BaseAppAdmin.list_display + ( + "display_volumes", + "image", + "port", + ) + + +@admin.register(TissuumapsInstance) +class TissuumapsInstanceAdmin(BaseAppAdmin): + list_display = BaseAppAdmin.list_display + ("display_volumes",) + + +@admin.register(FilemanagerInstance) +class FilemanagerInstanceAdmin(BaseAppAdmin): + list_display = BaseAppAdmin.list_display + ( + "display_volumes", + "persistent", + ) -admin.site.register(AppInstance, AppInstanceAdmin) +admin.site.register(Subdomain) admin.site.register(AppCategories) -admin.site.register(ResourceData) admin.site.register(AppStatus, AppStatusAdmin) diff --git a/apps/app_registry.py b/apps/app_registry.py new file mode 100644 index 000000000..4721fdfd4 --- /dev/null +++ b/apps/app_registry.py @@ -0,0 +1,39 @@ +from apps.forms import ( + CustomAppForm, + DashForm, + FilemanagerForm, + JupyterForm, + NetpolicyForm, + RStudioForm, + ShinyForm, + TissuumapsForm, + VolumeForm, + VSCodeForm, +) +from apps.models import ( + CustomAppInstance, + DashInstance, + FilemanagerInstance, + JupyterInstance, + NetpolicyInstance, + RStudioInstance, + ShinyInstance, + TissuumapsInstance, + VolumeInstance, + VSCodeInstance, +) +from apps.types_.app_registry import AppRegistry +from apps.types_.app_types import ModelFormTuple + +APP_REGISTRY = AppRegistry() +APP_REGISTRY.register("jupyter-lab", ModelFormTuple(JupyterInstance, JupyterForm)) +APP_REGISTRY.register("rstudio", ModelFormTuple(RStudioInstance, RStudioForm)) +APP_REGISTRY.register("vscode", ModelFormTuple(VSCodeInstance, VSCodeForm)) +APP_REGISTRY.register("volumeK8s", ModelFormTuple(VolumeInstance, VolumeForm)) +APP_REGISTRY.register("netpolicy", ModelFormTuple(NetpolicyInstance, NetpolicyForm)) +APP_REGISTRY.register("dashapp", ModelFormTuple(DashInstance, DashForm)) +APP_REGISTRY.register("customapp", ModelFormTuple(CustomAppInstance, CustomAppForm)) +APP_REGISTRY.register("shinyapp", ModelFormTuple(ShinyInstance, ShinyForm)) +APP_REGISTRY.register("shinyproxyapp", ModelFormTuple(ShinyInstance, ShinyForm)) +APP_REGISTRY.register("tissuumaps", ModelFormTuple(TissuumapsInstance, TissuumapsForm)) +APP_REGISTRY.register("filemanager", ModelFormTuple(FilemanagerInstance, FilemanagerForm)) diff --git a/apps/apps.py b/apps/apps.py index 84fe2c8dc..ec178376b 100644 --- a/apps/apps.py +++ b/apps/apps.py @@ -4,3 +4,6 @@ class AppsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps" + + def ready(self): + import apps.signals diff --git a/apps/constants.py b/apps/constants.py new file mode 100644 index 000000000..4437091d3 --- /dev/null +++ b/apps/constants.py @@ -0,0 +1,23 @@ +HELP_MESSAGE_MAP = { + "name": "Display name for the application. This is the name visible on the app catalogue if the app is public", + "description": "Provide a detailed description of your app. " + "This will be the description visible in the app catalogue if the app is public.", + "subdomain": "Valid subdomain names have minimum length of 3 characters and may contain lower case letters a-z " + "and numbers 0-9 and a hyphen '-'. The hyphen should not be at the start or end of the subdomain.", + "access": "Public apps will be displayed on the app catalogue and can be accessed by anyone that has the link to " + "them. Project apps can only be accessed by project members. Private apps are only accessible by users that " + "create the apps. Link apps are only accessible to those who have a direct link, they are not " + "displayed in the public catalogue.", + "source_code_url": "Provide a URL where the full source code of your app can be accessed (for example, to a GitHub " + "repository or a Figshare or Zenodo entry).", + "flavor": "Hardware allocation for your app. Only one option is available by default. If your app requires more " + "hardware resources, get in touch with us (serve@scilifelab.se) with a request.", + "image": "Docker Image for the app uploaded to DockerHub or GitHub. Each version of your app should have a unique " + "tag.", + "path": "Specify the path inside the container that you want to be persistent (path to database or similar). If " + "you follow our guide to build the container, then please include the username in the path as well.", + "port": "Port that the docker container exposes and the application runs on. This should be an integer between " + "3000-9999.", + "note_on_linkonly_privacy": "This option can be used only for a limited amount of time, for example while under " + "development or during peer review.", +} diff --git a/apps/controller.py b/apps/controller.py deleted file mode 100644 index 74ce0ae0e..000000000 --- a/apps/controller.py +++ /dev/null @@ -1,151 +0,0 @@ -import json -import os -import subprocess -import tarfile -import uuid - -import yaml -from django.conf import settings - -from studio.utils import get_logger - -from .models import Apps - -KUBEPATH = settings.KUBECONFIG -logger = get_logger(__name__) - - -def delete(options): - logger.info("DELETE FROM CONTROLLER") - # building args for the equivalent of helm uninstall command - args = ["helm", "-n", options["namespace"], "delete", options["release"]] - result = subprocess.run(args, capture_output=True) - return result - - -def deploy(options): - logger.info("STARTING DEPLOY FROM CONTROLLER") - - if "ghcr" in options["chart"]: - version = options["chart"].split(":")[-1] - chart = "oci://" + options["chart"].split(":")[0] - else: - version = None - chart = "charts/" + options["chart"] - - if "release" not in options: - logger.info("Release option not specified.") - return json.dumps({"status": "failed", "reason": "Option release not set."}) - if "appconfig" in options: - # check if path is root path - if "path" in options["appconfig"]: - if "/" == options["appconfig"]["path"]: - logger.info("Root path cannot be copied.") - return json.dumps({"status": "failed", "reason": "Cannot copy / root path."}) - # check if valid userid - if "userid" in options["appconfig"]: - try: - userid = int(options["appconfig"]["userid"]) - except Exception: - logger.error("Userid not a number.", exc_info=True) - return json.dumps({"status": "failed", "reason": "Userid not an integer."}) - if userid > 1010 or userid < 999: - logger.info("Userid outside of allowed range.") - return json.dumps({"status": "failed", "reason": "Userid outside of allowed range."}) - else: - # if no userid, then add default id of 1000 - options["appconfig"]["userid"] = "1000" - # check if valid port - if "port" in options["appconfig"]: - try: - port = int(options["appconfig"]["port"]) - except Exception: - logger.error("Port not a number.", exc_info=True) - return json.dumps({"status": "failed", "reason": "Port not an integer."}) - if port > 9999 or port < 3000: - logger.info("Port outside of allowed range.") - return json.dumps({"status": "failed", "reason": "Port outside of allowed range."}) - # check if valid proxyheartbeatrate - if "proxyheartbeatrate" in options["appconfig"]: - try: - proxyheartbeatrate = int(options["appconfig"]["proxyheartbeatrate"]) - except Exception: - logger.error("Proxy heartbeat rate not a number.", exc_info=True) - return json.dumps({"status": "failed", "reason": "Proxyheartbeatrate not an integer."}) - if proxyheartbeatrate < 1: - logger.info("Heartbeat rate outside of allowed range, must be at least 1.") - return json.dumps( - {"status": "failed", "reason": "Heartbeat rate outside of allowed range, must be at least 1."} - ) - else: - options["appconfig"]["proxyheartbeatrate"] = "10000" - # check if valid proxyheartbeattimeout - if "proxyheartbeattimeout" in options["appconfig"]: - try: - proxyheartbeattimeout = int(options["appconfig"]["proxyheartbeattimeout"]) - except Exception: - logger.error("Proxy heartbeat timeout not a number.", exc_info=True) - return json.dumps({"status": "failed", "reason": "Proxyheartbeattimeout not an integer."}) - if proxyheartbeattimeout < -1 or proxyheartbeattimeout == 0: - logger.info("Heartbeat timeout outside of allowed range, cannot be lower than 0 except for -1.") - return json.dumps( - { - "status": "failed", - "reason": "Heartbeat timeout outside of allowed range, , cannot be lower than 0 except for -1.", - } - ) - else: - options["appconfig"]["proxyheartbeattimeout"] = "60000" - # check if valid proxycontainerwaittime - if "proxycontainerwaittime" in options["appconfig"]: - try: - proxycontainerwaittime = int(options["appconfig"]["proxycontainerwaittime"]) - except Exception: - logger.error("Proxy container wait time not a number.", exc_info=True) - return json.dumps({"status": "failed", "reason": "Proxycontainerwaittime not an integer."}) - if proxycontainerwaittime < 20000: - logger.info("Proxy container wait time outside of allowed range, must be at least 20000.") - return json.dumps( - { - "status": "failed", - "reason": "Proxycontainerwaittime outside of allowed range, must be at least 20000.", - } - ) - else: - options["appconfig"]["proxycontainerwaittime"] = "30000" - - # Save helm values file for internal reference - unique_filename = "charts/values/{}-{}.yaml".format(str(uuid.uuid4()), str(options["app_name"])) - f = open(unique_filename, "w") - f.write(yaml.dump(options)) - f.close() - - # building args for the equivalent of helm install command - args = [ - "helm", - "upgrade", - "--install", - "-n", - options["namespace"], - options["release"], - chart, - "-f", - unique_filename, - ] - - # Append version if deploying via ghcr - if version: - args.append("--version") - args.append(version) - args.append("--repository-cache"), - args.append("/app/charts/.cache/helm/repository") - - logger.info("CONTROLLER: RUNNING HELM COMMAND... ") - - result = subprocess.run(args, capture_output=True) - - # remove file - rm_args = ["rm", unique_filename] - subprocess.run(rm_args) - - return result diff --git a/apps/forms/__init__.py b/apps/forms/__init__.py new file mode 100644 index 000000000..b388094aa --- /dev/null +++ b/apps/forms/__init__.py @@ -0,0 +1,12 @@ +from .custom_field import CustomField # isort:skip +from .base import AppBaseForm, BaseForm +from .custom import CustomAppForm +from .dash import DashForm +from .filemanager import FilemanagerForm +from .jupyter import JupyterForm +from .netpolicy import NetpolicyForm +from .rstudio import RStudioForm +from .shiny import ShinyForm +from .tissuumaps import TissuumapsForm +from .volumes import VolumeForm +from .vscode import VSCodeForm diff --git a/apps/forms/base.py b/apps/forms/base.py new file mode 100644 index 000000000..298cb7b7e --- /dev/null +++ b/apps/forms/base.py @@ -0,0 +1,220 @@ +import uuid + +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Button, Div, Submit +from django import forms +from django.shortcuts import get_object_or_404 +from django.template import loader +from django.utils.safestring import mark_safe + +from apps.constants import HELP_MESSAGE_MAP +from apps.forms import CustomField +from apps.helpers import get_select_options +from apps.models import BaseAppInstance, Subdomain, VolumeInstance +from apps.types_.subdomain import SubdomainCandidateName, SubdomainTuple +from projects.models import Flavor, Project + +__all__ = ["BaseForm", "AppBaseForm"] + + +# Custom Widget that adds boostrap-style input group to the subdomain field +class SubdomainInputGroup(forms.Widget): + subdomain_template = "apps/partials/subdomain_input_group.html" + + def __init__(self, base_widget, data, *args, **kwargs): + # Initialise widget and get base instance + super(SubdomainInputGroup, self).__init__(*args, **kwargs) + self.base_widget = base_widget(*args, **kwargs) + self.data = data + + def get_context(self, name, value, attrs=None): + return { + "initial_subdomain": value, + "project_pk": self.data["project_pk"], + "hidden": self.data["hidden"], + "subdomain_list": get_select_options(self.data["project_pk"]), + } + + def render(self, name, value, attrs=None, renderer=None): + # Render base widget and add bootstrap spans + context = self.get_context(name, value, attrs) + template = loader.get_template(self.subdomain_template).render(context) + return mark_safe(template) + + +class BaseForm(forms.ModelForm): + """The most generic form for apps running on serve. Current intended use is for VolumesK8S type apps""" + + subdomain = forms.CharField( + required=False, + min_length=3, + max_length=53, + widget=SubdomainInputGroup(base_widget=forms.TextInput, data={}), + ) + + def __init__(self, *args, **kwargs): + self.project_pk = kwargs.pop("project_pk", None) + self.project = get_object_or_404(Project, pk=self.project_pk) if self.project_pk else None + self.model_name = self._meta.model._meta.verbose_name.replace("Instance", "") + + super().__init__(*args, **kwargs) + + self._setup_form_fields() + self._setup_form_helper() + + def _setup_form_fields(self): + # Populate subdomain field with instance subdomain if it exists + self.fields["subdomain"].widget.data["project_pk"] = self.project_pk + self.fields["subdomain"].widget.data["hidden"] = "hidden" + self.fields["subdomain"].initial = "" + if self.instance and self.instance.pk: + self.fields["subdomain"].initial = self.instance.subdomain.subdomain if self.instance.subdomain else "" + self.fields["subdomain"].widget.data["hidden"] = "" + + # Handle name + self.fields["name"].initial = "" + + def _setup_form_helper(self): + # Create a footer for submit form or cancel + self.footer = Div( + Button("cancel", "Cancel", css_class="btn-danger", onclick="window.history.back()"), + Submit("submit", "Submit"), + css_class="card-footer d-flex justify-content-between", + ) + self.helper = FormHelper(self) + self.helper.form_method = "post" + + def clean_subdomain(self): + cleaned_data = super().clean() + subdomain_input = cleaned_data.get("subdomain") + return self.validate_subdomain(subdomain_input) + + def clean_source_code_url(self): + cleaned_data = super().clean() + access = cleaned_data.get("access") + source_code_url = cleaned_data.get("source_code_url") + + if access == "public" and not source_code_url: + self.add_error("source_code_url", "Source is required when access is public.") + + return source_code_url + + def clean_note_on_linkonly_privacy(self): + cleaned_data = super().clean() + + access = cleaned_data.get("access", None) + note_on_linkonly_privacy = cleaned_data.get("note_on_linkonly_privacy", None) + + if access == "link" and not note_on_linkonly_privacy: + self.add_error( + "note_on_linkonly_privacy", "Please, provide a reason for making the app accessible only via a link." + ) + + return note_on_linkonly_privacy + + def validate_subdomain(self, subdomain_input): + # If user did not input subdomain, set it to our standard release name + if not subdomain_input: + subdomain = "r" + uuid.uuid4().hex[0:8] + if Subdomain.objects.filter(subdomain=subdomain_input).exists(): + error_message = "Wow, you just won the lottery. Contact us for a free chocolate bar." + raise forms.ValidationError(error_message) + return SubdomainTuple(subdomain, False) + + # Check if the instance has an existing subdomain + current_subdomain = getattr(self.instance, "subdomain", None) + + # Validate if the subdomain input matches the instance's current subdomain + if current_subdomain and current_subdomain.subdomain == subdomain_input: + return SubdomainTuple(subdomain_input, current_subdomain.is_created_by_user) + + # Convert the subdomain to lowercase. OK because we force convert to lowecase in the UI. + subdomain_input = subdomain_input.lower() + + # Check if the subdomain adheres to helm rules + subdomain_candidate = SubdomainCandidateName(subdomain_input, self.project_pk) + + try: + subdomain_candidate.validate_subdomain() + except forms.ValidationError as e: + raise forms.ValidationError(f"{e.message}") + + # Check for subdomain availability + if not subdomain_candidate.is_available(): + error_message = "Subdomain already exists. Please choose another one." + raise forms.ValidationError(error_message) + + return SubdomainTuple(subdomain_input, True) + + def get_common_field(self, field_name: str, **kwargs): + """ + This function is very useful because it allows you to create a custom field, + that has a question_mark with tooltip next to the label. So "Name (?)" will have a tooltip. + The text in the tooltip is defined in HELP_MESSAGE_MAP. + The CustomField class just inherits the crispy_forms.layout.Field class and adds the + help_message attribute to it. The template then uses it to render the tooltip for all fields + using this class. + """ + + spinner = kwargs.pop("spinner", False) + + template = "apps/custom_field.html" + base_args = dict( + css_class="form-control form-control-with-spinner" if spinner else "form-control", + wrapper_class="mb-3", + rows=3, + help_message=HELP_MESSAGE_MAP.get(field_name, ""), + spinner=spinner, + ) + + base_args.update(kwargs) + field = CustomField(field_name, **base_args) + field.set_template(template) + return Div(field, css_class="form-input-with-spinner" if spinner else None) + + class Meta: + # Specify model to be used + model = BaseAppInstance + fields = "__all__" + + +class AppBaseForm(BaseForm): + """ + Generic form for apps that require some compute power, + so you can treat this form as an actual base form for the most of the apps + """ + + volume = forms.ModelChoiceField( + queryset=VolumeInstance.objects.none(), required=False, empty_label="None", initial=None + ) + + flavor = forms.ModelChoiceField(queryset=Flavor.objects.none(), required=True, empty_label=None) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def _setup_form_fields(self): + super()._setup_form_fields() + flavor_queryset = ( + Flavor.objects.filter(project__pk=self.project_pk) if self.project_pk else Flavor.objects.none() + ) + # Handle Flavor field + self.fields["flavor"].label = "Hardware" + self.fields["flavor"].queryset = flavor_queryset + self.fields["flavor"].initial = flavor_queryset.first() # if flavor_queryset else None + + # Handle Access field + self.fields["access"].label = "Permission" + + # Handle Volume field + volume_queryset = ( + VolumeInstance.objects.filter(project__pk=self.project_pk) + if self.project_pk + else VolumeInstance.objects.none() + ) + + self.fields["volume"].queryset = volume_queryset + self.fields["volume"].initial = volume_queryset + self.fields["volume"].help_text = f"Select a volume to attach to your {self.model_name}." + + self.fields["subdomain"].help_text = "Choose subdomain, create a new one or leave blank to get a random one." diff --git a/apps/forms/custom.py b/apps/forms/custom.py new file mode 100644 index 000000000..f861c26bc --- /dev/null +++ b/apps/forms/custom.py @@ -0,0 +1,88 @@ +from crispy_forms.layout import HTML, Div, Field, Layout, MultiField +from django import forms + +from apps.forms.base import AppBaseForm +from apps.models import CustomAppInstance, VolumeInstance +from projects.models import Flavor + +__all__ = ["CustomAppForm"] +from apps.forms import CustomField + + +class CustomAppForm(AppBaseForm): + flavor = forms.ModelChoiceField(queryset=Flavor.objects.none(), required=False, empty_label=None) + port = forms.IntegerField(min_value=3000, max_value=9999, required=True) + image = forms.CharField(max_length=255, required=True) + path = forms.CharField(max_length=255, required=False) + + def _setup_form_fields(self): + # Handle Volume field + super()._setup_form_fields() + self.fields["volume"].initial = None + + def _setup_form_helper(self): + super()._setup_form_helper() + + body = Div( + self.get_common_field("name", placeholder="Name your app"), + self.get_common_field("description", rows=3, placeholder="Provide a detailed description of your app"), + Field("tags"), + self.get_common_field("subdomain", placeholder="Enter a subdomain or leave blank for a random one."), + Field("volume"), + self.get_common_field("path", placeholder="/home/..."), + self.get_common_field("flavor"), + self.get_common_field("access"), + self.get_common_field("source_code_url", placeholder="Provide a link to the public source code"), + self.get_common_field( + "note_on_linkonly_privacy", + rows=1, + placeholder="Describe why you want to make the app accessible only via a link", + ), + self.get_common_field("port", placeholder="8000"), + self.get_common_field("image"), + css_class="card-body", + ) + self.helper.layout = Layout(body, self.footer) + + def clean_path(self): + cleaned_data = super().clean() + + path = cleaned_data.get("path", None) + volume = cleaned_data.get("volume", None) + + if volume and not path: + self.add_error("path", "Path is required when volume is selected.") + + if path and not volume: + self.add_error("path", "Warning, you have provided a path, but not selected a volume.") + + if path: + # If new path matches current path, it is valid. + if self.instance and getattr(self.instance, "path", None) == path: + return path + # Verify that path starts with "/home" + path = path.strip().rstrip("/").lower().replace(" ", "") + if not path.startswith("/home"): + self.add_error("path", 'Path must start with "/home"') + + return path + + class Meta: + model = CustomAppInstance + fields = [ + "name", + "description", + "volume", + "path", + "flavor", + "access", + "note_on_linkonly_privacy", + "source_code_url", + "port", + "image", + "tags", + ] + labels = { + "note_on_linkonly_privacy": "Reason for choosing the link only option", + "tags": "Keywords", + } diff --git a/apps/forms/custom_field.py b/apps/forms/custom_field.py new file mode 100644 index 000000000..59a599273 --- /dev/null +++ b/apps/forms/custom_field.py @@ -0,0 +1,17 @@ +from crispy_forms.layout import Field + + +class CustomField(Field): + template = "apps/custom_field.html" + + def __init__(self, *args, **kwargs): + self.help_message = kwargs.pop("help_message", "") + self.spinner = kwargs.pop("spinner", False) + super().__init__(*args, **kwargs) + + def render(self, form, context, **kwargs): + context.update({"help_message": self.help_message, "spinner": self.spinner}) + return super().render(form, context, **kwargs) + + def set_template(self, name: str): + self.template = name diff --git a/apps/forms/dash.py b/apps/forms/dash.py new file mode 100644 index 000000000..807e35a79 --- /dev/null +++ b/apps/forms/dash.py @@ -0,0 +1,69 @@ +from crispy_forms.layout import HTML, Div, Field, Layout +from django import forms + +from apps.forms.base import AppBaseForm +from apps.models import DashInstance +from projects.models import Flavor + +__all__ = ["DashForm"] + + +class DashForm(AppBaseForm): + flavor = forms.ModelChoiceField(queryset=Flavor.objects.none(), required=False, empty_label=None) + port = forms.IntegerField(min_value=3000, max_value=9999, required=True) + image = forms.CharField(max_length=255, required=True) + + def _setup_form_fields(self): + # Handle Volume field + super()._setup_form_fields() + + def _setup_form_helper(self): + super()._setup_form_helper() + body = Div( + self.get_common_field("name", placeholder="Name your app"), + self.get_common_field("description", rows="3", placeholder="Provide a detailed description of your app"), + Field("tags"), + self.get_common_field( + "subdomain", placeholder="Enter a subdomain or leave blank for a random one", spinner=True + ), + self.get_common_field("flavor"), + self.get_common_field("access"), + self.get_common_field( + "note_on_linkonly_privacy", + placeholder="Describe why you want to make the app accessible only via a link", + ), + self.get_common_field("source_code_url", placeholder="Provide a link to the public source code"), + self.get_common_field("port", placeholder="8000"), + self.get_common_field("image", placeholder="registry/repository/image:tag"), + css_class="card-body", + ) + + self.helper.layout = Layout(body, self.footer) + + def clean(self): + cleaned_data = super().clean() + access = cleaned_data.get("access") + source_code_url = cleaned_data.get("source_code_url") + + if access == "public" and not source_code_url: + self.add_error("source_code_url", "Source is required when access is public.") + + return cleaned_data + + class Meta: + model = DashInstance + fields = [ + "name", + "description", + "flavor", + "access", + "note_on_linkonly_privacy", + "source_code_url", + "port", + "image", + "tags", + ] + labels = { + "tags": "Keywords", + "note_on_linkonly_privacy": "Reason for choosing the link only option", + } diff --git a/apps/forms/filemanager.py b/apps/forms/filemanager.py new file mode 100644 index 000000000..7f9676879 --- /dev/null +++ b/apps/forms/filemanager.py @@ -0,0 +1,46 @@ +from crispy_forms.layout import HTML, Button, Div, Field, Layout, Submit +from django import forms + +from apps.forms.base import AppBaseForm +from apps.models import FilemanagerInstance, VolumeInstance + +__all__ = ["FilemanagerForm"] + + +class FilemanagerForm(AppBaseForm): + volume = forms.ModelMultipleChoiceField(queryset=VolumeInstance.objects.none(), required=False) + + def _setup_form_helper(self): + super()._setup_form_helper() + + self.footer = Div( + Button("cancel", "Cancel", css_class="btn-danger", onclick="window.history.back()"), + Submit("submit", "Activate", css_class="btn-profile text-dark"), + css_class="card-footer d-flex justify-content-between", + ) + body = Div( + Div( + HTML( + """

You are about to activate file manager on SciLifeLab Serve. + You can use it to upload or download files to a volume associated with this project. + This service will be active for 24 hours and automatically terminated afterwards. + The uploaded files will stay on the volume even after this service has been terminated.

+ """ + ), + HTML("

Click 'Activate' to activate file manager

"), + css_class="p-3 my-3", + ), + Field("name", type="hidden"), + Field("access", type="hidden"), + Field("flavor", type="hidden"), + Field("volume"), + css_class="card-body", + ) + + self.fields["name"].initial = "File Manager" + self.fields["access"].initial = "project" + self.helper.layout = Layout(body, self.footer) + + class Meta: + model = FilemanagerInstance + fields = ["name", "access", "flavor", "volume"] diff --git a/apps/forms/jupyter.py b/apps/forms/jupyter.py new file mode 100644 index 000000000..fea95a339 --- /dev/null +++ b/apps/forms/jupyter.py @@ -0,0 +1,29 @@ +from crispy_forms.layout import HTML, Div, Field, Layout +from django import forms + +from apps.forms.base import AppBaseForm +from apps.models import JupyterInstance, VolumeInstance +from projects.models import Flavor + +__all__ = ["JupyterForm"] + + +class JupyterForm(AppBaseForm): + volume = forms.ModelMultipleChoiceField(queryset=VolumeInstance.objects.none(), required=False) + + def _setup_form_helper(self): + super()._setup_form_helper() + + body = Div( + self.get_common_field("name", placeholder="Name your app"), + Field("volume"), + self.get_common_field("access"), + self.get_common_field("flavor"), + css_class="card-body", + ) + + self.helper.layout = Layout(body, self.footer) + + class Meta: + model = JupyterInstance + fields = ["name", "volume", "flavor", "access"] diff --git a/apps/forms/netpolicy.py b/apps/forms/netpolicy.py new file mode 100644 index 000000000..821a72ae8 --- /dev/null +++ b/apps/forms/netpolicy.py @@ -0,0 +1,19 @@ +from crispy_forms.layout import Div, Field, Layout + +from apps.forms.base import BaseForm +from apps.models import NetpolicyInstance + +__all__ = ["NetpolicyForm"] + + +class NetpolicyForm(BaseForm): + def _setup_form_helper(self): + super()._setup_form_helper() + body = Div(Field("name", placeholder="Name your app"), css_class="card-body") + + self.helper.layout = Layout(body, self.footer) + + # create meta class + class Meta: + model = NetpolicyInstance + fields = ["name"] diff --git a/apps/forms/rstudio.py b/apps/forms/rstudio.py new file mode 100644 index 000000000..39317c62c --- /dev/null +++ b/apps/forms/rstudio.py @@ -0,0 +1,27 @@ +from crispy_forms.layout import HTML, Div, Field, Layout +from django import forms + +from apps.forms.base import AppBaseForm +from apps.models import RStudioInstance, VolumeInstance + +__all__ = ["RStudioForm"] + + +class RStudioForm(AppBaseForm): + volume = forms.ModelMultipleChoiceField(queryset=VolumeInstance.objects.none(), required=False) + + def _setup_form_helper(self): + super()._setup_form_helper() + body = Div( + self.get_common_field("name", placeholder="Name your app"), + Field("volume"), + self.get_common_field("flavor"), + self.get_common_field("access"), + css_class="card-body", + ) + + self.helper.layout = Layout(body, self.footer) + + class Meta: + model = RStudioInstance + fields = ["name", "volume", "flavor", "access"] diff --git a/apps/forms/shiny.py b/apps/forms/shiny.py new file mode 100644 index 000000000..f434f40d4 --- /dev/null +++ b/apps/forms/shiny.py @@ -0,0 +1,76 @@ +from crispy_forms.layout import HTML, Div, Field, Layout +from django import forms + +from apps.forms.base import AppBaseForm +from apps.models import ShinyInstance +from projects.models import Flavor + +__all__ = ["ShinyForm"] + + +class ShinyForm(AppBaseForm): + flavor = forms.ModelChoiceField(queryset=Flavor.objects.none(), required=False, empty_label=None) + port = forms.IntegerField(min_value=3000, max_value=9999, required=True) + image = forms.CharField(max_length=255, required=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance and self.instance.pk: + self.initial_subdomain = self.instance.subdomain.subdomain + + def _setup_form_fields(self): + # Handle Volume field + super()._setup_form_fields() + + def _setup_form_helper(self): + super()._setup_form_helper() + body = Div( + self.get_common_field("name", placeholder="Name your app"), + self.get_common_field("description", rows="3", placeholder="Provide a detailed description of your app"), + Field("tags"), + self.get_common_field( + "subdomain", placeholder="Enter a subdomain or leave blank for a random one", spinner=True + ), + self.get_common_field("flavor"), + self.get_common_field("access"), + self.get_common_field( + "note_on_linkonly_privacy", + placeholder="Describe why you want to make the app accessible only via a link", + ), + self.get_common_field("source_code_url", placeholder="Provide a link to the public source code"), + self.get_common_field("port", placeholder="3838"), + self.get_common_field("image", placeholder="registry/repository/image:tag"), + css_class="card-body", + ) + + self.helper.layout = Layout(body, self.footer) + + def clean(self): + cleaned_data = super().clean() + access = cleaned_data.get("access", None) + source_code_url = cleaned_data.get("source_code_url", None) + + if access == "public" and not source_code_url: + self.add_error("source_code_url", "Source is required when access is public.") + + return cleaned_data + + class Meta: + model = ShinyInstance + fields = [ + "name", + "description", + "volume", + "flavor", + "access", + "note_on_linkonly_privacy", + "source_code_url", + "port", + "image", + "tags", + ] + labels = { + "tags": "Keywords", + "note_on_linkonly_privacy": "Reason for choosing the link only option", + } diff --git a/apps/forms/tissuumaps.py b/apps/forms/tissuumaps.py new file mode 100644 index 000000000..22ae2c003 --- /dev/null +++ b/apps/forms/tissuumaps.py @@ -0,0 +1,43 @@ +from crispy_forms.layout import HTML, Div, Field, Layout +from django import forms + +from apps.forms.base import AppBaseForm +from apps.models import TissuumapsInstance +from projects.models import Flavor + +__all__ = ["TissuumapsForm"] + + +class TissuumapsForm(AppBaseForm): + def _setup_form_fields(self): + # Handle Volume field + super()._setup_form_fields() + volume_form_field = self.fields["volume"] + volume_form_field.required = True + volume_form_field.empty_label = None + + def _setup_form_helper(self): + super()._setup_form_helper() + body = Div( + self.get_common_field("name", placeholder="Name your app"), + self.get_common_field("description", rows="3", placeholder="Provide a detailed description of your app"), + Field("tags"), + self.get_common_field( + "subdomain", placeholder="Enter a subdomain or leave blank for a random one", spinner=True + ), + Field("volume"), + self.get_common_field("flavor"), + self.get_common_field("access"), + self.get_common_field( + "note_on_linkonly_privacy", + placeholder="Describe why you want to make the app accessible only via a link", + ), + css_class="card-body", + ) + + self.helper.layout = Layout(body, self.footer) + + class Meta: + model = TissuumapsInstance + fields = ["name", "description", "volume", "flavor", "access", "note_on_linkonly_privacy", "tags"] + labels = {"tags": "Keywords", "note_on_linkonly_privacy": "Reason for choosing the link only option"} diff --git a/apps/forms/volumes.py b/apps/forms/volumes.py new file mode 100644 index 000000000..fdfd83040 --- /dev/null +++ b/apps/forms/volumes.py @@ -0,0 +1,24 @@ +from crispy_forms.layout import Div, Field, Layout + +from apps.forms.base import BaseForm +from apps.models import VolumeInstance + +__all__ = ["VolumeForm"] + + +class VolumeForm(BaseForm): + def _setup_form_helper(self): + super()._setup_form_helper() + body = Div( + Field("name", placeholder="Name your app"), + Field("size"), + Field("subdomain", placeholder="Enter a subdomain or leave blank for a random one"), + css_class="card-body", + ) + + self.helper.layout = Layout(body, self.footer) + + # create meta class + class Meta: + model = VolumeInstance + fields = ["name", "size"] diff --git a/apps/forms/vscode.py b/apps/forms/vscode.py new file mode 100644 index 000000000..8d1f9745e --- /dev/null +++ b/apps/forms/vscode.py @@ -0,0 +1,27 @@ +from crispy_forms.layout import HTML, Div, Field, Layout +from django import forms + +from apps.forms.base import AppBaseForm +from apps.models import VolumeInstance, VSCodeInstance + +__all__ = ["VSCodeForm"] + + +class VSCodeForm(AppBaseForm): + volume = forms.ModelMultipleChoiceField(queryset=VolumeInstance.objects.none(), required=False) + + def _setup_form_helper(self): + super()._setup_form_helper() + body = Div( + self.get_common_field("name", placeholder="Name your app"), + Field("volume"), + self.get_common_field("flavor"), + self.get_common_field("access"), + css_class="card-body", + ) + + self.helper.layout = Layout(body, self.footer) + + class Meta: + model = VSCodeInstance + fields = ["name", "volume", "flavor", "access"] diff --git a/apps/generate_form.py b/apps/generate_form.py deleted file mode 100644 index 9e6bd54a1..000000000 --- a/apps/generate_form.py +++ /dev/null @@ -1,280 +0,0 @@ -from django.conf import settings -from django.db.models import Case, IntegerField, Q, Value, When - -from models.models import Model -from projects.models import S3, Environment, Flavor, ReleaseName -from studio.utils import get_logger - -from .models import AppInstance, Apps - -logger = get_logger(__name__) - -key_words = [ - "appobj", - "model", - "flavor", - "environment", - "volumes", - "apps", - "logs", - "permissions", - "default_values", - "export-cli", - "csrfmiddlewaretoken", - "S3", - "env_variables", - "publishable", -] - - -def get_form_models(aset, project, appinstance=[]): - dep_model = False - models = [] - if "model" in aset: - logger.info("app requires a model") - dep_model = True - if "object_type" in aset["model"]: - object_type = aset["model"]["object_type"] - else: - object_type = "default" - models = Model.objects.filter(project=project, object_type__slug=object_type) - - for model in models: - if appinstance and model.appinstance_set.filter(pk=appinstance.pk).exists(): - logger.info(model) - model.selected = "selected" - else: - model.selected = "" - return dep_model, models - - -def get_form_apps(aset, project, myapp, user, appinstance=[]): - dep_apps = False - app_deps = [] - if "apps" in aset: - dep_apps = True - app_deps = dict() - apps = aset["apps"] - for app_name, option_type in apps.items(): - logger.info(">>>>>") - logger.info(app_name) - # .order_by('-revision').first() - app_obj = Apps.objects.filter(name=app_name) - logger.info(app_obj) - logger.info(">>>>>") - # TODO: Only get app instances that we have permission to list. - - app_instances = AppInstance.objects.get_available_app_dependencies( - user=user, project=project, app_name=app_name - ) - # TODO: Special case here for "environment" app. - # Could be solved by supporting "condition": - # '"appobj.app_slug":"true"' - if app_name == "Environment": - app_instances = AppInstance.objects.filter( - ~Q(state="Deleted"), - Q(owner=user) | Q(access__in=["project", "public"]), - project=project, - app__name=app_name, - parameters__contains={"appobj": {myapp.slug: True}}, - ) - - for ain in app_instances: - if appinstance and ain.appinstance_set.filter(pk=appinstance.pk).exists(): - ain.selected = "selected" - else: - ain.selected = "" - - if option_type == "one": - app_deps[app_name] = { - "instances": app_instances, - "option_type": "", - } - else: - app_deps[app_name] = { - "instances": app_instances, - "option_type": "multiple", - } - return dep_apps, app_deps - - -def get_disable_fields(): - try: - result = settings.DISABLED_APP_INSTANCE_FIELDS - return result if result is not None else [] - except Exception: - return [] - - -def get_form_primitives(app_settings, appinstance=[]): - disabled_fields = get_disable_fields() - - all_keys = app_settings.keys() - primitives = dict() - - for key in all_keys: - if key not in key_words: - primitives[key] = app_settings[key] - if "meta" in primitives[key]: - primitives[key]["meta_title"] = primitives[key]["meta"]["title"] - else: - primitives[key]["meta_title"] = key - - for disabled_field in disabled_fields: - if disabled_field in primitives[key]: - del primitives[key][disabled_field] - - if appinstance and key in appinstance.parameters.keys(): - for _key, _ in app_settings[key].items(): - is_meta_key = _key in ["meta", "meta_title"] - if not is_meta_key: - parameters_of_key = appinstance.parameters[key] - - logger.info("_key: %s", _key) - - if _key in parameters_of_key.keys(): - primitives[key][_key]["default"] = parameters_of_key[_key] - - return primitives - - -def get_form_permission(aset, project, appinstance=[]): - form_permissions = { - "project": {"value": "false", "option": "false"}, - "public": {"value": "false", "option": "false"}, - "link": {"value": "false", "option": "false"}, - "private": {"value": "true", "option": "true"}, - } - dep_permissions = True - if "permissions" in aset: - form_permissions["project"] = aset["permissions"]["project"] - form_permissions["public"] = aset["permissions"]["public"] - form_permissions["link"] = aset["permissions"]["link"] - form_permissions["private"] = aset["permissions"]["private"] - - # I don't really know why we keep this - # if not form_permissions: - # dep_permissions = False - - if appinstance: - try: - ai_vals = appinstance.parameters - logger.info(ai_vals["permissions"]) - form_permissions["public"]["value"] = ai_vals["permissions"].get("public", False) - form_permissions["project"]["value"] = ai_vals["permissions"].get("project", False) - form_permissions["private"]["value"] = ai_vals["permissions"].get("private", False) - form_permissions["link"]["value"] = ai_vals["permissions"].get("link", False) - logger.info(form_permissions) - except Exception: - logger.error("Permissions not set for app instance, using default.", exc_info=True) - - return dep_permissions, form_permissions - - -# TODO: refactor. Change default value to immutable -def get_form_appobj(aset, project, appinstance=[]): - logger.info("CHECKING APP OBJ") - dep_appobj = False - appobjs = dict() - if "appobj" in aset: - logger.info("NEEDS APP OBJ") - dep_appobj = True - appobjs["objs"] = Apps.objects.all() - appobjs["title"] = aset["appobj"]["title"] - appobjs["type"] = aset["appobj"]["type"] - - logger.info(appobjs) - return dep_appobj, appobjs - - -def get_form_environments(aset, project, app, appinstance=[]): - logger.info("CHECKING ENVIRONMENT") - dep_environment = False - environments = dict() - if "environment" in aset: - dep_environment = True - if aset["environment"]["type"] == "match": - environments["objs"] = Environment.objects.filter( - Q(project=project) | Q(project__isnull=True, public=True), - app__slug=app.slug, - ).order_by( - Case( - When(name__contains="- public", then=Value(1)), - default=Value(0), - output_field=IntegerField(), - ), - "-name", - ) - elif aset["environment"]["type"] == "any": - environments["objs"] = Environment.objects.filter( - Q(project=project) | Q(project__isnull=True, public=True) - ).order_by( - Case( - When(name__contains="- public", then=Value(1)), - default=Value(0), - output_field=IntegerField(), - ), - "-name", - ) - elif "apps" in aset["environment"]: - environments["objs"] = Environment.objects.filter( - Q(project=project) | Q(project__isnull=True, public=True), - app__slug__in=aset["environment"]["apps"], - ).order_by( - Case( - When(name__contains="- public", then=Value(1)), - default=Value(0), - output_field=IntegerField(), - ), - "-name", - ) - - environments["title"] = aset["environment"]["title"] - if appinstance: - ai_vals = appinstance.parameters - environments["selected"] = ai_vals["environment"]["pk"] - - return dep_environment, environments - - -def get_form_S3(aset, project, app, appinstance=[]): - logger.info("CHECKING S3") - dep_S3 = False - s3stores = [] - if "S3" in aset: - dep_S3 = True - s3stores = S3.objects.filter(project=project) - return dep_S3, s3stores - - -def get_link_privacy_type_note(aset, project, appinstance=tuple()): - if appinstance: - return appinstance.note_on_linkonly_privacy - return "" - - -def generate_form(aset, project, app, user, appinstance=[]): - form = dict() - form["dep_model"], form["models"] = get_form_models(aset, project, appinstance) - form["dep_apps"], form["app_deps"] = get_form_apps(aset, project, app, user, appinstance) - form["dep_appobj"], form["appobjs"] = get_form_appobj(aset, project, appinstance) - form["dep_environment"], form["environments"] = get_form_environments(aset, project, app, appinstance) - form["dep_S3"], form["s3stores"] = get_form_S3(aset, project, app, appinstance) - - form["dep_vols"] = False - form["dep_flavor"] = False - if "flavor" in aset: - form["dep_flavor"] = True - if appinstance and appinstance.flavor: - form["current_flavor"] = Flavor.objects.filter(project=project, id=appinstance.flavor.id) - form["flavors"] = Flavor.objects.filter(project=project).exclude(id=appinstance.flavor.id) - else: - form["current_flavor"] = None - form["flavors"] = Flavor.objects.filter(project=project) - - form["primitives"] = get_form_primitives(aset, appinstance) - form["dep_permissions"], form["form_permissions"] = get_form_permission(aset, project, appinstance) - release_names = ReleaseName.objects.filter(project=project, status="active") - form["release_names"] = release_names - form["link_privacy_type_note"] = get_link_privacy_type_note(aset, project, appinstance) - return form diff --git a/apps/helpers.py b/apps/helpers.py index 6c4803b85..686d44465 100644 --- a/apps/helpers.py +++ b/apps/helpers.py @@ -1,75 +1,34 @@ -import re -import time -import uuid from datetime import datetime from enum import Enum from typing import Optional -from django.apps import apps -from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db import transaction -from django.template import engines -from projects.models import Flavor +from apps.types_.subdomain import SubdomainCandidateName, SubdomainTuple from studio.utils import get_logger -from .models import AppInstance, AppStatus -from .serialize import serialize_app -from .tasks import deploy_resource +from .models import Apps, AppStatus, BaseAppInstance, Subdomain logger = get_logger(__name__) -ReleaseName = apps.get_model(app_label=settings.RELEASENAME_MODEL) - - -def create_instance_params(instance, action="create"): - logger.info("HELPER - CREATING INSTANCE PARAMS") - RELEASE_NAME = "r" + uuid.uuid4().hex[0:8] - logger.info("RELEASE_NAME: " + RELEASE_NAME) - - SERVICE_NAME = RELEASE_NAME + "-" + instance.app.slug - # TODO: Fix for multicluster setup, look at e.g. labs - HOST = settings.DOMAIN - AUTH_HOST = settings.AUTH_DOMAIN - AUTH_PROTOCOL = settings.AUTH_PROTOCOL - NAMESPACE = settings.NAMESPACE - - # Add some generic parameters. - parameters = { - "release": RELEASE_NAME, - "chart": str(instance.app.chart), - "namespace": NAMESPACE, - "app_slug": str(instance.app.slug), - "app_revision": str(instance.app.revision), - "appname": RELEASE_NAME, - "global": { - "domain": HOST, - "auth_domain": AUTH_HOST, - "protocol": AUTH_PROTOCOL, - }, - "s3sync": {"image": "scaleoutsystems/s3-sync:latest"}, - "service": { - "name": SERVICE_NAME, - "port": instance.parameters["default_values"]["port"], - "targetport": instance.parameters["default_values"]["targetport"], - }, - "storageClass": settings.STORAGECLASS, - } - - instance.parameters.update(parameters) - - if "project" not in instance.parameters: - instance.parameters["project"] = dict() - - instance.parameters["project"].update({"name": instance.project.name, "slug": instance.project.slug}) - - -def can_access_app_instance(app_instance, user, project): + +def get_select_options(project_pk, selected_option=""): + select_options = [] + for sub in Subdomain.objects.filter(project=project_pk, is_created_by_user=True).values_list( + "subdomain", flat=True + ): + subdomain_candidate = SubdomainCandidateName(sub, project_pk) + if subdomain_candidate.is_available() or sub == selected_option: + select_options.append(sub) + return select_options + + +def can_access_app_instance(instance, user, project): """Checks if a user has access to an app instance Args: - app_instance (AppInstance): app instance object + instance (subclass of BaseAppInstance): instance object user (User): user object project (Project): project object @@ -78,32 +37,32 @@ def can_access_app_instance(app_instance, user, project): """ authorized = False - if app_instance.access in ("public", "link"): + if instance.access in ("public", "link"): authorized = True - elif app_instance.access == "project": + elif instance.access == "project": if user.has_perm("can_view_project", project): authorized = True else: - if user.has_perm("can_access_app", app_instance): + if user.has_perm("can_access_app", instance): authorized = True return authorized -def can_access_app_instances(app_instances, user, project): +def can_access_app_instances(instances, user, project): """Checks if user has access to all app instances provided Args: - app_instances (Queryset): list of app instances + instances (Queryset): list of instances user (User): user object project (Project): project object Returns: Boolean: returns False if user lacks - permission to any of the app instances provided + permission to any of the instances provided """ - for app_instance in app_instances: - authorized = can_access_app_instance(app_instance, user, project) + for instance in instances: + authorized = can_access_app_instance(instance, user, project) if not authorized: return False @@ -135,113 +94,6 @@ def handle_permissions(parameters, project): return access -# TODO: refactor -# 1. data=[]. This is bad as this is not a list, but a dict and secondly, -# it is not a good practice to use mutable as default -# 2. Use some type annotations -# 3. Use tuple as return type instead of list -def create_app_instance(user, project, app, app_settings, data=[], wait=False): - app_name = data.get("app_name") - app_description = data.get("app_description") - created_by_admin = False - # For custom apps, if admin user fills form, then data.get("admin") exists as hidden input - if data.get("created_by_admin"): - created_by_admin = True - parameters_out, app_deps, model_deps = serialize_app(data, project, app_settings, user.username) - parameters_out["created_by_admin"] = created_by_admin - authorized = can_access_app_instances(app_deps, user, project) - - if not authorized: - raise Exception("Not authorized to use specified app dependency") - - access = handle_permissions(parameters_out, project) - - flavor_id = data.get("flavor", None) - flavor = Flavor.objects.get(pk=flavor_id, project=project) if flavor_id else None - - source_code_url = data.get("source_code_url") - app_instance = AppInstance( - name=app_name, - description=app_description, - access=access, - app=app, - project=project, - info={}, - parameters=parameters_out, - owner=user, - flavor=flavor, - note_on_linkonly_privacy=data.get("link_privacy_type_note"), - source_code_url=source_code_url, - ) - - create_instance_params(app_instance, "create") - - # Attempt to create a ReleaseName model object - rel_name_obj = [] - if "app_release_name" in data and data.get("app_release_name") != "": - submitted_rn = data.get("app_release_name") - try: - rel_name_obj = ReleaseName.objects.get(name=submitted_rn, project=project, status="active") - rel_name_obj.status = "in-use" - rel_name_obj.save() - app_instance.parameters["release"] = submitted_rn - except Exception: - logger.error("Submitted release name not owned by project.", exc_info=True) - return [False, None, None] - - # Add fields for apps table: - # to be displayed as app details in views - if app_instance.app.table_field and app_instance.app.table_field != "": - django_engine = engines["django"] - info_field = django_engine.from_string(app_instance.app.table_field).render(app_instance.parameters) - # Nikita Churikov @ 2024-01-25 - # TODO: this seems super bad and exploitable - app_instance.table_field = eval(info_field) - else: - app_instance.table_field = {} - - # Setting status fields before saving app instance - status = AppStatus(appinstance=app_instance) - status.status_type = "Created" - status.info = app_instance.parameters["release"] - if "appconfig" in app_instance.parameters: - if "path" in app_instance.parameters["appconfig"]: - # remove trailing / in all cases - if app_instance.parameters["appconfig"]["path"] != "/": - app_instance.parameters["appconfig"]["path"] = app_instance.parameters["appconfig"]["path"].rstrip("/") - if app_deps: - if not created_by_admin: - app_instance.parameters["appconfig"]["path"] = ( - "/home/" + app_instance.parameters["appconfig"]["path"] - ) - if "userid" not in app_instance.parameters["appconfig"]: - app_instance.parameters["appconfig"]["userid"] = "1000" - if "proxyheartbeatrate" not in app_instance.parameters["appconfig"]: - app_instance.parameters["appconfig"]["proxyheartbeatrate"] = "10000" - if "proxyheartbeattimeout" not in app_instance.parameters["appconfig"]: - app_instance.parameters["appconfig"]["proxyheartbeattimeout"] = "60000" - if "proxycontainerwaittime" not in app_instance.parameters["appconfig"]: - app_instance.parameters["appconfig"]["proxycontainerwaittime"] = "30000" - app_instance.save() - # Saving ReleaseName, status and setting up dependencies - if rel_name_obj: - rel_name_obj.app = app_instance - rel_name_obj.save() - status.save() - app_instance.app_dependencies.set(app_deps) - app_instance.model_dependencies.set(model_deps) - - # Finally, attempting to create apps resources - res = deploy_resource.delay(app_instance.pk, "create") - - # wait is passed as a function parameter - if wait: - while not res.ready(): - time.sleep(0.1) - - return [True, project.slug, app_instance.app.category.slug] - - class HandleUpdateStatusResponseCode(Enum): NO_ACTION = 0 UPDATED_STATUS = 1 @@ -256,7 +108,7 @@ def handle_update_status_request( Helper function to handle update app status requests by determining if the request should be performed or ignored. - :param release str: The release id of the app instance, stored in the AppInstance.parameters dict. + :param release str: The release id of the app instance, stored in the AppInstance.k8s_values dict in the subdomain. :param new_status str: The new status code. Trimmed to max 15 chars if needed. :param event_ts timestamp: A JSON-formatted timestamp in UTC, e.g. 2024-01-25T16:02:50.00Z. :param event_msg json dict: An optional json dict containing pod-msg and/or container-msg. @@ -272,27 +124,27 @@ def handle_update_status_request( # We wrap the select and update tasks in a select_for_update lock # to avoid race conditions. + subdomain = Subdomain.objects.get(subdomain=release) + with transaction.atomic(): - app_instance = ( - AppInstance.objects.select_for_update().filter(parameters__contains={"release": release}).last() - ) - if app_instance is None: + instance = BaseAppInstance.objects.select_for_update().filter(subdomain=subdomain).last() + if instance is None: logger.info("The specified app instance was not found release=%s.", release) raise ObjectDoesNotExist - logger.debug("The app instance exists. name=%s, state=%s", app_instance.name, app_instance.state) + logger.debug("The app instance exists. name=%s", instance.name) # Also get the latest app status object for this app instance - if app_instance.status is None or app_instance.status.count() == 0: + if instance.app_status is None: # Missing app status so create one now logger.info("AppInstance %s does not have an associated AppStatus. Creating one now.", release) - status_object = AppStatus(appinstance=app_instance) - update_status(app_instance, status_object, new_status, event_ts, event_msg) + app_status = AppStatus.objects.create() + update_status(instance, app_status, new_status, event_ts, event_msg) return HandleUpdateStatusResponseCode.CREATED_FIRST_STATUS else: - app_status = app_instance.status.latest() + app_status = instance.app_status - logger.debug("AppStatus %s, %s, %s.", app_status.status_type, app_status.time, app_status.info) + logger.debug("AppStatus %s, %s, %s.", app_status.status, app_status.time, app_status.info) # Now determine whether to update the state and status @@ -308,7 +160,7 @@ def handle_update_status_request( # The event is newer than the existing persisted object - if new_status == app_instance.state: + if new_status == instance.app_status.status: # The same status. Simply update the time. logger.debug("The same status. Simply update the time.") update_status_time(app_status, event_ts, event_msg) @@ -316,8 +168,8 @@ def handle_update_status_request( # Different status and newer time logger.debug("Different status and newer time.") - status_object = AppStatus(appinstance=app_instance) - update_status(app_instance, status_object, new_status, event_ts, event_msg) + status_object = instance.app_status + update_status(instance, status_object, new_status, event_ts, event_msg) return HandleUpdateStatusResponseCode.UPDATED_STATUS except Exception as err: @@ -331,7 +183,7 @@ def update_status(appinstance, status_object, status, status_ts=None, event_msg= Helper function to update the status of an appinstance and a status object. """ # Persist a new app statuss object - status_object.status_type = status + status_object.status = status status_object.time = status_ts status_object.info = event_msg status_object.save() @@ -346,8 +198,8 @@ def update_status(appinstance, status_object, status, status_ts=None, event_msg= status_object.save(update_fields=["time", "info"]) # Update the app instance object - appinstance.state = status - appinstance.save(update_fields=["state"]) + appinstance.app_status = status_object + appinstance.save(update_fields=["app_status"]) @transaction.atomic @@ -362,3 +214,114 @@ def update_status_time(status_object, status_ts, event_msg=None): else: status_object.info = event_msg status_object.save(update_fields=["time", "info"]) + + +def get_URI(values): + # Subdomain is empty if app is already deleted + subdomain = values["subdomain"] if "subdomain" in values else "" + URI = f"https://{subdomain}.{values['global']['domain']}" + + URI = URI.strip("/") + return URI + + +@transaction.atomic +def create_instance_from_form(form, project, app_slug, app_id=None): + """ + Create or update an instance from a form. This function handles both the creation of new instances + and the updating of existing ones based on the presence of an app_id. + + Parameters: + - form: The form instance containing validated data. + - project: The project to which this instance belongs. + - app_slug: Slug of the app associated with this instance. + - app_id: Optional ID of an existing instance to update. If None, a new instance is created. + + Returns: + - The newly created or updated instance. + + Raises: + - ValueError: If the form does not have a 'subdomain' or if the specified app cannot be found. + """ + from .tasks import deploy_resource + + subdomain_name, is_created_by_user = get_subdomain_name(form) + + instance = form.save(commit=False) + + # Handle status creation or retrieval + status = get_or_create_status(instance, app_id) + # Retrieve or create the subdomain + subdomain, created = Subdomain.objects.get_or_create( + subdomain=subdomain_name, project=project, is_created_by_user=is_created_by_user + ) + + if app_id: + handle_subdomain_change(instance, subdomain, subdomain_name) + + app_slug = handle_shiny_proxy_case(instance, app_slug, app_id) + + app = get_app(app_slug) + + setup_instance(instance, subdomain, app, project, status) + save_instance_and_related_data(instance, form) + + deploy_resource.delay(instance.serialize()) + + +def get_subdomain_name(form): + subdomain_tuple = form.cleaned_data.get("subdomain") + if not str(subdomain_tuple): + raise ValueError("Subdomain is required") + return subdomain_tuple + + +def get_or_create_status(instance, app_id): + return instance.app_status if app_id else AppStatus.objects.create() + + +def handle_subdomain_change(instance, subdomain, subdomain_name): + from .tasks import delete_resource + + if instance.subdomain.subdomain != subdomain_name: + # In this special case, we avoid async task. + delete_resource(instance.serialize()) + old_subdomain = instance.subdomain + instance.subdomain = subdomain + instance.save(update_fields=["subdomain"]) + if old_subdomain and not old_subdomain.is_created_by_user: + old_subdomain.delete() + + +def handle_shiny_proxy_case(instance, app_slug, app_id): + conditions = {("shinyapp", True): "shinyproxyapp", ("shinyproxyapp", False): "shinyapp"} + + proxy_status = getattr(instance, "proxy", False) + new_slug = conditions.get((app_slug, proxy_status), app_slug) + + return new_slug + + +def get_app(app_slug): + try: + return Apps.objects.get(slug=app_slug) + except Apps.DoesNotExist: + logger.error("App with slug %s not found during instance creation", app_slug) + raise ValueError(f"App with slug {app_slug} not found") + + +def setup_instance(instance, subdomain, app, project, status, is_created_by_user=False): + instance.subdomain = subdomain + instance.app = app + instance.chart = instance.app.chart + instance.project = project + instance.owner = project.owner + instance.app_status = status + + +def save_instance_and_related_data(instance, form): + instance.save() + form.save_m2m() + instance.set_k8s_values() + instance.url = get_URI(instance.k8s_values) + instance.save(update_fields=["k8s_values", "url"]) diff --git a/apps/migrations/0001_initial.py b/apps/migrations/0001_initial.py new file mode 100644 index 000000000..ff81eb171 --- /dev/null +++ b/apps/migrations/0001_initial.py @@ -0,0 +1,211 @@ +# Generated by Django 5.0.2 on 2024-05-27 07:28 + +import django.db.models.deletion +import tagulous.models.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="AppCategories", + fields=[ + ("name", models.CharField(max_length=512)), + ("priority", models.IntegerField(default=100)), + ("slug", models.CharField(default="", max_length=512, primary_key=True, serialize=False)), + ], + options={ + "verbose_name": "App Category", + "verbose_name_plural": "App Categories", + }, + ), + migrations.CreateModel( + name="AppStatus", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("info", models.JSONField(blank=True, null=True)), + ("status", models.CharField(default="Creating", max_length=15)), + ("time", models.DateTimeField(auto_now_add=True)), + ], + options={ + "verbose_name": "App Status", + "verbose_name_plural": "App Statuses", + "get_latest_by": "time", + }, + ), + migrations.CreateModel( + name="BaseAppInstance", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("chart", models.CharField(max_length=512)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("deleted_on", models.DateTimeField(blank=True, null=True)), + ("info", models.JSONField(blank=True, null=True)), + ("name", models.CharField(default="app_name", max_length=512)), + ("k8s_values", models.JSONField(blank=True, null=True)), + ("url", models.URLField(blank=True, null=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "# BASE APP INSTANCE", + "verbose_name_plural": "# BASE APP INSTANCES", + "permissions": [("can_access_app", "Can access app service")], + }, + ), + migrations.CreateModel( + name="Subdomain", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("subdomain", models.CharField(max_length=53, unique=True)), + ], + options={ + "verbose_name": "Subdomain", + "verbose_name_plural": "Subdomains", + }, + ), + migrations.CreateModel( + name="Tagulous_CustomAppInstance_tags", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255, unique=True)), + ("slug", models.SlugField()), + ( + "count", + models.IntegerField(default=0, help_text="Internal counter of how many times this tag is in use"), + ), + ( + "protected", + models.BooleanField(default=False, help_text="Will not be deleted when the count reaches 0"), + ), + ], + options={ + "ordering": ("name",), + "abstract": False, + }, + bases=(tagulous.models.models.BaseTagModel, models.Model), + ), + migrations.CreateModel( + name="Tagulous_DashInstance_tags", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255, unique=True)), + ("slug", models.SlugField()), + ( + "count", + models.IntegerField(default=0, help_text="Internal counter of how many times this tag is in use"), + ), + ( + "protected", + models.BooleanField(default=False, help_text="Will not be deleted when the count reaches 0"), + ), + ], + options={ + "ordering": ("name",), + "abstract": False, + }, + bases=(tagulous.models.models.BaseTagModel, models.Model), + ), + migrations.CreateModel( + name="Tagulous_ShinyInstance_tags", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255, unique=True)), + ("slug", models.SlugField()), + ( + "count", + models.IntegerField(default=0, help_text="Internal counter of how many times this tag is in use"), + ), + ( + "protected", + models.BooleanField(default=False, help_text="Will not be deleted when the count reaches 0"), + ), + ], + options={ + "ordering": ("name",), + "abstract": False, + }, + bases=(tagulous.models.models.BaseTagModel, models.Model), + ), + migrations.CreateModel( + name="Tagulous_SocialMixin_tags", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255, unique=True)), + ("slug", models.SlugField()), + ( + "count", + models.IntegerField(default=0, help_text="Internal counter of how many times this tag is in use"), + ), + ( + "protected", + models.BooleanField(default=False, help_text="Will not be deleted when the count reaches 0"), + ), + ], + options={ + "ordering": ("name",), + "abstract": False, + }, + bases=(tagulous.models.models.BaseTagModel, models.Model), + ), + migrations.CreateModel( + name="Tagulous_TissuumapsInstance_tags", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255, unique=True)), + ("slug", models.SlugField()), + ( + "count", + models.IntegerField(default=0, help_text="Internal counter of how many times this tag is in use"), + ), + ( + "protected", + models.BooleanField(default=False, help_text="Will not be deleted when the count reaches 0"), + ), + ], + options={ + "ordering": ("name",), + "abstract": False, + }, + bases=(tagulous.models.models.BaseTagModel, models.Model), + ), + migrations.CreateModel( + name="Apps", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("user_can_create", models.BooleanField(default=True)), + ("user_can_edit", models.BooleanField(default=True)), + ("user_can_delete", models.BooleanField(default=True)), + ("access", models.CharField(blank=True, default="public", max_length=20, null=True)), + ("chart", models.CharField(max_length=512)), + ("chart_archive", models.FileField(blank=True, null=True, upload_to="apps/")), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("description", models.TextField(blank=True, default="", null=True)), + ("logo", models.CharField(blank=True, max_length=512, null=True)), + ("name", models.CharField(max_length=512)), + ("priority", models.IntegerField(default=100)), + ("revision", models.IntegerField(default=1)), + ("settings", models.JSONField(blank=True, null=True)), + ("slug", models.CharField(blank=True, max_length=512, null=True)), + ("table_field", models.JSONField(blank=True, null=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ( + "category", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="apps", + to="apps.appcategories", + ), + ), + ], + options={ + "verbose_name": "App Template", + "verbose_name_plural": "App Templates", + }, + ), + ] diff --git a/apps/migrations/0002_initial.py b/apps/migrations/0002_initial.py new file mode 100644 index 000000000..377cb3dd3 --- /dev/null +++ b/apps/migrations/0002_initial.py @@ -0,0 +1,526 @@ +# Generated by Django 5.0.2 on 2024-05-27 07:28 + +import django.core.validators +import django.db.models.deletion +import tagulous.models.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("apps", "0001_initial"), + ("portal", "0001_initial"), + ("projects", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="apps", + name="projects", + field=models.ManyToManyField(blank=True, to="projects.project"), + ), + migrations.CreateModel( + name="NetpolicyInstance", + fields=[ + ( + "baseappinstance_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="apps.baseappinstance", + ), + ), + ], + options={ + "verbose_name": "Network Policy", + "verbose_name_plural": "Network Policies", + "permissions": [("can_access_app", "Can access app service")], + }, + bases=("apps.baseappinstance",), + ), + migrations.CreateModel( + name="VolumeInstance", + fields=[ + ( + "baseappinstance_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="apps.baseappinstance", + ), + ), + ( + "size", + models.IntegerField( + default=1, + help_text="Size in GB", + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(100), + ], + ), + ), + ], + options={ + "verbose_name": "Persistent Volume", + "verbose_name_plural": "Persistent Volumes", + "permissions": [("can_access_app", "Can access app service")], + }, + bases=("apps.baseappinstance",), + ), + migrations.AddField( + model_name="baseappinstance", + name="app", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_related", + to="apps.apps", + ), + ), + migrations.AddField( + model_name="baseappinstance", + name="app_status", + field=models.OneToOneField( + null=True, on_delete=django.db.models.deletion.RESTRICT, related_name="%(class)s", to="apps.appstatus" + ), + ), + migrations.AddField( + model_name="baseappinstance", + name="flavor", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.RESTRICT, + related_name="%(class)s", + to="projects.flavor", + ), + ), + migrations.AddField( + model_name="baseappinstance", + name="owner", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="baseappinstance", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="%(class)s", to="projects.project" + ), + ), + migrations.AddField( + model_name="subdomain", + name="project", + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="projects.project"), + ), + migrations.AddField( + model_name="baseappinstance", + name="subdomain", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, related_name="%(class)s", to="apps.subdomain" + ), + ), + migrations.AlterUniqueTogether( + name="tagulous_customappinstance_tags", + unique_together={("slug",)}, + ), + migrations.AlterUniqueTogether( + name="tagulous_dashinstance_tags", + unique_together={("slug",)}, + ), + migrations.AlterUniqueTogether( + name="tagulous_shinyinstance_tags", + unique_together={("slug",)}, + ), + migrations.AlterUniqueTogether( + name="tagulous_socialmixin_tags", + unique_together={("slug",)}, + ), + migrations.AlterUniqueTogether( + name="tagulous_tissuumapsinstance_tags", + unique_together={("slug",)}, + ), + migrations.AlterUniqueTogether( + name="apps", + unique_together={("slug", "revision")}, + ), + migrations.CreateModel( + name="DashInstance", + fields=[ + ( + "baseappinstance_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="apps.baseappinstance", + ), + ), + ( + "logs_enabled", + models.BooleanField( + default=True, help_text="Indicates whether logs are activated and visible to the user" + ), + ), + ("note_on_linkonly_privacy", models.TextField(blank=True, default="", null=True)), + ("source_code_url", models.URLField(blank=True, null=True)), + ("description", models.TextField(blank=True, default="", null=True)), + ( + "access", + models.CharField( + choices=[ + ("project", "Project"), + ("private", "Private"), + ("public", "Public"), + ("link", "Link"), + ], + default="private", + max_length=20, + ), + ), + ("port", models.IntegerField(default=8000)), + ("image", models.CharField(max_length=255)), + ("collections", models.ManyToManyField(blank=True, related_name="%(class)s", to="portal.collection")), + ( + "tags", + tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + help_text="Enter a comma-separated tag string", + to="apps.tagulous_dashinstance_tags", + ), + ), + ], + options={ + "verbose_name": "Dash App Instance", + "verbose_name_plural": "Dash App Instances", + "permissions": [("can_access_app", "Can access app service")], + }, + bases=("apps.baseappinstance", models.Model), + ), + migrations.CreateModel( + name="ShinyInstance", + fields=[ + ( + "baseappinstance_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="apps.baseappinstance", + ), + ), + ( + "logs_enabled", + models.BooleanField( + default=True, help_text="Indicates whether logs are activated and visible to the user" + ), + ), + ("note_on_linkonly_privacy", models.TextField(blank=True, default="", null=True)), + ("source_code_url", models.URLField(blank=True, null=True)), + ("description", models.TextField(blank=True, default="", null=True)), + ( + "access", + models.CharField( + choices=[ + ("project", "Project"), + ("private", "Private"), + ("public", "Public"), + ("link", "Link"), + ], + default="private", + max_length=20, + ), + ), + ("port", models.IntegerField(default=3838)), + ("image", models.CharField(max_length=255)), + ("proxy", models.BooleanField(default=True)), + ("container_waittime", models.IntegerField(default=20000)), + ("heartbeat_timeout", models.IntegerField(default=60000)), + ("heartbeat_rate", models.IntegerField(default=10000)), + ("collections", models.ManyToManyField(blank=True, related_name="%(class)s", to="portal.collection")), + ( + "tags", + tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + help_text="Enter a comma-separated tag string", + to="apps.tagulous_shinyinstance_tags", + ), + ), + ], + options={ + "verbose_name": "Shiny App Instance", + "verbose_name_plural": "Shiny App Instances", + "permissions": [("can_access_app", "Can access app service")], + }, + bases=("apps.baseappinstance", models.Model), + ), + migrations.CreateModel( + name="TissuumapsInstance", + fields=[ + ( + "baseappinstance_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="apps.baseappinstance", + ), + ), + ( + "logs_enabled", + models.BooleanField( + default=True, help_text="Indicates whether logs are activated and visible to the user" + ), + ), + ("note_on_linkonly_privacy", models.TextField(blank=True, default="", null=True)), + ("source_code_url", models.URLField(blank=True, null=True)), + ("description", models.TextField(blank=True, default="", null=True)), + ( + "access", + models.CharField( + choices=[ + ("project", "Project"), + ("private", "Private"), + ("public", "Public"), + ("link", "Link"), + ], + default="private", + max_length=20, + ), + ), + ("collections", models.ManyToManyField(blank=True, related_name="%(class)s", to="portal.collection")), + ( + "tags", + tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + help_text="Enter a comma-separated tag string", + to="apps.tagulous_tissuumapsinstance_tags", + ), + ), + ( + "volume", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s", + to="apps.volumeinstance", + ), + ), + ], + options={ + "verbose_name": "TissUUmaps Instance", + "verbose_name_plural": "TissUUmaps Instances", + "permissions": [("can_access_app", "Can access app service")], + }, + bases=("apps.baseappinstance", models.Model), + ), + migrations.CreateModel( + name="RStudioInstance", + fields=[ + ( + "baseappinstance_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="apps.baseappinstance", + ), + ), + ( + "access", + models.CharField( + choices=[("project", "Project"), ("private", "Private")], default="private", max_length=20 + ), + ), + ("volume", models.ManyToManyField(blank=True, to="apps.volumeinstance")), + ], + options={ + "verbose_name": "RStudio Instance", + "verbose_name_plural": "RStudio Instances", + "permissions": [("can_access_app", "Can access app service")], + }, + bases=("apps.baseappinstance",), + ), + migrations.CreateModel( + name="JupyterInstance", + fields=[ + ( + "baseappinstance_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="apps.baseappinstance", + ), + ), + ( + "access", + models.CharField( + choices=[("project", "Project"), ("private", "Private")], default="private", max_length=20 + ), + ), + ("volume", models.ManyToManyField(blank=True, to="apps.volumeinstance")), + ], + options={ + "verbose_name": "JupyterLab Instance", + "verbose_name_plural": "JupyterLab Instances", + "permissions": [("can_access_app", "Can access app service")], + }, + bases=("apps.baseappinstance",), + ), + migrations.CreateModel( + name="FilemanagerInstance", + fields=[ + ( + "baseappinstance_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="apps.baseappinstance", + ), + ), + ( + "access", + models.CharField( + choices=[("project", "Project"), ("private", "Private")], default="project", max_length=20 + ), + ), + ("persistent", models.BooleanField(default=False)), + ("volume", models.ManyToManyField(blank=True, to="apps.volumeinstance")), + ], + options={ + "verbose_name": "Filemanager", + "verbose_name_plural": "Filemanagers", + "permissions": [("can_access_app", "Can access app service")], + }, + bases=("apps.baseappinstance",), + ), + migrations.CreateModel( + name="CustomAppInstance", + fields=[ + ( + "baseappinstance_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="apps.baseappinstance", + ), + ), + ( + "logs_enabled", + models.BooleanField( + default=True, help_text="Indicates whether logs are activated and visible to the user" + ), + ), + ("note_on_linkonly_privacy", models.TextField(blank=True, default="", null=True)), + ("source_code_url", models.URLField(blank=True, null=True)), + ("description", models.TextField(blank=True, default="", null=True)), + ( + "access", + models.CharField( + choices=[ + ("project", "Project"), + ("private", "Private"), + ("public", "Public"), + ("link", "Link"), + ], + default="private", + max_length=20, + ), + ), + ("port", models.IntegerField(default=8000)), + ("image", models.CharField(max_length=255)), + ("path", models.CharField(default="/", max_length=255)), + ("user_id", models.IntegerField(default=1000)), + ("collections", models.ManyToManyField(blank=True, related_name="%(class)s", to="portal.collection")), + ( + "tags", + tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + help_text="Enter a comma-separated tag string", + to="apps.tagulous_customappinstance_tags", + ), + ), + ( + "volume", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s", + to="apps.volumeinstance", + ), + ), + ], + options={ + "verbose_name": "Custom App Instance", + "verbose_name_plural": "Custom App Instances", + "permissions": [("can_access_app", "Can access app service")], + }, + bases=("apps.baseappinstance", models.Model), + ), + migrations.CreateModel( + name="VSCodeInstance", + fields=[ + ( + "baseappinstance_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="apps.baseappinstance", + ), + ), + ( + "access", + models.CharField( + choices=[("project", "Project"), ("private", "Private")], default="private", max_length=20 + ), + ), + ("volume", models.ManyToManyField(blank=True, to="apps.volumeinstance")), + ], + options={ + "verbose_name": "VS Code Instance", + "verbose_name_plural": "VS Code Instances", + "permissions": [("can_access_app", "Can access app service")], + }, + bases=("apps.baseappinstance",), + ), + ] diff --git a/apps/migrations/0003_alter_customappinstance_tags_alter_dashinstance_tags_and_more.py b/apps/migrations/0003_alter_customappinstance_tags_alter_dashinstance_tags_and_more.py new file mode 100644 index 000000000..2692a70e9 --- /dev/null +++ b/apps/migrations/0003_alter_customappinstance_tags_alter_dashinstance_tags_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 5.0.2 on 2024-07-30 13:44 + +import tagulous.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("apps", "0002_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="customappinstance", + name="tags", + field=tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + help_text="Add keywords to help categorize your app", + to="apps.tagulous_customappinstance_tags", + ), + ), + migrations.AlterField( + model_name="dashinstance", + name="tags", + field=tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + help_text="Add keywords to help categorize your app", + to="apps.tagulous_dashinstance_tags", + ), + ), + migrations.AlterField( + model_name="shinyinstance", + name="tags", + field=tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + help_text="Add keywords to help categorize your app", + to="apps.tagulous_shinyinstance_tags", + ), + ), + migrations.AlterField( + model_name="tissuumapsinstance", + name="tags", + field=tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + help_text="Add keywords to help categorize your app", + to="apps.tagulous_tissuumapsinstance_tags", + ), + ), + ] diff --git a/apps/migrations/0004_alter_customappinstance_access_and_more.py b/apps/migrations/0004_alter_customappinstance_access_and_more.py new file mode 100644 index 000000000..52eb097f8 --- /dev/null +++ b/apps/migrations/0004_alter_customappinstance_access_and_more.py @@ -0,0 +1,69 @@ +# Generated by Django 5.0.2 on 2024-08-01 15:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("apps", "0003_alter_customappinstance_tags_alter_dashinstance_tags_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="customappinstance", + name="access", + field=models.CharField( + choices=[("project", "Project"), ("private", "Private"), ("public", "Public"), ("link", "Link")], + default="project", + max_length=20, + ), + ), + migrations.AlterField( + model_name="dashinstance", + name="access", + field=models.CharField( + choices=[("project", "Project"), ("private", "Private"), ("public", "Public"), ("link", "Link")], + default="project", + max_length=20, + ), + ), + migrations.AlterField( + model_name="jupyterinstance", + name="access", + field=models.CharField( + choices=[("project", "Project"), ("private", "Private")], default="project", max_length=20 + ), + ), + migrations.AlterField( + model_name="rstudioinstance", + name="access", + field=models.CharField( + choices=[("project", "Project"), ("private", "Private")], default="project", max_length=20 + ), + ), + migrations.AlterField( + model_name="shinyinstance", + name="access", + field=models.CharField( + choices=[("project", "Project"), ("private", "Private"), ("public", "Public"), ("link", "Link")], + default="project", + max_length=20, + ), + ), + migrations.AlterField( + model_name="tissuumapsinstance", + name="access", + field=models.CharField( + choices=[("project", "Project"), ("private", "Private"), ("public", "Public"), ("link", "Link")], + default="project", + max_length=20, + ), + ), + migrations.AlterField( + model_name="vscodeinstance", + name="access", + field=models.CharField( + choices=[("project", "Project"), ("private", "Private")], default="project", max_length=20 + ), + ), + ] diff --git a/apps/migrations/0005_shinyinstance_allow_container_reuse_and_more.py b/apps/migrations/0005_shinyinstance_allow_container_reuse_and_more.py new file mode 100644 index 000000000..4d82bf92e --- /dev/null +++ b/apps/migrations/0005_shinyinstance_allow_container_reuse_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.2 on 2024-08-04 13:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("apps", "0004_alter_customappinstance_access_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="shinyinstance", + name="allow_container_reuse", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="shinyinstance", + name="minimum_seats_available", + field=models.IntegerField(default=2), + ), + migrations.AddField( + model_name="shinyinstance", + name="seats_per_container", + field=models.IntegerField(default=3), + ), + ] diff --git a/apps/migrations/0006_subdomain_is_created_by_user_and_more.py b/apps/migrations/0006_subdomain_is_created_by_user_and_more.py new file mode 100644 index 000000000..0ba2ce23b --- /dev/null +++ b/apps/migrations/0006_subdomain_is_created_by_user_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.0.2 on 2024-08-04 17:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("apps", "0005_shinyinstance_allow_container_reuse_and_more"), + ("projects", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="subdomain", + name="is_created_by_user", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="baseappinstance", + name="subdomain", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s", + to="apps.subdomain", + ), + ), + migrations.AlterField( + model_name="subdomain", + name="project", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="projects.project" + ), + ), + ] diff --git a/apps/migrations/0007_shinyinstance_path_shinyinstance_volume.py b/apps/migrations/0007_shinyinstance_path_shinyinstance_volume.py new file mode 100644 index 000000000..e6b85324f --- /dev/null +++ b/apps/migrations/0007_shinyinstance_path_shinyinstance_volume.py @@ -0,0 +1,29 @@ +# Generated by Django 5.0.2 on 2024-08-09 08:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("apps", "0006_subdomain_is_created_by_user_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="shinyinstance", + name="path", + field=models.CharField(default="/", max_length=255), + ), + migrations.AddField( + model_name="shinyinstance", + name="volume", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s", + to="apps.volumeinstance", + ), + ), + ] diff --git a/apps/migrations/0008_alter_shinyinstance_allow_container_reuse_and_more.py b/apps/migrations/0008_alter_shinyinstance_allow_container_reuse_and_more.py new file mode 100644 index 000000000..1f8388f5b --- /dev/null +++ b/apps/migrations/0008_alter_shinyinstance_allow_container_reuse_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.2 on 2024-08-13 06:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("apps", "0007_shinyinstance_path_shinyinstance_volume"), + ] + + operations = [ + migrations.AlterField( + model_name="shinyinstance", + name="allow_container_reuse", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="shinyinstance", + name="minimum_seats_available", + field=models.IntegerField(default=1), + ), + migrations.AlterField( + model_name="shinyinstance", + name="seats_per_container", + field=models.IntegerField(default=1), + ), + ] diff --git a/apps/migrations/0009_alter_customappinstance_volume_and_more.py b/apps/migrations/0009_alter_customappinstance_volume_and_more.py new file mode 100644 index 000000000..e8cc0898d --- /dev/null +++ b/apps/migrations/0009_alter_customappinstance_volume_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.0.2 on 2024-08-13 11:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("apps", "0008_alter_shinyinstance_allow_container_reuse_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="customappinstance", + name="volume", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s", + to="apps.volumeinstance", + ), + ), + migrations.AlterField( + model_name="shinyinstance", + name="volume", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s", + to="apps.volumeinstance", + ), + ), + migrations.AlterField( + model_name="tissuumapsinstance", + name="volume", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s", + to="apps.volumeinstance", + ), + ), + ] diff --git a/apps/models.py b/apps/models.py deleted file mode 100644 index bd16ab76e..000000000 --- a/apps/models.py +++ /dev/null @@ -1,223 +0,0 @@ -from datetime import datetime, timedelta - -from django.conf import settings -from django.contrib.auth import get_user_model -from django.db import models -from django.db.models import Q -from django.db.models.signals import post_save -from django.dispatch import receiver -from guardian.shortcuts import assign_perm, remove_perm -from tagulous.models import TagField - - -class AppCategories(models.Model): - name = models.CharField(max_length=512) - priority = models.IntegerField(default=100) - slug = models.CharField(max_length=512, default="", primary_key=True) - - def __str__(self): - return str(self.name) - - -class Apps(models.Model): - user_can_create = models.BooleanField(default=True) - user_can_edit = models.BooleanField(default=True) - user_can_delete = models.BooleanField(default=True) - access = models.CharField(max_length=20, blank=True, null=True, default="public") - category = models.ForeignKey( - "AppCategories", - related_name="apps", - on_delete=models.CASCADE, - null=True, - ) - chart = models.CharField(max_length=512) - chart_archive = models.FileField(upload_to="apps/", null=True, blank=True) - created_on = models.DateTimeField(auto_now_add=True) - description = models.TextField(blank=True, null=True, default="") - logo = models.CharField(max_length=512, null=True, blank=True) - name = models.CharField(max_length=512) - priority = models.IntegerField(default=100) - projects = models.ManyToManyField("projects.Project", blank=True) - revision = models.IntegerField(default=1) - settings = models.JSONField(blank=True, null=True) - slug = models.CharField(max_length=512, blank=True, null=True) - table_field = models.JSONField(blank=True, null=True) - updated_on = models.DateTimeField(auto_now=True) - - class Meta: - unique_together = ( - "slug", - "revision", - ) - - def __str__(self): - return str(self.name) + "({})".format(self.revision) - - -class AppInstanceManager(models.Manager): - def get_app_instances_of_project_filter(self, user, project, include_deleted=False, deleted_time_delta=None): - q = Q() - - if not include_deleted: - if deleted_time_delta is None: - q &= ~Q(state="Deleted") - else: - time_threshold = datetime.now() - timedelta(minutes=deleted_time_delta) - q &= ~Q(state="Deleted") | Q(deleted_on__gte=time_threshold) - - q &= Q(owner=user) | Q( - access__in=["project", "public", "private", "link"] if user.is_superuser else ["project", "public", "link"] - ) - q &= Q(project=project) - - return q - - def get_app_instances_of_project( - self, - user, - project, - filter_func=None, - order_by=None, - limit=None, - override_default_filter=False, - ): - if order_by is None: - order_by = "-created_on" - - if filter_func is None: - return self.filter(self.get_app_instances_of_project_filter(user=user, project=project)).order_by(order_by)[ - :limit - ] - - if override_default_filter: - return self.filter(filter_func).order_by(order_by)[:limit] - - return ( - self.filter(self.get_app_instances_of_project_filter(user=user, project=project)) - .filter(filter_func) - .order_by(order_by)[:limit] - ) - - def get_available_app_dependencies(self, user, project, app_name): - result = self.filter( - ~Q(state="Deleted"), - Q(owner=user) | Q(access__in=["project", "public"]), - project=project, - app__name=app_name, - ) - - if settings.STUDIO_ACCESSMODE == "ReadWriteOnce" and app_name == "Persistent Volume": - for instance in result: - exists = self.filter( - ~Q(state="Deleted"), - project=project, - app_dependencies=instance, - ).exists() - - if exists: - result = result.exclude(id=instance.id) - - return result - - def user_can_create(self, user, project, app_slug): - apps_per_project = {} if project.apps_per_project is None else project.apps_per_project - - limit = apps_per_project[app_slug] if app_slug in apps_per_project else None - - app = Apps.objects.get(slug=app_slug) - - if not app.user_can_create: - return False - - num_of_app_instances = self.filter( - ~Q(state="Deleted"), - app__slug=app_slug, - project=project, - ).count() - - has_perm = user.has_perm("apps.add_appinstance") - - return limit is None or limit > num_of_app_instances or has_perm - - -class AppInstance(models.Model): - objects = AppInstanceManager() - - access = models.CharField(max_length=20, default="private", null=True, blank=True) - app = models.ForeignKey("Apps", on_delete=models.CASCADE, related_name="appinstance") - app_dependencies = models.ManyToManyField("apps.AppInstance", blank=True) - created_on = models.DateTimeField(auto_now_add=True) - deleted_on = models.DateTimeField(null=True, blank=True) - description = models.TextField(blank=True, null=True, default="") - info = models.JSONField(blank=True, null=True) - model_dependencies = models.ManyToManyField("models.Model", blank=True) - name = models.CharField(max_length=512, default="app_name") - owner = models.ForeignKey( - get_user_model(), - on_delete=models.CASCADE, - related_name="app_owner", - null=True, - ) - parameters = models.JSONField(blank=True, null=True) - project = models.ForeignKey( - "projects.Project", - on_delete=models.CASCADE, - related_name="appinstance", - ) - flavor = models.ForeignKey( - "projects.Flavor", - on_delete=models.RESTRICT, - related_name="appinstance", - null=True, - ) - state = models.CharField(max_length=50, null=True, blank=True) - table_field = models.JSONField(blank=True, null=True) - tags = TagField(blank=True) - updated_on = models.DateTimeField(auto_now=True) - note_on_linkonly_privacy = models.TextField(blank=True, null=True, default="") - collections = models.ManyToManyField("collections_module.Collection", blank=True, related_name="app_instances") - source_code_url = models.URLField(blank=True, null=True) - - class Meta: - permissions = [("can_access_app", "Can access app service")] - - def __str__(self): - return str(self.name) + " ({})-{}-{}-{}".format(self.state, self.owner, self.app.name, self.project) - - -@receiver( - post_save, - sender=AppInstance, - dispatch_uid="app_instance_update_permission", -) -def update_permission(sender, instance, created, **kwargs): - owner = instance.owner - - if instance.access == "private": - if created or not owner.has_perm("can_access_app", instance): - assign_perm("can_access_app", owner, instance) - - else: - if owner.has_perm("can_access_app", instance): - remove_perm("can_access_app", owner, instance) - - -class AppStatus(models.Model): - appinstance = models.ForeignKey("AppInstance", on_delete=models.CASCADE, related_name="status") - info = models.JSONField(blank=True, null=True) - status_type = models.CharField(max_length=15, default="app_name") - time = models.DateTimeField(auto_now_add=True) - - class Meta: - get_latest_by = "time" - - def __str__(self): - return str(self.appinstance.name) + "({})".format(self.time) - - -class ResourceData(models.Model): - appinstance = models.ForeignKey("AppInstance", on_delete=models.CASCADE, related_name="resourcedata") - cpu = models.IntegerField() - gpu = models.IntegerField() - mem = models.IntegerField() - time = models.IntegerField() diff --git a/apps/models/__init__.py b/apps/models/__init__.py new file mode 100644 index 000000000..657371d7c --- /dev/null +++ b/apps/models/__init__.py @@ -0,0 +1,4 @@ +# flake8: noqa: F403 + +from .base import * # isort:skip +from .app_types import * # isort:skip diff --git a/apps/models/app_types/__init__.py b/apps/models/app_types/__init__.py new file mode 100644 index 000000000..7fd820431 --- /dev/null +++ b/apps/models/app_types/__init__.py @@ -0,0 +1,10 @@ +from .custom import CustomAppInstance, CustomAppInstanceManager +from .dash import DashInstance, DashInstanceManager +from .filemanager import FilemanagerInstance, FilemanagerInstanceManager +from .jupyter import JupyterInstance, JupyterInstanceManager +from .netpolicy import NetpolicyInstance, NetpolicyInstanceManager +from .rstudio import RStudioInstance, RStudioInstanceManager +from .shiny import ShinyInstance, ShinyInstanceManager +from .tissuumaps import TissuumapsInstance, TissuumapsInstanceManager +from .volume import VolumeInstance, VolumeInstanceManager +from .vscode import VSCodeInstance, VSCodeInstanceManager diff --git a/apps/models/app_types/custom.py b/apps/models/app_types/custom.py new file mode 100644 index 000000000..c09417974 --- /dev/null +++ b/apps/models/app_types/custom.py @@ -0,0 +1,50 @@ +from django.db import models + +from apps.models import ( + AppInstanceManager, + BaseAppInstance, + LogsEnabledMixin, + SocialMixin, +) + + +class CustomAppInstanceManager(AppInstanceManager): + model_type = "customappinstance" + + +class CustomAppInstance(BaseAppInstance, SocialMixin, LogsEnabledMixin): + objects = CustomAppInstanceManager() + ACCESS_TYPES = ( + ("project", "Project"), + ( + "private", + "Private", + ), + ("public", "Public"), + ("link", "Link"), + ) + + volume = models.ForeignKey( + "VolumeInstance", blank=True, null=True, related_name="%(class)s", on_delete=models.SET_NULL + ) + access = models.CharField(max_length=20, default="project", choices=ACCESS_TYPES) + port = models.IntegerField(default=8000) + image = models.CharField(max_length=255) + path = models.CharField(max_length=255, default="/") + user_id = models.IntegerField(default=1000) + + def get_k8s_values(self): + k8s_values = super().get_k8s_values() + + k8s_values["permission"] = str(self.access) + k8s_values["appconfig"] = dict(port=self.port, image=self.image, path=self.path, userid=self.user_id) + volumeK8s_dict = {"volumeK8s": {}} + if self.volume: + volumeK8s_dict["volumeK8s"][self.volume.name] = dict(release=self.volume.subdomain.subdomain) + k8s_values["apps"] = volumeK8s_dict + return k8s_values + + class Meta: + verbose_name = "Custom App Instance" + verbose_name_plural = "Custom App Instances" + permissions = [("can_access_app", "Can access app service")] diff --git a/apps/models/app_types/dash.py b/apps/models/app_types/dash.py new file mode 100644 index 000000000..43d803406 --- /dev/null +++ b/apps/models/app_types/dash.py @@ -0,0 +1,40 @@ +from django.db import models + +from apps.models import ( + AppInstanceManager, + BaseAppInstance, + LogsEnabledMixin, + SocialMixin, +) + + +class DashInstanceManager(AppInstanceManager): + model_type = "dashinstance" + + +class DashInstance(BaseAppInstance, SocialMixin, LogsEnabledMixin): + objects = DashInstanceManager() + ACCESS_TYPES = ( + ("project", "Project"), + ( + "private", + "Private", + ), + ("public", "Public"), + ("link", "Link"), + ) + access = models.CharField(max_length=20, default="project", choices=ACCESS_TYPES) + port = models.IntegerField(default=8000) + image = models.CharField(max_length=255) + + def get_k8s_values(self): + k8s_values = super().get_k8s_values() + + k8s_values["permission"] = str(self.access) + k8s_values["appconfig"] = dict(port=self.port, image=self.image) + return k8s_values + + class Meta: + verbose_name = "Dash App Instance" + verbose_name_plural = "Dash App Instances" + permissions = [("can_access_app", "Can access app service")] diff --git a/apps/models/app_types/filemanager.py b/apps/models/app_types/filemanager.py new file mode 100644 index 000000000..2bd1a32a8 --- /dev/null +++ b/apps/models/app_types/filemanager.py @@ -0,0 +1,40 @@ +from django.db import models + +from apps.models import AppInstanceManager, BaseAppInstance + + +class FilemanagerInstanceManager(AppInstanceManager): + model_type = "filemanagerinstance" + + +class FilemanagerInstance(BaseAppInstance): + objects = FilemanagerInstanceManager() + ACCESS_TYPES = ( + ("project", "Project"), + ("private", "Private"), + ) + volume = models.ManyToManyField("VolumeInstance", blank=True) + access = models.CharField(max_length=20, default="project", choices=ACCESS_TYPES) + persistent = models.BooleanField(default=False) + + def get_k8s_values(self): + k8s_values = super().get_k8s_values() + + k8s_values["permission"] = str(self.access) + + # Not the nicest perhaps, but it works since the charts assume that the volumes are on this form + # {apps: + # {volumeK8s: + # {project-vol: + # {release: r1582t9h9 + + volumeK8s_dict = {"volumeK8s": {}} + for object in self.volume.all(): + volumeK8s_dict["volumeK8s"][object.name] = dict(release=object.subdomain.subdomain) + k8s_values["apps"] = volumeK8s_dict + return k8s_values + + class Meta: + verbose_name = "Filemanager" + verbose_name_plural = "Filemanagers" + permissions = [("can_access_app", "Can access app service")] diff --git a/apps/models/app_types/jupyter.py b/apps/models/app_types/jupyter.py new file mode 100644 index 000000000..cfc12b1bf --- /dev/null +++ b/apps/models/app_types/jupyter.py @@ -0,0 +1,41 @@ +from django.db import models + +from apps.models import AppInstanceManager, BaseAppInstance + + +class JupyterInstanceManager(AppInstanceManager): + model_type = "jupyterinstance" + + +class JupyterInstance(BaseAppInstance): + objects = JupyterInstanceManager() + ACCESS_TYPES = ( + ("project", "Project"), + ("private", "Private"), + ) + volume = models.ManyToManyField("VolumeInstance", blank=True) + access = models.CharField(max_length=20, default="project", choices=ACCESS_TYPES) + + def get_k8s_values(self): + k8s_values = super().get_k8s_values() + + k8s_values["permission"] = str(self.access) + # Not the nicest perhaps, but it works since the charts assume that the volumes are on this form + # {apps: + # {volumeK8s: + # {project-vol: + # {release: r1582t9h9 + + volumeK8s_dict = {"volumeK8s": {}} + for object in self.volume.all(): + volumeK8s_dict["volumeK8s"][object.name] = dict(release=object.subdomain.subdomain) + k8s_values["apps"] = volumeK8s_dict + # This is just do fix a legacy. + # TODO: Change the jupyter chart to fetch port from appconfig as other apps + k8s_values["service"]["targetport"] = 8888 + return k8s_values + + class Meta: + verbose_name = "JupyterLab Instance" + verbose_name_plural = "JupyterLab Instances" + permissions = [("can_access_app", "Can access app service")] diff --git a/apps/models/app_types/netpolicy.py b/apps/models/app_types/netpolicy.py new file mode 100644 index 000000000..ab158a9c4 --- /dev/null +++ b/apps/models/app_types/netpolicy.py @@ -0,0 +1,36 @@ +from datetime import datetime, timedelta + +from django.db.models import Q + +from apps.models import AppInstanceManager, BaseAppInstance + + +class NetpolicyInstanceManager(AppInstanceManager): + model_type = "netpolicyinstance" + + def get_app_instances_of_project_filter(self, user, project, include_deleted=False, deleted_time_delta=None): + q = Q() + + if not include_deleted: + if deleted_time_delta is None: + q &= ~Q(app_status__status="Deleted") + else: + time_threshold = datetime.now() - timedelta(minutes=deleted_time_delta) + q &= ~Q(app_status__status="Deleted") | Q(deleted_on__gte=time_threshold) + + q &= Q(owner=user) + q &= Q(project=project) + + return q + + +class NetpolicyInstance(BaseAppInstance): + objects = NetpolicyInstanceManager() + + def __str__(self): + return str(self.name) + + class Meta: + verbose_name = "Network Policy" + verbose_name_plural = "Network Policies" + permissions = [("can_access_app", "Can access app service")] diff --git a/apps/models/app_types/rstudio.py b/apps/models/app_types/rstudio.py new file mode 100644 index 000000000..64a60bd0f --- /dev/null +++ b/apps/models/app_types/rstudio.py @@ -0,0 +1,42 @@ +from django.db import models + +from apps.models import AppInstanceManager, BaseAppInstance + + +class RStudioInstanceManager(AppInstanceManager): + model_type = "rstudioinstance" + + +class RStudioInstance(BaseAppInstance): + objects = RStudioInstanceManager() + ACCESS_TYPES = ( + ("project", "Project"), + ("private", "Private"), + ) + volume = models.ManyToManyField("VolumeInstance", blank=True) + access = models.CharField(max_length=20, default="project", choices=ACCESS_TYPES) + + def get_k8s_values(self): + k8s_values = super().get_k8s_values() + + k8s_values["permission"] = str(self.access) + # Not the nicest perhaps, but it works since the charts assume that the volumes are on this form + # {apps: + # {volumeK8s: + # {project-vol: + # {release: r1582t9h9 + + volumeK8s_dict = {"volumeK8s": {}} + for object in self.volume.all(): + volumeK8s_dict["volumeK8s"][object.name] = dict(release=object.subdomain.subdomain) + k8s_values["apps"] = volumeK8s_dict + + # This is just do fix a legacy. + # TODO: Change the rstdio chart to fetch port from appconfig as other apps + k8s_values["service"]["targetport"] = 8787 + return k8s_values + + class Meta: + verbose_name = "RStudio Instance" + verbose_name_plural = "RStudio Instances" + permissions = [("can_access_app", "Can access app service")] diff --git a/apps/models/app_types/shiny.py b/apps/models/app_types/shiny.py new file mode 100644 index 000000000..db7a48385 --- /dev/null +++ b/apps/models/app_types/shiny.py @@ -0,0 +1,69 @@ +from django.db import models + +from apps.models import ( + AppInstanceManager, + BaseAppInstance, + LogsEnabledMixin, + SocialMixin, +) + + +class ShinyInstanceManager(AppInstanceManager): + model_type = "shinyinstance" + + +class ShinyInstance(BaseAppInstance, SocialMixin, LogsEnabledMixin): + objects = ShinyInstanceManager() + ACCESS_TYPES = ( + ("project", "Project"), + ( + "private", + "Private", + ), + ("public", "Public"), + ("link", "Link"), + ) + + volume = models.ForeignKey( + "VolumeInstance", blank=True, null=True, related_name="%(class)s", on_delete=models.SET_NULL + ) + access = models.CharField(max_length=20, default="project", choices=ACCESS_TYPES) + port = models.IntegerField(default=3838) + image = models.CharField(max_length=255) + path = models.CharField(max_length=255, default="/") + proxy = models.BooleanField(default=True) + container_waittime = models.IntegerField(default=20000) + heartbeat_timeout = models.IntegerField(default=60000) + heartbeat_rate = models.IntegerField(default=10000) + + # The following three settings control the pre-init and seats behaviour (see documentation) + # These settings override the Helm chart default values + minimum_seats_available = models.IntegerField(default=1) + seats_per_container = models.IntegerField(default=1) + allow_container_reuse = models.BooleanField(default=False) + + def get_k8s_values(self): + k8s_values = super().get_k8s_values() + + k8s_values["permission"] = str(self.access) + k8s_values["appconfig"] = dict( + port=self.port, + image=self.image, + path=self.path, + proxyheartbeatrate=self.heartbeat_rate, + proxyheartbeattimeout=self.heartbeat_timeout, + proxycontainerwaittime=self.container_waittime, + minimumSeatsAvailable=self.minimum_seats_available, + seatsPerContainer=self.seats_per_container, + allowContainerReuse=self.allow_container_reuse, + ) + volumeK8s_dict = {"volumeK8s": {}} + if self.volume: + volumeK8s_dict["volumeK8s"][self.volume.name] = dict(release=self.volume.subdomain.subdomain) + k8s_values["apps"] = volumeK8s_dict + return k8s_values + + class Meta: + verbose_name = "Shiny App Instance" + verbose_name_plural = "Shiny App Instances" + permissions = [("can_access_app", "Can access app service")] diff --git a/apps/models/app_types/tissuumaps.py b/apps/models/app_types/tissuumaps.py new file mode 100644 index 000000000..1f9fb57e5 --- /dev/null +++ b/apps/models/app_types/tissuumaps.py @@ -0,0 +1,50 @@ +from django.db import models + +from apps.models import ( + AppInstanceManager, + BaseAppInstance, + LogsEnabledMixin, + SocialMixin, +) + + +class TissuumapsInstanceManager(AppInstanceManager): + model_type = "tissuumapsinstance" + + +class TissuumapsInstance(BaseAppInstance, SocialMixin, LogsEnabledMixin): + objects = TissuumapsInstanceManager() + ACCESS_TYPES = ( + ("project", "Project"), + ( + "private", + "Private", + ), + ("public", "Public"), + ("link", "Link"), + ) + volume = models.ForeignKey( + "VolumeInstance", blank=True, null=True, related_name="%(class)s", on_delete=models.SET_NULL + ) + access = models.CharField(max_length=20, default="project", choices=ACCESS_TYPES) + + def get_k8s_values(self): + k8s_values = super().get_k8s_values() + + k8s_values["permission"] = str(self.access) + # Not the nicest perhaps, but it works since the charts assume that the volumes are on this form + # {apps: + # {volumeK8s: + # {project-vol: + # {release: r1582t9h9 + + volumeK8s_dict = {"volumeK8s": {}} + if self.volume: + volumeK8s_dict["volumeK8s"][self.volume.name] = dict(release=self.volume.subdomain.subdomain) + k8s_values["apps"] = volumeK8s_dict + return k8s_values + + class Meta: + verbose_name = "TissUUmaps Instance" + verbose_name_plural = "TissUUmaps Instances" + permissions = [("can_access_app", "Can access app service")] diff --git a/apps/models/app_types/volume.py b/apps/models/app_types/volume.py new file mode 100644 index 000000000..d9c0d4b55 --- /dev/null +++ b/apps/models/app_types/volume.py @@ -0,0 +1,31 @@ +from datetime import datetime, timedelta + +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.db.models import Q + +from apps.models import AppInstanceManager, BaseAppInstance + + +class VolumeInstanceManager(AppInstanceManager): + model_type = "volumeinstance" + + +class VolumeInstance(BaseAppInstance): + objects = VolumeInstanceManager() + size = models.IntegerField( + default=1, help_text="Size in GB", validators=[MinValueValidator(1), MaxValueValidator(100)] + ) + + def __str__(self): + return f"{str(self.name)} ({self.project.name})" + + def get_k8s_values(self): + k8s_values = super().get_k8s_values() + k8s_values["volume"] = dict(size=f"{str(self.size)}Gi") + return k8s_values + + class Meta: + verbose_name = "Persistent Volume" + verbose_name_plural = "Persistent Volumes" + permissions = [("can_access_app", "Can access app service")] diff --git a/apps/models/app_types/vscode.py b/apps/models/app_types/vscode.py new file mode 100644 index 000000000..60e9a10f1 --- /dev/null +++ b/apps/models/app_types/vscode.py @@ -0,0 +1,38 @@ +from django.db import models + +from apps.models import AppInstanceManager, BaseAppInstance + + +class VSCodeInstanceManager(AppInstanceManager): + model_type = "vscodeinstance" + + +class VSCodeInstance(BaseAppInstance): + objects = VSCodeInstanceManager() + ACCESS_TYPES = ( + ("project", "Project"), + ("private", "Private"), + ) + volume = models.ManyToManyField("VolumeInstance", blank=True) + access = models.CharField(max_length=20, default="project", choices=ACCESS_TYPES) + + def get_k8s_values(self): + k8s_values = super().get_k8s_values() + + k8s_values["permission"] = str(self.access) + # Not the nicest perhaps, but it works since the charts assume that the volumes are on this form + # {apps: + # {volumeK8s: + # {project-vol: + # {release: r1582t9h9 + + volumeK8s_dict = {"volumeK8s": {}} + for object in self.volume.all(): + volumeK8s_dict["volumeK8s"][object.name] = dict(release=object.subdomain.subdomain) + k8s_values["apps"] = volumeK8s_dict + return k8s_values + + class Meta: + verbose_name = "VS Code Instance" + verbose_name_plural = "VS Code Instances" + permissions = [("can_access_app", "Can access app service")] diff --git a/apps/models/base/__init__.py b/apps/models/base/__init__.py new file mode 100644 index 000000000..4d8e20460 --- /dev/null +++ b/apps/models/base/__init__.py @@ -0,0 +1,7 @@ +from .app_categories import AppCategories +from .app_status import AppStatus +from .app_template import Apps +from .base import AppInstanceManager, BaseAppInstance +from .logs_enabled_mixin import LogsEnabledMixin +from .social_mixin import SocialMixin +from .subdomain import Subdomain diff --git a/apps/models/base/app_categories.py b/apps/models/base/app_categories.py new file mode 100644 index 000000000..25920863e --- /dev/null +++ b/apps/models/base/app_categories.py @@ -0,0 +1,14 @@ +from django.db import models + + +class AppCategories(models.Model): + name = models.CharField(max_length=512) + priority = models.IntegerField(default=100) + slug = models.CharField(max_length=512, default="", primary_key=True) + + def __str__(self): + return str(self.name) + + class Meta: + verbose_name = "App Category" + verbose_name_plural = "App Categories" diff --git a/apps/models/base/app_status.py b/apps/models/base/app_status.py new file mode 100644 index 000000000..13356bc2d --- /dev/null +++ b/apps/models/base/app_status.py @@ -0,0 +1,15 @@ +from django.db import models + + +class AppStatus(models.Model): + info = models.JSONField(blank=True, null=True) + status = models.CharField(max_length=15, default="Creating") + time = models.DateTimeField(auto_now_add=True) + + class Meta: + get_latest_by = "time" + verbose_name = "App Status" + verbose_name_plural = "App Statuses" + + def __str__(self): + return f"{str(self.status)} ({str(self.time)})" diff --git a/apps/models/base/app_template.py b/apps/models/base/app_template.py new file mode 100644 index 000000000..fcde4ab87 --- /dev/null +++ b/apps/models/base/app_template.py @@ -0,0 +1,43 @@ +from django.db import models + + +class Apps(models.Model): + """Essentially app template""" + + user_can_create = models.BooleanField(default=True) + user_can_edit = models.BooleanField(default=True) + user_can_delete = models.BooleanField(default=True) + access = models.CharField(max_length=20, blank=True, null=True, default="public") + category = models.ForeignKey( + "AppCategories", + related_name="apps", + on_delete=models.CASCADE, + null=True, + ) + chart = models.CharField(max_length=512) + chart_archive = models.FileField(upload_to="apps/", null=True, blank=True) + created_on = models.DateTimeField(auto_now_add=True) + description = models.TextField(blank=True, null=True, default="") + logo = models.CharField(max_length=512, null=True, blank=True) + name = models.CharField(max_length=512) + priority = models.IntegerField(default=100) + projects = models.ManyToManyField("projects.Project", blank=True) + revision = models.IntegerField(default=1) + settings = models.JSONField(blank=True, null=True) + slug = models.CharField(max_length=512, blank=True, null=True) + table_field = models.JSONField(blank=True, null=True) + updated_on = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ( + "slug", + "revision", + ) + verbose_name = "App Template" + verbose_name_plural = "App Templates" + + def __str__(self): + return str(self.name) + "({})".format(self.revision) + + def to_dict(self): + pass diff --git a/apps/models/base/base.py b/apps/models/base/base.py new file mode 100644 index 000000000..01bb00946 --- /dev/null +++ b/apps/models/base/base.py @@ -0,0 +1,159 @@ +import json +from datetime import datetime, timedelta + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core import serializers +from django.db import models +from django.db.models import Q + +from apps.models.base.app_status import AppStatus +from apps.models.base.app_template import Apps +from apps.models.base.subdomain import Subdomain +from projects.models import Flavor, Project + + +class AppInstanceManager(models.Manager): + model_type = "appinstance" + + def get_app_instances_of_project_filter(self, user, project, include_deleted=False, deleted_time_delta=None): + q = Q() + + if not include_deleted: + if deleted_time_delta is None: + q &= ~Q(app_status__status="Deleted") + else: + time_threshold = datetime.now() - timedelta(minutes=deleted_time_delta) + q &= ~Q(app_status__status="Deleted") | Q(deleted_on__gte=time_threshold) + + if hasattr(self.model, "access"): + q &= Q(owner=user) | Q( + access__in=["project", "public", "private", "link"] + if user.is_superuser + else ["project", "public", "link"] + ) + else: + q &= Q(owner=user) + + q &= Q(project=project) + + return q + + def get_app_instances_of_project( + self, + user, + project, + filter_func=None, + order_by=None, + limit=None, + override_default_filter=False, + ): + if order_by is None: + order_by = "-created_on" + + if filter_func is None: + return self.filter(self.get_app_instances_of_project_filter(user=user, project=project)).order_by(order_by)[ + :limit + ] + + if override_default_filter: + return self.filter(filter_func).order_by(order_by)[:limit] + + return ( + self.filter(self.get_app_instances_of_project_filter(user=user, project=project)) + .filter(filter_func) + .order_by(order_by)[:limit] + ) + + def user_can_create(self, user, project, app_slug): + apps_per_project = {} if project.apps_per_project is None else project.apps_per_project + + limit = apps_per_project[app_slug] if app_slug in apps_per_project else None + app = Apps.objects.get(slug=app_slug) + + if not app.user_can_create: + return False + + num_of_app_instances = self.filter( + ~Q(app_status__status="Deleted"), + app__slug=app_slug, + project=project, + ).count() + + has_perm = user.has_perm(f"apps.add_{self.model_type}") + return limit is None or limit > num_of_app_instances or has_perm + + def user_can_edit(self, user, project, app_slug): + app = Apps.objects.get(slug=app_slug) + return app.user_can_edit or user.has_perm(f"apps.change_{self.model_type}") + + +class BaseAppInstance(models.Model): + objects = AppInstanceManager() + + app = models.ForeignKey(Apps, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s_related") + chart = models.CharField( + max_length=512, + ) + created_on = models.DateTimeField(auto_now_add=True) + deleted_on = models.DateTimeField(null=True, blank=True) + info = models.JSONField(blank=True, null=True) + # model_dependencies = models.ManyToManyField("models.Model", blank=True) + name = models.CharField(max_length=512, default="app_name") + owner = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="%(class)s", + null=True, + ) + k8s_values = models.JSONField(blank=True, null=True) + project = models.ForeignKey( + Project, + on_delete=models.CASCADE, + related_name="%(class)s", + ) + flavor = models.ForeignKey(Flavor, on_delete=models.RESTRICT, related_name="%(class)s", null=True, blank=True) + subdomain = models.OneToOneField( + Subdomain, on_delete=models.SET_NULL, related_name="%(class)s", null=True, blank=True + ) + app_status = models.OneToOneField(AppStatus, on_delete=models.RESTRICT, related_name="%(class)s", null=True) + + url = models.URLField(blank=True, null=True) + updated_on = models.DateTimeField(auto_now=True) + + class Meta: + permissions = [("can_access_app", "Can access app service")] + verbose_name = "# BASE APP INSTANCE" + verbose_name_plural = "# BASE APP INSTANCES" + + def __str__(self): + return f"{self.name}-{self.owner}-{self.app.name}-{self.project}" + + def get_k8s_values(self): + k8s_values = dict( + name=self.name, + appname=self.subdomain.subdomain if self.subdomain else "deleted", + project=dict(name=self.project.name, slug=self.project.slug), + service=dict( + name=(self.subdomain.subdomain if self.subdomain else "deleted") + "-" + self.app.slug, + ), + **self.subdomain.to_dict() if self.subdomain else {}, + **self.flavor.to_dict() if self.flavor else {}, + storageClass=settings.STORAGECLASS, + namespace=settings.NAMESPACE, + release=self.subdomain.subdomain if self.subdomain else "deleted", # This is legacy and should be changed + ) + + # Add global values + k8s_values["global"] = dict( + domain=settings.DOMAIN, + auth_domain=settings.AUTH_DOMAIN, + protocol=settings.AUTH_PROTOCOL, + ) + return k8s_values + + def set_k8s_values(self): + self.k8s_values = self.get_k8s_values() + + def serialize(self): + return json.loads(serializers.serialize("json", [self]))[0] diff --git a/apps/models/base/logs_enabled_mixin.py b/apps/models/base/logs_enabled_mixin.py new file mode 100644 index 000000000..f4d1d9791 --- /dev/null +++ b/apps/models/base/logs_enabled_mixin.py @@ -0,0 +1,10 @@ +from django.db import models + + +class LogsEnabledMixin(models.Model): + logs_enabled = models.BooleanField( + default=True, help_text="Indicates whether logs are activated and visible to the user" + ) + + class Meta: + abstract = True diff --git a/apps/models/base/social_mixin.py b/apps/models/base/social_mixin.py new file mode 100644 index 000000000..36f78bddb --- /dev/null +++ b/apps/models/base/social_mixin.py @@ -0,0 +1,13 @@ +from django.db import models +from tagulous.models import TagField + + +class SocialMixin(models.Model): + tags = TagField(blank=True, help_text="Add keywords to help categorize your app") + note_on_linkonly_privacy = models.TextField(blank=True, null=True, default="") + collections = models.ManyToManyField("portal.Collection", blank=True, related_name="%(class)s") + source_code_url = models.URLField(blank=True, null=True) + description = models.TextField(blank=True, null=True, default="") + + class Meta: + abstract = True diff --git a/apps/models/base/subdomain.py b/apps/models/base/subdomain.py new file mode 100644 index 000000000..55425614f --- /dev/null +++ b/apps/models/base/subdomain.py @@ -0,0 +1,21 @@ +from django.conf import settings +from django.db import models + + +class Subdomain(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + subdomain = models.CharField(max_length=53, unique=True) + project = models.ForeignKey(settings.PROJECTS_MODEL, on_delete=models.CASCADE, null=True, blank=True) + is_created_by_user = models.BooleanField(default=False) + + def __str__(self): + return str(self.subdomain) + " ({})".format(self.project.name) + + def to_dict(self): + return { + "subdomain": self.subdomain, + } + + class Meta: + verbose_name = "Subdomain" + verbose_name_plural = "Subdomains" diff --git a/apps/serialize.py b/apps/serialize.py deleted file mode 100644 index 37f07e25a..000000000 --- a/apps/serialize.py +++ /dev/null @@ -1,369 +0,0 @@ -import json - -import flatten_json -from django.conf import settings -from django.contrib.auth import get_user_model -from django.db.models import Q -from django.template import engines -from django.utils.text import slugify -from rest_framework.authtoken.models import Token - -from models.models import Model -from projects.models import S3, Environment, Flavor -from studio.utils import get_logger - -from .models import AppInstance, Apps - -logger = get_logger(__name__) - -User = get_user_model() - -KEYWORDS = [ - "appobj", - "model", - "flavor", - "S3", - "environment", - "volumes", - "apps", - "logs", - "permissions", - "default_values", - "export-cli", - "csrfmiddlewaretoken", - "env_variables", - "publishable", -] - - -def serialize_model(form_selection): - logger.info("SERIALIZING MODEL") - model_json = dict() - obj = [] - if "model" in form_selection: - model_id = form_selection.get("model", None) - if isinstance(model_id, str): - model_id = int(model_id) - obj = Model.objects.filter(pk=model_id) - logger.info("Fetching selected model:") - - object_type = obj[0].object_type.all() - if len(object_type) == 1: - logger.info("OK") - else: - logger.info("Currently only supports one object type per model. Will assume first in list.") - model_json = { - "model": { - "name": obj[0].name, - "version": obj[0].version, - "release_type": obj[0].release_type, - "description": obj[0].description, - "url": "http://{}".format(obj[0].s3.host), - "service": obj[0].s3.app.parameters["service"]["name"], - "port": obj[0].s3.app.parameters["service"]["port"], - "targetport": obj[0].s3.app.parameters["service"]["targetport"], - "access_key": obj[0].s3.access_key, - "secret_key": obj[0].s3.secret_key, - "bucket": obj[0].bucket, - "obj": obj[0].uid, - "path": obj[0].path, - "type": object_type[0].slug, - } - } - - return model_json, obj - - -def serialize_S3(form_selection, project): - logger.info("SERIALIZING S3") - s3_json = dict() - if "S3" in form_selection: - s3_id = form_selection.get("S3", None) - try: - obj = S3.objects.filter(pk=s3_id) - except: # noqa E722 TODO: Add exception - obj = S3.objects.filter(name=s3_id, project=project) - s3_json = { - "s3": { - "pk": obj[0].pk, - "name": obj[0].name, - "host": obj[0].host, - "service": obj[0].app.parameters["service"]["name"], - "port": obj[0].app.parameters["service"]["port"], - "targetport": obj[0].app.parameters["service"]["targetport"], - "access_key": obj[0].access_key, - "secret_key": obj[0].secret_key, - "region": obj[0].region, - } - } - return s3_json - - -def serialize_flavor(form_selection, project): - logger.info("SERIALIZING FLAVOR") - flavor_json = dict() - if "flavor" in form_selection: - flavor_id = form_selection.get("flavor", None) - flavor = Flavor.objects.get(pk=flavor_id, project=project) - flavor_json["flavor"] = { - "requests": { - "cpu": flavor.cpu_req, - "memory": flavor.mem_req, - "ephemeral-storage": flavor.ephmem_req, - }, - "limits": { - "cpu": flavor.cpu_lim, - "memory": flavor.mem_lim, - "ephemeral-storage": flavor.ephmem_lim, - }, - } - if flavor.gpu_req and int(flavor.gpu_req) > 0: - flavor_json["flavor"]["requests"]["nvidia.com/gpu"] = (flavor.gpu_req,) - flavor_json["flavor"]["limits"]["nvidia.com/gpu"] = flavor.gpu_lim - - return flavor_json - - -def serialize_environment(form_selection, project): - logger.info("SERIALIZING ENVIRONMENT") - environment_json = dict() - if "environment" in form_selection: - environment_id = form_selection.get("environment", None) - try: - environment = Environment.objects.get(pk=environment_id) - except: # noqa E722 TODO: Add exception - environment = Environment.objects.get(name=environment_id, project=project) - environment_json["environment"] = { - "pk": environment.pk, - "repository": environment.repository, - "image": environment.image, - "registry": False, - } - if environment.registry: - environment_json["environment"]["registry"] = environment.registry.parameters - environment_json["environment"]["registry"]["enabled"] = True - else: - environment_json["environment"]["registry"] = {"enabled": False} - return environment_json - - -def serialize_apps(form_selection, project): - logger.info("SERIALIZING DEPENDENT APPS") - parameters = dict() - parameters["apps"] = dict() - app_deps = [] - for key in form_selection.keys(): - if "app:" in key and key[0:4] == "app:": - app_name = key[4:] - try: - app = Apps.objects.filter(name=app_name).order_by("-revision").first() - if not app: - app = Apps.objects.filter(slug=app_name).order_by("-revision").first() - except Exception: - logger.error("Failed to fetch app: %s", app_name) - raise - if not app: - logger.info("App not found: %s", app_name) - - parameters["apps"][app.slug] = dict() - logger.info("app: %s id: %s", app_name, str(form_selection[key])) - try: - objs = AppInstance.objects.filter(pk__in=form_selection.getlist(key)) - except: # noqa E722 TODO: Add exception - objs = AppInstance.objects.filter(name__in=form_selection[key], project=project) - - for obj in objs: - app_deps.append(obj) - parameters["apps"][app.slug][slugify(obj.name)] = obj.parameters - - return parameters, app_deps - - -def serialize_primitives(form_selection): - """ - This function serializes all values that are not part of the KEYWORDS list at the top. - """ - logger.info("SERIALIZING PRIMITIVES") - parameters = dict() - # TODO: minor-refactor: do for loop over form_selection without new variable - keys = form_selection.keys() - for key in keys: - if key not in KEYWORDS and "app:" not in key: - parameters[key] = form_selection[key].replace("\r\n", "\n") - - # Turn string booleans to python booleans - if parameters[key] == "False": - parameters[key] = False - elif parameters[key] == "True": - parameters[key] = True - - # Slugify app_name so that users can use special chars in their app names. - elif key == "app_name": - parameters[key] = slugify(parameters[key]) - logger.info(parameters) - - return flatten_json.unflatten(parameters, ".") - - -def serialize_permissions(form_selection): - """ - Serialize permissions from form selection into a dictionary (and later into a JSON object) - - To achieve this we first create a dictionary with all permissions set to False. - Then we set the permission that was selected (or typed in admin panel) to True. - - :param form_selection: form selection from request.POST - :return: dictionary of permissions - """ - logger.info("SERIALIZING PERMISSIONS") - parameters = dict() - parameters["permissions"] = { - "public": False, - "project": False, - "private": False, - "link": False, - } - - permission = form_selection.get("permission", None) - parameters["permissions"][permission] = True - logger.info(parameters) - return parameters - - -def serialize_appobjs(form_selection): - logger.info("SERIALIZING APPOBJS") - parameters = dict() - appobjs = [] - if "appobj" in form_selection: - appobjs = form_selection.getlist("appobj") - parameters["appobj"] = dict() - for obj in appobjs: - app = Apps.objects.get(pk=obj) - parameters["appobj"][app.slug] = True - logger.info(parameters) - return parameters - - -def serialize_default_values(aset): - parameters = [] - if "default_values" in aset: - parameters = dict() - logger.info(aset["default_values"]) - parameters["default_values"] = aset["default_values"] - for key in parameters["default_values"].keys(): - if parameters["default_values"][key] == "False": - parameters["default_values"][key] = False - elif parameters["default_values"][key] == "True": - parameters["default_values"][key] = True - - return parameters - - -def serialize_project(project): - parameters = dict() - if project.mlflow: - parameters["mlflow"] = { - "url": project.mlflow.mlflow_url, - "host": project.mlflow.host, - "service": project.mlflow.app.parameters["service"]["name"], - "port": project.mlflow.app.parameters["service"]["port"], - "targetport": project.mlflow.app.parameters["service"]["targetport"], - "s3url": "https://" + project.mlflow.s3.host, - "s3service": project.mlflow.s3.app.parameters["service"]["name"], - "s3port": project.mlflow.s3.app.parameters["service"]["port"], - "s3targetport": project.mlflow.s3.app.parameters["service"]["targetport"], - "access_key": project.mlflow.s3.access_key, - "secret_key": project.mlflow.s3.secret_key, - "region": project.mlflow.s3.region, - "username": project.mlflow.basic_auth.username, - "password": project.mlflow.basic_auth.password, - } - return parameters - - -def serialize_cli(username, project, aset): - user = User.objects.get(username=username) - token, created = Token.objects.get_or_create(user=user) - parameters = dict() - if "export-cli" in aset and aset["export-cli"] == "True": - parameters["cli_setup"] = { - "url": settings.STUDIO_URL, - "project": project.name, - "user": username, - "token": token.key, - "secure": "False", - } - return parameters - - -def serialize_env_variables(username, project, aset): - logger.info("SERIALIZING ENV VARIABLES") - parameters = dict() - parameters["app_env"] = dict() - logger.info("fetching apps") - try: - apps = AppInstance.objects.filter( - ~Q(state="Deleted"), - Q(owner__username=username) | Q(access__in=["project", "public"]), - project=project, - ) - except Exception as err: - logger.error(err, exc_info=True) - logger.info("Creating template engine") - django_engine = engines["django"] - # TODO: refactor potential bug. If there is an exception thrown by query statement above, then `apps` is not defined - logger.info(apps) - for app in apps: - params = app.parameters - appsettings = app.app.settings - if "env_variables" in appsettings: - tmp = json.dumps(appsettings["env_variables"]) - env_vars = json.loads(django_engine.from_string(tmp).render(params)) - for key in env_vars.keys(): - parameters["app_env"][slugify(key)] = env_vars[key] - logger.info(parameters) - - return parameters - - -def serialize_app(form_selection, project, aset, username): - logger.info("SERIALIZING APP") - parameters = dict() - - model_params, model_deps = serialize_model(form_selection) - parameters.update(model_params) - - app_params, app_deps = serialize_apps(form_selection, project) - parameters.update(app_params) - - prim_params = serialize_primitives(form_selection) - parameters.update(prim_params) - - flavor_params = serialize_flavor(form_selection, project) - parameters.update(flavor_params) - - environment_params = serialize_environment(form_selection, project) - parameters.update(environment_params) - - s3params = serialize_S3(form_selection, project) - parameters.update(s3params) - - permission_params = serialize_permissions(form_selection) - parameters.update(permission_params) - - appobj_params = serialize_appobjs(form_selection) - parameters.update(appobj_params) - - default_values = serialize_default_values(aset) - parameters.update(default_values) - - project_values = serialize_project(project) - parameters.update(project_values) - - cli_values = serialize_cli(username, project, aset) - parameters.update(cli_values) - - env_variables = serialize_env_variables(username, project, aset) - parameters.update(env_variables) - - return parameters, app_deps, model_deps diff --git a/apps/setup.py b/apps/setup.py deleted file mode 100644 index da5fdc98d..000000000 --- a/apps/setup.py +++ /dev/null @@ -1,35 +0,0 @@ -from setuptools import setup - -setup( - name="studio-apps", - version="0.0.1", - description="""Django app for handling portal in Studio""", - url="https://www.scaleoutsystems.com", - include_package_data=True, - package=["apps"], - package_dir={"apps": "."}, - python_requires=">=3.6,<4", - install_requires=[ - "django==4.2.1", - "requests==2.31.0", - "django-guardian==2.4.0", - "celery==5.2.7", - "Pillow==9.4.0", - "django-tagulous==1.3.3", - "minio==7.0.2", - "s3fs==2022.1.0", - "flatten-json==0.1.13", - "PyYAML==6.0", - ], - license="Copyright Scaleout Systems AB. See license for details", - zip_safe=False, - keywords="", - classifiers=[ - "Development Status :: 2 - Pre-Alpha", - "Intended Audience :: Developers", - "Natural Language :: English", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - ], -) diff --git a/apps/signals.py b/apps/signals.py new file mode 100644 index 000000000..d1c018496 --- /dev/null +++ b/apps/signals.py @@ -0,0 +1,80 @@ +from django.db.models.signals import post_delete, post_save, pre_delete +from django.dispatch import receiver +from guardian.shortcuts import assign_perm, remove_perm + +from apps.app_registry import APP_REGISTRY +from apps.models import AppStatus, BaseAppInstance +from studio.utils import get_logger + +from .tasks import helm_delete + +logger = get_logger(__name__) + + +UID = "app_instance_update_permission" + + +@receiver(pre_delete, sender=BaseAppInstance) +def pre_delete_helm_uninstall(sender, instance, **kwargs): + """ + If object is deleted from the database, then we run helm uninstall + """ + logger.info("PRE DELETING RESOURCES") + + values = instance.k8s_values + if values: + helm_delete.delay(values["subdomain"], values["namespace"]) + else: + logger.error(f"Could not find helm release for {instance}") + + +# After status of app changes to Deleted, remove subdomain from BaseAppInstance +@receiver(post_save, sender=AppStatus) +def post_delete_subdomain_remove(sender, instance, using, **kwargs): + if instance.status == "Deleted": + baseapp_instance = BaseAppInstance.objects.get(app_status=instance) + baseapp_instance.subdomain = None + baseapp_instance.save() + + +def update_permission(sender, instance, created, **kwargs): + owner = instance.owner + + access = getattr(instance, "access", None) + + if access is None: + logger.error(f"Access not found in {instance}") + return + + if access == "private": + logger.info(f"Assigning permission to {owner} for {instance}") + if created or not owner.has_perm("can_access_app", instance): + assign_perm("can_access_app", owner, instance) + + else: + if owner.has_perm("can_access_app", instance): + logger.info(f"Removing permission from {owner} for {instance}") + remove_perm("can_access_app", owner, instance) + + +for model in APP_REGISTRY.iter_orm_models(): + receiver(post_save, sender=model, dispatch_uid=UID)(update_permission) + + """ + What is going on here? + Well, after a model is saved, we want to update the permission of the owner of the model. + This signal is triggered after a model is saved, and we update the permission of the owner of the model. + But, since we have many types of models, we must add a reciever for all types of models. + We can do this by iterating over the values of SLUG_MODEL_FORM_MAP, + which is a dictionary that maps the slug of the model to the model itself. + + Equivalent to doing this: + + @receiver(post_save, sender=JupyterInstance, dispatch_uid=UID) + @receiver(post_save, sender=DashInstance, dispatch_uid=UID) + ... + @receiver(post_save, sender=LastModelInstance, dispatch_uid=UID) + def update_perrmission(sender, instance, created, **kwargs): + pass + + """ diff --git a/apps/tasks.py b/apps/tasks.py index b40ba46c3..a9b4fa5c6 100644 --- a/apps/tasks.py +++ b/apps/tasks.py @@ -1,532 +1,162 @@ -import json import subprocess -import time +import uuid +from datetime import datetime -import requests +import yaml from celery import shared_task -from celery.signals import worker_ready from django.apps import apps -from django.conf import settings -from django.core.exceptions import EmptyResultSet +from django.core.exceptions import ObjectDoesNotExist from django.db import transaction -from django.db.models import Q from django.utils import timezone -from models.models import Model, ObjectType -from projects.models import S3, BasicAuth, Environment, MLFlow +from apps.app_registry import APP_REGISTRY from studio.celery import app from studio.utils import get_logger -from . import controller -from .models import AppInstance, Apps, AppStatus, ResourceData +from .models import FilemanagerInstance logger = get_logger(__name__) -K8S_STATUS_MAP = { - "CrashLoopBackOff": "Error", - "Completed": "Retrying...", - "ContainerCreating": "Created", - "PodInitializing": "Pending", -} - -ReleaseName = apps.get_model(app_label=settings.RELEASENAME_MODEL) - - -def get_URI(parameters): - URI = "https://" + parameters["release"] + "." + parameters["global"]["domain"] - - URI = URI.strip("/") - return URI - - -def process_helm_result(results): - stdout = results.stdout.decode("utf-8") - stderr = results.stderr.decode("utf-8") - return stdout, stderr - - -def post_create_hooks(instance): - logger.info("TASK - POST CREATE HOOK...") - # hard coded hooks for now, we can make this dynamic - # and loaded from the app specs - - if instance.app.slug == "minio-admin": - # Create project S3 object - # TODO: If the instance is being updated, - # update the existing S3 object. - access_key = instance.parameters["credentials"]["access_key"] - secret_key = instance.parameters["credentials"]["secret_key"] - - # OBS!! TEMP WORKAROUND to be able to connect to minio - minio_svc = "{}-minio".format(instance.parameters["release"]) - cmd = f"kubectl -n {settings.NAMESPACE} get svc {minio_svc} -o jsonpath='{{.spec.clusterIP}}'" - minio_host_url = "" - try: - result = subprocess.run( - cmd, - shell=True, - capture_output=True, - timeout=settings.KUBE_API_REQUEST_TIMEOUT, - ) - minio_host_url = result.stdout.decode("utf-8") - minio_host_url += ":9000" - except subprocess.CalledProcessError: - logger.error("Oops, something went wrong running the command: %s", cmd) - - try: - s3obj = instance.s3obj - s3obj.access_key = access_key - s3obj.secret_key = secret_key - s3obj.host = minio_host_url - except: # noqa E722 TODO: Add exception - s3obj = S3( - name=instance.name, - project=instance.project, - host=minio_host_url, - access_key=access_key, - secret_key=secret_key, - app=instance, - owner=instance.owner, - ) - s3obj.save() - - if instance.app.slug == "environment": - params = instance.parameters - image = params["container"]["name"] - # We can assume one registry here - for reg_key in params["apps"]["docker_registry"].keys(): - reg_release = params["apps"]["docker_registry"][reg_key]["release"] - reg_domain = params["apps"]["docker_registry"][reg_key]["global"]["domain"] - repository = reg_release + "." + reg_domain - registry = AppInstance.objects.get(parameters__contains={"release": reg_release}) - - target_environment = Environment.objects.get(pk=params["environment"]["pk"]) - target_app = target_environment.app - - env_obj = Environment( - name=instance.name, - project=instance.project, - repository=repository, - image=image, - registry=registry, - app=target_app, - appenv=instance, - ) - env_obj.save() - - if instance.app.slug == "mlflow": - params = instance.parameters - - # OBS!! TEMP WORKAROUND to be able to connect to mlflow (internal dns - # between docker and k8s does not work currently) - # Sure one could use FQDN but lets avoid going via the internet - mlflow_svc = instance.parameters["service"]["name"] - cmd = f"kubectl -n {settings.NAMESPACE} get svc {mlflow_svc} -o jsonpath='{{.spec.clusterIP}}'" - mlflow_host_ip = "" - try: - result = subprocess.run( - cmd, - shell=True, - capture_output=True, - timeout=settings.KUBE_API_REQUEST_TIMEOUT, - ) - mlflow_host_ip = result.stdout.decode("utf-8") - mlflow_host_ip += ":{}".format(instance.parameters["service"]["port"]) - except subprocess.CalledProcessError: - logger.error("Oops, something went wrong running the command: %s", cmd) - - s3 = S3.objects.get(pk=instance.parameters["s3"]["pk"]) - basic_auth = BasicAuth( - owner=instance.owner, - name=instance.name, - project=instance.project, - username=instance.parameters["credentials"]["username"], - password=instance.parameters["credentials"]["password"], - ) - basic_auth.save() - obj = MLFlow( - name=instance.name, - project=instance.project, - mlflow_url="https://{}.{}".format( - instance.parameters["release"], - instance.parameters["global"]["domain"], - ), - s3=s3, - host=mlflow_host_ip, - basic_auth=basic_auth, - app=instance, - owner=instance.owner, - ) - obj.save() - elif instance.app.slug in ("volumeK8s", "netpolicy"): - # Handle volumeK8s and netpolicy creation/recreation - instance.state = "Created" - instance.deleted_on = None - status = AppStatus(appinstance=instance) - status.status_type = "Created" - instance.save() - status.save() - - -def release_name(instance): - # Free up release name (if reserved) - rel_names = instance.releasename_set.all() - - for rel_name in rel_names: - rel_name.status = "active" - rel_name.app = None - rel_name.save() - - -def post_delete_hooks(appinstance): - logger.info("TASK - POST DELETE HOOK...") - release_name(appinstance) - project = appinstance.project - if project.s3storage and project.s3storage.app == appinstance: - project.s3storage.delete() - elif project.mlflow and project.mlflow.app == appinstance: - project.mlflow.delete() - elif appinstance.app.slug in ("volumeK8s", "netpolicy"): - # Handle volumeK8s and netpolicy deletion - appinstance.state = "Deleted" - appinstance.deleted_on = timezone.now() - status = AppStatus(appinstance=appinstance) - status.status_type = "Deleted" - appinstance.save() - status.save() +@app.task +def delete_old_objects(): + """ + This function retrieves the old apps based on the given threshold, category, and model class. + It then iterates through the subclasses of BaseAppInstance and deletes the old apps + for both the "Develop" and "Manage Files" categories. -@shared_task -@transaction.atomic -def delete_and_deploy_resource(instance_pk, new_release_name): - appinstance = AppInstance.objects.select_for_update().get(pk=instance_pk) - - if appinstance: - delete_resource(appinstance.pk) - - parameters = appinstance.parameters - parameters["release"] = new_release_name - appinstance.parameters.update(parameters) - appinstance.save(update_fields=["parameters", "table_field"]) - - try: - rel_name_obj = ReleaseName.objects.get(name=new_release_name, project=appinstance.project, status="active") - rel_name_obj.status = "in-use" - rel_name_obj.app = appinstance - rel_name_obj.save() - except Exception: - logger.error("Error: Submitted release name not owned by project.", exc_info=True) - - deploy_resource(appinstance.pk) - - -@shared_task -@transaction.atomic -def deploy_resource(instance_pk, action="create"): - logger.info("TASK - DEPLOY RESOURCE...") - appinstance = AppInstance.objects.select_for_update().get(pk=instance_pk) - - results = controller.deploy(appinstance.parameters) - if type(results) is str: - results = json.loads(results) - stdout = results["status"] - stderr = results["reason"] - logger.info("Helm install failed") - helm_info = { - "success": False, - "info": {"stdout": stdout, "stderr": stderr}, - } - appinstance.info["helm"] = helm_info - appinstance.save() - else: - stdout, stderr = process_helm_result(results) - - if results.returncode == 0: - logger.info("Helm install succeeded") - - helm_info = { - "success": True, - "info": {"stdout": stdout, "stderr": stderr}, - } - else: - logger.error("Helm install failed") - helm_info = { - "success": False, - "info": {"stdout": stdout, "stderr": stderr}, - } - appinstance.info["helm"] = helm_info - appinstance.save() - if results.returncode != 0: - logger.info(appinstance.info["helm"]) - else: - post_create_hooks(appinstance) - return results + """ + def get_threshold(threshold): + return timezone.now() - timezone.timedelta(days=threshold) -@shared_task -@transaction.atomic -def delete_resource(pk): - appinstance = AppInstance.objects.select_for_update().get(pk=pk) - - if appinstance and appinstance.state != "Deleted": - # The instance does exist. - parameters = appinstance.parameters - # TODO: Check that the user has the permission required to delete it. - - # TODO: Fix for multicluster setup - # TODO: We are assuming this URI here, but we should allow - # for other forms. - # The instance should store information about this. - - # Invoke chart controller - results = controller.delete(parameters) - - if results.returncode == 0 or "release: not found" in results.stderr.decode("utf-8"): - status = AppStatus(appinstance=appinstance) - status.status_type = "Deleting..." - appinstance.state = "Deleting..." - status.save() - logger.info("CALLING POST DELETE HOOKS") - post_delete_hooks(appinstance) - else: - status = AppStatus(appinstance=appinstance) - status.status_type = "FailedToDelete" - status.save() - appinstance.state = "FailedToDelete" - appinstance.save(update_fields=["state"]) + # Handle deletion of apps in the "Develop" category + for orm_model in APP_REGISTRY.iter_orm_models(): + old_develop_apps = orm_model.objects.filter(created_on__lt=get_threshold(7), app__category__name="Develop") + for app_ in old_develop_apps: + delete_resource.delay(app_.pk) -@shared_task -@transaction.atomic -def delete_resource_permanently(appinstance): - parameters = appinstance.parameters + # Handle deletion of non persistent file managers + old_file_managers = FilemanagerInstance.objects.filter( + created_on__lt=timezone.now() - timezone.timedelta(days=1), persistent=False + ) + for app_ in old_file_managers: + delete_resource.delay(app_.pk) - # Invoke chart controller - results = controller.delete(parameters) - if not (results.returncode == 0 or "release: not found" in results.stderr.decode("utf-8")): - status = AppStatus(appinstance=appinstance) - status.status_type = "FailedToDelete" - status.save() - appinstance.state = "FailedToDelete" +def helm_install(release_name, chart, namespace="default", values_file=None, version=None): + """ + Run a Helm install command. - release_name(appinstance) + Args: + release_name (str): Name of the Helm release. + chart (str): Helm chart to install. + namespace (str): Kubernetes namespace to deploy to. + options (list, optional): Additional options for Helm command in list format. - appinstance.delete() + Returns: + tuple: Output message and any errors from the Helm command. + """ + # Base command + command = f"helm upgrade --force --install {release_name} {chart} --namespace {namespace}" + if values_file: + command += f" -f {values_file}" -@app.task -def get_resource_usage(): - timestamp = time.time() + # Append version if deploying via ghcr + if version: + command += f" --version {version} --repository-cache /app/charts/.cache/helm/repository" - args = ["kubectl", "get", "--raw", "/apis/metrics.k8s.io/v1beta1/pods"] - results = subprocess.run(args, capture_output=True) - - pods = [] + logger.debug(f"Running Helm command: {command}") + # Execute the command try: - res_json = json.loads(results.stdout.decode("utf-8")) - pods = res_json["items"] - except: # noqa E722 TODO: Add exception - pass - - resources = dict() + result = subprocess.run(command.split(" "), check=True, text=True, capture_output=True) + return result.stdout, None + except subprocess.CalledProcessError as e: + return e.stdout, e.stderr - args_pod = ["kubectl", "-n", f"{settings.NAMESPACE}" "get", "po", "-o", "json"] - results_pod = subprocess.run(args_pod, capture_output=True) - results_pod_json = json.loads(results_pod.stdout.decode("utf-8")) - try: - for pod in results_pod_json["items"]: - if ( - "metadata" in pod - and "labels" in pod["metadata"] - and "release" in pod["metadata"]["labels"] - and "project" in pod["metadata"]["labels"] - ): - pod_name = pod["metadata"]["name"] - resources[pod_name] = dict() - resources[pod_name]["labels"] = pod["metadata"]["labels"] - resources[pod_name]["cpu"] = 0.0 - resources[pod_name]["memory"] = 0.0 - resources[pod_name]["gpu"] = 0 - except: # noqa E722 TODO: Add exception - pass +@shared_task +def helm_delete(release_name, namespace="default"): + # Base command + command = f"helm uninstall {release_name} --namespace {namespace} --wait" + # Execute the command try: - for pod in pods: - podname = pod["metadata"]["name"] - if podname in resources: - containers = pod["containers"] - cpu = 0 - mem = 0 - for container in containers: - cpun = container["usage"]["cpu"] - memki = container["usage"]["memory"] - try: - cpu += int(cpun.replace("n", "")) / 1e6 - except: # noqa E722 TODO: Add exception - logger.error("Failed to parse CPU usage: %s", cpun) - if "Ki" in memki: - mem += int(memki.replace("Ki", "")) / 1000 - elif "Mi" in memki: - mem += int(memki.replace("Mi", "")) - elif "Gi" in memki: - mem += int(memki.replace("Mi", "")) * 1000 - - resources[podname]["cpu"] = cpu - resources[podname]["memory"] = mem - except: # noqa E722 TODO: Add exception - pass - # TODO minor refactor: remove unnecessary comments - # print(json.dumps(resources, indent=2)) - - for key in resources.keys(): - entry = resources[key] - # print(entry['labels']['release']) - try: - appinstance = AppInstance.objects.get(parameters__contains={"release": entry["labels"]["release"]}) - # print(timestamp) - # print(appinstance) - # print(entry) - datapoint = ResourceData( - appinstance=appinstance, - cpu=entry["cpu"], - mem=entry["memory"], - gpu=entry["gpu"], - time=timestamp, - ) - datapoint.save() - except: # noqa E722 TODO: Add exception - logger.error("Didn't find corresponding AppInstance: %s", key, exc_info=True) - - # print(timestamp) - # print(json.dumps(resources, indent=2)) - - -@app.task -def sync_mlflow_models(): - mlflow_apps = AppInstance.objects.filter(~Q(state="Deleted"), project__status="active", app__slug="mlflow") - for mlflow_app in mlflow_apps: - if mlflow_app.project is None or mlflow_app.project.mlflow is None: - continue - - url = "http://{}/{}".format( - mlflow_app.project.mlflow.host, - "api/2.0/mlflow/model-versions/search", - ) - res = False - try: - res = requests.get(url) - except Exception: - logger.error("Call to MLFlow Server failed.", exc_info=True) - - if res: - models = res.json() - logger.info(models) - if len(models) > 0: - for item in models["model_versions"]: - # print(item) - name = item["name"] - version = "v{}.0.0".format(item["version"]) - release = "major" - source = item["source"].replace("s3://", "").split("/") - run_id = source[2] - path = "/".join(source[1:]) - project = mlflow_app.project - uid = run_id - s3 = S3.objects.get(pk=mlflow_app.parameters["s3"]["pk"]) - model_found = True - try: - stackn_model = Model.objects.get(uid=uid) - except: # noqa E722 TODO: Add exception - model_found = False - if not model_found: - obj_type = ObjectType.objects.filter(slug="mlflow") - if obj_type.exists(): - model = Model( - version=version, - project=project, - name=name, - uid=uid, - release_type=release, - s3=s3, - bucket="mlflow", - path=path, - ) - model.save() - model.object_type.set(obj_type) - else: - raise EmptyResultSet - else: - if item["current_stage"] == "Archived" and stackn_model.status != "AR": - stackn_model.status = "AR" - stackn_model.save() - if item["current_stage"] != "Archived" and stackn_model.status == "AR": - stackn_model.status = "CR" - stackn_model.save() - else: - logger.warning("WARNING: Failed to fetch info from MLflow Server: %s", url) + result = subprocess.run(command.split(" "), check=True, text=True, capture_output=True) + return result.stdout, None + except subprocess.CalledProcessError as e: + return e.stdout, e.stderr -@app.task -def clean_resource_usage(): - curr_timestamp = time.time() - ResourceData.objects.filter(time__lte=curr_timestamp - 48 * 3600).delete() +@shared_task +@transaction.atomic +def deploy_resource(serialized_instance): + instance = deserialize(serialized_instance) + logger.info("Deploying resource for instance %s", instance) + values = instance.k8s_values + if "ghcr" in instance.chart: + version = instance.chart.split(":")[-1] + chart = "oci://" + instance.chart.split(":")[0] + # Save helm values file for internal reference + values_file = f"charts/values/{str(uuid.uuid4())}.yaml" + with open(values_file, "w") as f: + f.write(yaml.dump(values)) + output, error = helm_install(values["subdomain"], chart, values["namespace"], values_file, version) + success = not error -@app.task -def remove_deleted_app_instances(): - apps = AppInstance.objects.filter(state="Deleted") - logger.info("NUMBER OF APPS TO DELETE: %s", len(apps)) - for instance in apps: - try: - name = instance.name - logger.info("Deleting app instance: %s", name) - instance.delete() - logger.info("Deleted app instance: %s", name) - except Exception: - logger.error("Failed to delete app instances.", exc_info=True) + helm_info = {"success": success, "info": {"stdout": output, "stderr": error}} + instance.info = dict(helm=helm_info) + instance.app_status.status = "Created" if success else "Failed" -@app.task -def clear_table_field(): - all_apps = AppInstance.objects.all() - for instance in all_apps: - instance.table_field = "{}" - instance.save() + instance.app_status.save() + instance.save() - all_apps = Apps.objects.all() - for instance in all_apps: - instance.table_field = "{}" - instance.save() + subprocess.run(["rm", "-f", values_file]) -@app.task -def purge_tasks(): - """ - Remove tasks from queue to avoid overflow - """ - app.control.purge() +@shared_task +@transaction.atomic +def delete_resource(serialized_instance): + instance = deserialize(serialized_instance) + values = instance.k8s_values + output, error = helm_delete(values["subdomain"], values["namespace"]) + success = not error -@app.task -def delete_old_objects(): - """ - Deletes apps of category Develop (e.g., jupyter-lab, vscode etc) + if success: + if instance.app.slug in ("volumeK8s", "netpolicy"): + instance.app_status.status = "Deleted" + instance.deleted_on = datetime.now() + else: + instance.app_status.status = "Deleting..." + else: + instance.app_status.status = "FailedToDelete" - Setting the threshold to 7 days. If any app is older than this, it will be deleted. - The deleted resource will still exist in the database, but with status "Deleted" + helm_info = {"success": success, "info": {"stdout": output, "stderr": error}} - TODO: Make this a variable in settings.py and use the same number in templates - """ + instance.info = dict(helm=helm_info) + instance.app_status.save() + instance.save() - def get_old_apps(threshold, category): - threshold_time = timezone.now() - timezone.timedelta(days=threshold) - return AppInstance.objects.filter(created_on__lt=threshold_time, app__category__name=category) - old_develop_apps = get_old_apps(threshold=7, category="Develop") - old_minio_apps = get_old_apps(threshold=1, category="Manage Files") - for app_ in old_develop_apps: - delete_resource.delay(app_.pk) +def deserialize(serialized_instance): + # Check if the input is a dictionary + if not isinstance(serialized_instance, dict): + raise ValueError(f"The input must be a dictionary and not {type(serialized_instance)}") - for app_ in old_minio_apps: - delete_resource.delay(app_.pk) + try: + model = serialized_instance["model"] + pk = serialized_instance["pk"] + app_label, model_name = model.split(".") + + model_class = apps.get_model(app_label, model_name) + instance = model_class.objects.get(pk=pk) + + return instance + except (KeyError, ValueError) as e: + raise ValueError(f"Invalid serialized data format: {e}") + except ObjectDoesNotExist: + raise ValueError(f"No instance found for model {model} with pk {pk}") diff --git a/apps/tests/test_app_instance.py b/apps/tests/test_app_instance.py index 38568c7cc..ad52d0aa8 100644 --- a/apps/tests/test_app_instance.py +++ b/apps/tests/test_app_instance.py @@ -1,69 +1,102 @@ from django.contrib.auth import get_user_model +from django.db.models.signals import post_save from django.test import TestCase from projects.models import Project -from ..models import AppInstance, Apps +from ..models import ( + Apps, + AppStatus, + BaseAppInstance, + CustomAppInstance, + FilemanagerInstance, + JupyterInstance, + RStudioInstance, + ShinyInstance, + Subdomain, + TissuumapsInstance, + VSCodeInstance, +) User = get_user_model() +MODELS_LIST = [ + JupyterInstance, + RStudioInstance, + VSCodeInstance, + FilemanagerInstance, + ShinyInstance, + TissuumapsInstance, + CustomAppInstance, +] -class AppInstaceTestCase(TestCase): + +class AppInstanceTestCase(TestCase): def setUp(self): self.user = User.objects.create_user("foo1", "foo@test.com", "bar") def get_data(self, access): project = Project.objects.create_project(name="test-perm", owner=self.user, description="") - app = Apps.objects.create(name="FEDn Combiner", slug="combiner") - - app_instance = AppInstance.objects.create( - access=access, - owner=self.user, - name="test_app_instance_private", - app=app, - project=project, - ) - - return [project, app, app_instance] + app = Apps.objects.create(name="Serve App", slug="serve-app") + + app_instance_list = [] + for i, model_class in enumerate(MODELS_LIST): + subdomain = Subdomain.objects.create(subdomain=f"test_internal_{i}") + app_status = AppStatus.objects.create(status="Created") + + app_instance = model_class.objects.create( + access=access, + owner=self.user, + name="test_app_instance_private", + app=app, + project=project, + subdomain=subdomain, + app_status=app_status, + ) + app_instance_list.append(app_instance) + + return app_instance_list def test_permission_created_if_private(self): - project, app, app_instance = self.get_data("private") + app_instance_list = self.get_data("private") - result = self.user.has_perm("can_access_app", app_instance) - - self.assertTrue(result) + result_list = [self.user.has_perm("can_access_app", app_instance) for app_instance in app_instance_list] + print(result_list) + self.assertTrue(all(result_list)) def test_permission_do_note_created_if_project(self): - project, app, app_instance = self.get_data("project") - - result = self.user.has_perm("can_access_app", app_instance) + app_instance_list = self.get_data("project") - self.assertFalse(result) + result_list = [self.user.has_perm("can_access_app", app_instance) for app_instance in app_instance_list] + print(result_list) + self.assertFalse(any(result_list)) def test_permission_create_if_changed_to_private(self): - project, app, app_instance = self.get_data("project") + app_instance_list = self.get_data("project") - result = self.user.has_perm("can_access_app", app_instance) + result_list = [self.user.has_perm("can_access_app", app_instance) for app_instance in app_instance_list] + print(result_list) + self.assertFalse(any(result_list)) - self.assertFalse(result) + for app_instance in app_instance_list: + app_instance.access = "private" + app_instance.save() - app_instance.access = "private" - app_instance.save() - - result = self.user.has_perm("can_access_app", app_instance) - - self.assertTrue(result) + result_list = [self.user.has_perm("can_access_app", app_instance) for app_instance in app_instance_list] + print(result_list) + self.assertTrue(all(result_list)) def test_permission_remove_if_changed_from_project(self): - project, app, app_instance = self.get_data("private") - - result = self.user.has_perm("can_access_app", app_instance) - - self.assertTrue(result) + app_instance_list = self.get_data("private") - app_instance.access = "project" - app_instance.save() + result_list = [self.user.has_perm("can_access_app", app_instance) for app_instance in app_instance_list] + print(result_list) + self.assertTrue(all(result_list)) - result = self.user.has_perm("can_access_app", app_instance) + for app_instance in app_instance_list: + app_instance.access = "project" + app_instance.save() - self.assertFalse(result) + result_list = [self.user.has_perm("can_access_app", app_instance) for app_instance in app_instance_list] + print(result_list) + self.assertFalse(any(result_list)) diff --git a/apps/tests/test_app_instance_manager.py b/apps/tests/test_app_instance_manager.py index fbbcc5a84..199eaa1a1 100644 --- a/apps/tests/test_app_instance_manager.py +++ b/apps/tests/test_app_instance_manager.py @@ -4,10 +4,31 @@ from projects.models import Project -from ..models import AppInstance, Apps +from ..models import ( + Apps, + BaseAppInstance, + CustomAppInstance, + FilemanagerInstance, + JupyterInstance, + RStudioInstance, + ShinyInstance, + Subdomain, + TissuumapsInstance, + VSCodeInstance, +) User = get_user_model() +MODELS_LIST = [ + JupyterInstance, + RStudioInstance, + VSCodeInstance, + FilemanagerInstance, + ShinyInstance, + TissuumapsInstance, + CustomAppInstance, +] + class AppInstaceManagerTestCase(TestCase): def setUp(self): @@ -17,72 +38,17 @@ def setUp(self): ) app = Apps.objects.create(name="Persistent Volume", slug="volumeK8s") - app_instance = AppInstance.objects.create( - access="project", - owner=self.user, - name="test_app_instance_1", - app=app, - project=self.project, - ) - - app_instance_2 = AppInstance.objects.create( - access="project", - owner=self.user, - name="test_app_instance_2", - app=app, - project=self.project, - ) - - app_instance_3 = AppInstance.objects.create( - access="project", - owner=self.user, - name="test_app_instance_3", - app=app, - project=self.project, - ) - - app_instance.app_dependencies.set([app_instance_2, app_instance_3]) - - app_instance_4 = AppInstance.objects.create( - access="project", - owner=self.user, - name="test_app_instance_4", - app=app, - project=self.project, - ) - - app_instance_5 = AppInstance.objects.create( - access="project", - owner=self.user, - name="test_app_instance_5", - app=app, - project=self.project, - ) - - app_instance_4.app_dependencies.set([app_instance_5]) - - @override_settings(STUDIO_ACCESSMODE="ReadWriteOnce") - def test_get_available_app_dependencies_rw_once(self): - result = AppInstance.objects.get_available_app_dependencies( - user=self.user, project=self.project, app_name="Persistent Volume" - ) - - self.assertEqual(len(result), 2) - - @override_settings(STUDIO_ACCESSMODE="ReadWriteMany") - def test_get_available_app_dependencies_rw_many(self): - result = AppInstance.objects.get_available_app_dependencies( - user=self.user, project=self.project, app_name="Persistent Volume" - ) - - self.assertEqual(len(result), 5) - - def test_get_available_app_dependencies_setting_default(self): - result = AppInstance.objects.get_available_app_dependencies( - user=self.user, project=self.project, app_name="Persistent Volume" - ) - - self.assertEqual(len(result), 5) + self.instances = [] + for i, model_class in enumerate(MODELS_LIST): + subdomain = Subdomain.objects.create(subdomain=f"test_{i}") + instance = BaseAppInstance.objects.create( + owner=self.user, + name=f"test_app_instance_{i}", + app=app, + project=self.project, + subdomain=subdomain, + ) + self.instances.append(instance) # ---------- get_app_instances_of_project ---------- # @@ -93,66 +59,67 @@ def test_get_app_instances_of_project(self): app = Apps.objects.create(name="Combiner", slug="combiner") - app_instance = AppInstance.objects.create( - access="project", + subdomain = Subdomain.objects.create(subdomain="test_internal") + instance = BaseAppInstance.objects.create( owner=self.user, name="test_app_instance_internal", app=app, project=project, + subdomain=subdomain, ) - result = AppInstance.objects.get_app_instances_of_project(self.user, self.project) + result = BaseAppInstance.objects.get_app_instances_of_project(self.user, self.project) - self.assertEqual(len(result), 5) + self.assertEqual(len(result), len(MODELS_LIST)) - app_instance.project = self.project - app_instance.save() + instance.project = self.project + instance.save() - result = AppInstance.objects.get_app_instances_of_project(self.user, self.project) + result = BaseAppInstance.objects.get_app_instances_of_project(self.user, self.project) - self.assertEqual(len(result), 6) + self.assertEqual(len(result), len(MODELS_LIST) + 1) - app_instance.access = "private" - app_instance.save() + instance.access = "private" + instance.save() - result = AppInstance.objects.get_app_instances_of_project(self.user, self.project) + result = BaseAppInstance.objects.get_app_instances_of_project(self.user, self.project) - self.assertEqual(len(result), 6) + self.assertEqual(len(result), len(MODELS_LIST) + 1) def test_get_app_instances_of_project_limit(self): - result = AppInstance.objects.get_app_instances_of_project(self.user, self.project, limit=3) + result = BaseAppInstance.objects.get_app_instances_of_project(self.user, self.project, limit=3) self.assertEqual(len(result), 3) - result = AppInstance.objects.get_app_instances_of_project(self.user, self.project) + result = BaseAppInstance.objects.get_app_instances_of_project(self.user, self.project) - self.assertEqual(len(result), 5) + self.assertEqual(len(result), len(MODELS_LIST)) def test_get_app_instances_of_project_filter(self): app = Apps.objects.create(name="Combiner", slug="combiner") - - _ = AppInstance.objects.create( - access="project", + subdomain = Subdomain.objects.create(subdomain="test_internal") + _ = BaseAppInstance.objects.create( owner=self.user, name="test_app_instance_internal", app=app, project=self.project, + subdomain=subdomain, ) def filter_func(slug): return Q(app__slug=slug) - result = AppInstance.objects.get_app_instances_of_project( + result = BaseAppInstance.objects.get_app_instances_of_project( self.user, self.project, filter_func=filter_func("volumeK8s") ) - self.assertEqual(len(result), 5) + self.assertEqual(len(result), len(MODELS_LIST)) - result = AppInstance.objects.get_app_instances_of_project(self.user, self.project) + result = BaseAppInstance.objects.get_app_instances_of_project(self.user, self.project) - self.assertEqual(len(result), 6) + self.assertEqual(len(result), len(MODELS_LIST) + 1) - result = AppInstance.objects.get_app_instances_of_project( + result = BaseAppInstance.objects.get_app_instances_of_project( self.user, self.project, filter_func=filter_func("non-existing-slug"), @@ -161,14 +128,14 @@ def filter_func(slug): self.assertEqual(len(result), 0) def test_get_app_instances_of_project_order_by(self): - result = AppInstance.objects.get_app_instances_of_project(self.user, self.project, order_by="name") + result = BaseAppInstance.objects.get_app_instances_of_project(self.user, self.project, order_by="name") - self.assertEqual(len(result), 5) - self.assertEqual(result.first().name, "test_app_instance_1") - self.assertEqual(result.last().name, "test_app_instance_5") + self.assertEqual(len(result), len(MODELS_LIST)) + self.assertEqual(result.first().name, "test_app_instance_0") + self.assertEqual(result.last().name, f"test_app_instance_{len(MODELS_LIST)-1}") - result = AppInstance.objects.get_app_instances_of_project(self.user, self.project, order_by="-name") + result = BaseAppInstance.objects.get_app_instances_of_project(self.user, self.project, order_by="-name") - self.assertEqual(len(result), 5) - self.assertEqual(result.first().name, "test_app_instance_5") - self.assertEqual(result.last().name, "test_app_instance_1") + self.assertEqual(len(result), len(MODELS_LIST)) + self.assertEqual(result.first().name, f"test_app_instance_{len(MODELS_LIST)-1}") + self.assertEqual(result.last().name, "test_app_instance_0") diff --git a/apps/tests/test_app_settings_view.py b/apps/tests/test_app_settings_view.py index 73655e506..1fe673959 100644 --- a/apps/tests/test_app_settings_view.py +++ b/apps/tests/test_app_settings_view.py @@ -3,7 +3,7 @@ from projects.models import Project -from ..models import AppCategories, AppInstance, Apps +from ..models import AppCategories, Apps, AppStatus, CustomAppInstance, Subdomain User = get_user_model() @@ -15,39 +15,25 @@ def setUp(self) -> None: self.user = User.objects.create_user(test_user["username"], test_user["email"], test_user["password"]) self.category = AppCategories.objects.create(name="Network", priority=100, slug="network") self.app = Apps.objects.create( - name="Jupyter Lab", - slug="jupyter-lab", + name="My Custom App", + slug="customapp", user_can_edit=False, category=self.category, - settings={ - "apps": {"Persistent Volume": "many"}, - "flavor": "one", - "default_values": {"port": "80", "targetport": "8888"}, - "environment": { - "name": "from", - "title": "Image", - "quantity": "one", - "type": "match", - }, - "permissions": { - "public": {"value": "false", "option": "false"}, - "project": {"value": "true", "option": "true"}, - "private": {"value": "false", "option": "true"}, - "link": {"value": "false", "option": "true"}, - }, - "export-cli": "True", - }, ) self.project = Project.objects.create_project(name="test-perm", owner=self.user, description="") - self.app_instance = AppInstance.objects.create( + subdomain = Subdomain.objects.create(subdomain="test_internal") + app_status = AppStatus.objects.create(status="Created") + self.app_instance = CustomAppInstance.objects.create( access="public", owner=self.user, name="test_app_instance_public", app=self.app, project=self.project, - parameters={ + subdomain=subdomain, + app_status=app_status, + k8s_values={ "environment": {"pk": ""}, }, ) @@ -67,7 +53,7 @@ def test_user_can_edit_true(self): self.assertEqual(response.status_code, 302) - url = f"/projects/{self.project.slug}/" + f"apps/settings/{self.app_instance.id}" + url = f"/projects/{self.project.slug}/" + f"apps/settings/{self.app_instance.app.slug}/{self.app_instance.id}" response = c.get(url) @@ -84,7 +70,7 @@ def test_user_can_edit_false(self): self.app.user_can_edit = True self.app.save() - url = f"/projects/{self.project.slug}/" + f"apps/settings/{self.app_instance.id}" + url = f"/projects/{self.project.slug}/" + f"apps/settings/{self.app_instance.app.slug}/{self.app_instance.id}" response = c.get(url) diff --git a/apps/tests/test_app_view_forbidden.py b/apps/tests/test_app_view_forbidden.py index 07de63f67..954c8fc53 100644 --- a/apps/tests/test_app_view_forbidden.py +++ b/apps/tests/test_app_view_forbidden.py @@ -18,74 +18,6 @@ def setUp(self): user = User.objects.create_user("member", "bar@test.com", "bar") self.client.login(username="bar@test.com", password="bar") - def test_forbidden_apps_compute(self): - """ - Test non-project member not allowed to access /=compute - """ - project = Project.objects.get(name="test-perm") - response = self.client.get( - reverse( - "apps:filtered", - kwargs={ - "project": project.slug, - "category": "compute", - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_apps_serve(self): - """ - Test non-project member not allowed to access /=serve - """ - project = Project.objects.get(name="test-perm") - response = self.client.get( - reverse( - "apps:filtered", - kwargs={ - "project": project.slug, - "category": "serve", - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_apps_store(self): - """ - Test non-project member not allowed to access /=store - """ - project = Project.objects.get(name="test-perm") - response = self.client.get( - reverse( - "apps:filtered", - kwargs={ - "project": project.slug, - "category": "store", - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_apps_develop(self): - """ - Test non-project member not allowed to access /=develop - """ - project = Project.objects.get(name="test-perm") - response = self.client.get( - reverse( - "apps:filtered", - kwargs={ - "project": project.slug, - "category": "develop", - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - def test_forbidden_apps_create(self): """ Test non-project member not allowed to access /create/=test @@ -105,13 +37,13 @@ def test_forbidden_apps_create(self): def test_forbidden_apps_logs(self): """ - Test non-project member not allowed to access /logs/=1 + Test non-project member not allowed to access /logs/=jupyter-lab/=1 """ project = Project.objects.get(name="test-perm") response = self.client.get( reverse( "apps:logs", - kwargs={"project": project.slug, "ai_id": "1"}, + kwargs={"project": project.slug, "app_slug": "jupyter-lab", "app_id": "1"}, ) ) self.assertTemplateUsed(response, "403.html") @@ -119,82 +51,11 @@ def test_forbidden_apps_logs(self): def test_forbidden_apps_settings(self): """ - Test non-project member not allowed to access /seetings/=1 - """ - project = Project.objects.get(name="test-perm") - response = self.client.get( - reverse( - "apps:appsettings", - kwargs={"project": project.slug, "ai_id": "1"}, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_apps_settings_add_tag(self): - """ - Test non-project member not allowed to access - /settings/=1/add_tag - """ - project = Project.objects.get(name="test-perm") - response = self.client.get( - reverse( - "apps:add_tag", - kwargs={"project": project.slug, "ai_id": "1"}, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_apps_settings_remove_tag(self): - """ - Test non-project member not allowed to access - /settings/=1/remove_tag - """ - project = Project.objects.get(name="test-perm") - response = self.client.get( - reverse( - "apps:remove_tag", - kwargs={"project": project.slug, "ai_id": "1"}, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_apps_delete(self): - """ - Test non-project member not allowed to access - /delete/=compute/=1 + Test non-project member not allowed to access /settings/=jupyter-lab/=1 """ project = Project.objects.get(name="test-perm") response = self.client.get( - reverse( - "apps:delete", - kwargs={ - "project": project.slug, - "ai_id": "1", - "category": "compute", - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_apps_publish(self): - """ - Test non-project member not allowed to access - /publish/=compute/=1 - """ - project = Project.objects.get(name="test-perm") - response = self.client.get( - reverse( - "apps:publish", - kwargs={ - "project": project.slug, - "ai_id": "1", - "category": "compute", - }, - ) + reverse("apps:appsettings", kwargs={"project": project.slug, "app_slug": "jupyter-lab", "app_id": "1"}) ) self.assertTemplateUsed(response, "403.html") self.assertEqual(response.status_code, 403) diff --git a/apps/tests/test_create_app_view.py b/apps/tests/test_create_app_view.py index 9c14c9188..899edbb20 100644 --- a/apps/tests/test_create_app_view.py +++ b/apps/tests/test_create_app_view.py @@ -5,7 +5,7 @@ from projects.models import Project -from ..models import AppInstance, Apps +from ..models import Apps, AppStatus, JupyterInstance, Subdomain User = get_user_model() @@ -18,24 +18,6 @@ def setUp(self) -> None: self.app = Apps.objects.create( name="Jupyter Lab", slug="jupyter-lab", - settings={ - "apps": {"Persistent Volume": "many"}, - "flavor": "one", - "default_values": {"port": "80", "targetport": "8888"}, - "environment": { - "name": "from", - "title": "Image", - "quantity": "one", - "type": "match", - }, - "permissions": { - "public": {"value": "false", "option": "false"}, - "project": {"value": "true", "option": "true"}, - "private": {"value": "false", "option": "true"}, - "link": {"value": "false", "option": "true"}, - }, - "export-cli": "True", - }, ) def get_data(self, user=None): @@ -163,12 +145,16 @@ def test_has_permission_project_level(self): self.assertEqual(response.status_code, 200) - _ = AppInstance.objects.create( + subdomain = Subdomain.objects.create(subdomain="test_internal") + app_status = AppStatus.objects.create(status="Created") + _ = JupyterInstance.objects.create( access="private", owner=self.user, name="test_app_instance_private", app=self.app, project=project, + subdomain=subdomain, + app_status=app_status, ) response = c.get(f"/projects/{project.slug}/apps/create/jupyter-lab") @@ -190,11 +176,11 @@ def test_permission_overrides_reached_app_limit(self): self.assertEqual(response.status_code, 403) - content_type = ContentType.objects.get_for_model(AppInstance) + content_type = ContentType.objects.get_for_model(JupyterInstance) project_permissions = Permission.objects.filter(content_type=content_type) add_permission = next( - (perm for perm in project_permissions if perm.codename == "add_appinstance"), + (perm for perm in project_permissions if perm.codename == "add_jupyterinstance"), None, ) @@ -230,12 +216,16 @@ def test_app_limit_is_per_project(self): self.assertEqual(response.status_code, 200) - _ = AppInstance.objects.create( + subdomain = Subdomain.objects.create(subdomain="test_internal") + app_status = AppStatus.objects.create(status="Created") + _ = JupyterInstance.objects.create( access="private", owner=self.user, name="test_app_instance_private", app=self.app, project=project, + subdomain=subdomain, + app_status=app_status, ) response = c.get(f"/projects/{project.slug}/apps/create/jupyter-lab") @@ -280,12 +270,16 @@ def test_app_limit_altered_for_project_v2(self): self.assertEqual(response.status_code, 200) - _ = AppInstance.objects.create( + subdomain = Subdomain.objects.create(subdomain="test_internal") + app_status = AppStatus.objects.create(status="Created") + _ = JupyterInstance.objects.create( access="private", owner=self.user, name="test_app_instance_private", app=self.app, project=project, + subdomain=subdomain, + app_status=app_status, ) response = c.get(f"/projects/{project.slug}/apps/create/jupyter-lab") diff --git a/apps/tests/test_delete_app_view.py b/apps/tests/test_delete_app_view.py index c80668c09..56e237bb9 100644 --- a/apps/tests/test_delete_app_view.py +++ b/apps/tests/test_delete_app_view.py @@ -5,7 +5,7 @@ from projects.models import Project -from ..models import AppCategories, AppInstance, Apps +from ..models import AppCategories, Apps, AppStatus, JupyterInstance, Subdomain User = get_user_model() @@ -21,33 +21,20 @@ def setUp(self) -> None: slug="jupyter-lab", user_can_delete=False, category=self.category, - settings={ - "apps": {"Persistent Volume": "many"}, - "flavor": "one", - "default_values": {"port": "80", "targetport": "8888"}, - "environment": { - "name": "from", - "title": "Image", - "quantity": "one", - "type": "match", - }, - "permissions": { - "public": {"value": "false", "option": "false"}, - "project": {"value": "true", "option": "true"}, - "private": {"value": "false", "option": "true"}, - }, - "export-cli": "True", - }, ) self.project = Project.objects.create_project(name="test-perm", owner=self.user, description="") - self.app_instance = AppInstance.objects.create( + subdomain = Subdomain.objects.create(subdomain="test_internal") + app_status = AppStatus.objects.create(status="Created") + self.app_instance = JupyterInstance.objects.create( access="public", owner=self.user, name="test_app_instance_public", app=self.app, project=self.project, + subdomain=subdomain, + app_status=app_status, ) def get_data(self, user=None): @@ -65,7 +52,7 @@ def test_user_can_delete_false(self): self.assertEqual(response.status_code, 302) - url = f"/projects/{self.project.slug}/apps/delete/" + f"{self.category.slug}/{self.app_instance.id}" + url = f"/projects/{self.project.slug}/apps/delete/" + f"{self.app_instance.app.slug}/{self.app_instance.id}" response = c.get(url) @@ -83,13 +70,13 @@ def test_user_can_delete_true(self): self.app.save() with patch("apps.tasks.delete_resource.delay") as mock_task: - url = f"/projects/{self.project.slug}/apps/delete/" + f"{self.category.slug}/{self.app_instance.id}" + url = f"/projects/{self.project.slug}/apps/delete/" + f"{self.app_instance.app.slug}/{self.app_instance.id}" response = c.get(url) self.assertEqual(response.status_code, 302) - self.app_instance = AppInstance.objects.get(name="test_app_instance_public") + self.app_instance = JupyterInstance.objects.get(name="test_app_instance_public") self.assertEqual("private", self.app_instance.access) diff --git a/apps/tests/test_forms.py b/apps/tests/test_forms.py new file mode 100644 index 000000000..f0ae6cfa7 --- /dev/null +++ b/apps/tests/test_forms.py @@ -0,0 +1,194 @@ +from django.contrib.auth import get_user_model +from django.template import Context, Template +from django.test import TestCase + +from apps.forms import CustomAppForm +from apps.models import Apps, AppStatus, Subdomain, VolumeInstance +from projects.models import Flavor, Project + +User = get_user_model() + +test_user = {"username": "foo1", "email": "foo@test.com", "password": "bar"} + + +class BaseAppFormTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(test_user["username"], test_user["email"], test_user["password"]) + self.project = Project.objects.create_project(name="test-perm", owner=self.user, description="") + self.app = Apps.objects.create(name="Custom App", slug="customapp") + self.volume = VolumeInstance.objects.create( + name="project-vol", + app=self.app, + owner=self.user, + project=self.project, + size=1, + subdomain=Subdomain.objects.create(subdomain="subdomain", project=self.project), + app_status=AppStatus.objects.create(status="Created"), + ) + self.flavor = Flavor.objects.create(name="flavor", project=self.project) + + +class CustomAppFormTest(BaseAppFormTest): + def setUp(self): + super().setUp() + self.valid_data = { + "name": "Valid Name", + "description": "A valid description", + "subdomain": "valid-subdomain", + "volume": self.volume, + "path": "/home/user", + "flavor": self.flavor, + "access": "public", + "source_code_url": "http://example.com", + "note_on_linkonly_privacy": None, + "port": 8000, + "image": "ghcr.io/scilifelabdatacentre/image:tag", + "tags": ["tag1", "tag2", "tag3"], + } + + def test_form_valid_data(self): + form = CustomAppForm(self.valid_data, project_pk=self.project.pk) + self.assertTrue(form.is_valid()) + + def test_form_missing_data(self): + invalid_data = self.valid_data.copy() + invalid_data.pop("name") + invalid_data.pop("port") + invalid_data.pop("image") + + form = CustomAppForm(invalid_data, project_pk=self.project.pk) + self.assertFalse(form.is_valid()) + self.assertIn("name", form.errors) + self.assertIn("port", form.errors) + self.assertIn("image", form.errors) + + def test_invalid_path(self): + invalid_data = self.valid_data.copy() + invalid_data["path"] = "/var" + + form = CustomAppForm(invalid_data, project_pk=self.project.pk) + self.assertFalse(form.is_valid()) + self.assertIn("Path must start with", str(form.errors)) + + def test_valid_path_if_set_in_admin_panel(self): + valid_data = self.valid_data.copy() + form = CustomAppForm(valid_data, project_pk=self.project.pk) + self.assertTrue(form.is_valid()) + + # Simulate a path set in the admin panel + instance = form.save(commit=False) + instance.project = self.project + instance.owner = self.user + instance.app_status = AppStatus.objects.create(status="Created") + instance.app = self.app + + # Fetch subdomain and set + subdomain_name, is_created_by_user = form.cleaned_data.get("subdomain") + subdomain, _ = Subdomain.objects.get_or_create( + subdomain=subdomain_name, project=self.project, is_created_by_user=is_created_by_user + ) + instance.subdomain = subdomain + + # Change the path to something that the form would not allow + instance.path = "/var" + instance.save() + + # Open form in "edit mode" by sending instance as argument + form = CustomAppForm(valid_data, project_pk=self.project.pk, instance=instance) + + # This should now work, since we set the path in the "admin panel" + self.assertTrue(form.is_valid()) + + def test_volume_no_path(self): + invalid_data = self.valid_data.copy() + invalid_data.pop("path") + + form = CustomAppForm(invalid_data, project_pk=self.project.pk) + self.assertFalse(form.is_valid()) + self.assertIn("Path is required when volume is selected.", str(form.errors)) + + def test_path_no_volume(self): + invalid_data = self.valid_data.copy() + invalid_data.pop("volume") + + form = CustomAppForm(invalid_data, project_pk=self.project.pk) + self.assertFalse(form.is_valid()) + self.assertIn("Warning, you have provided a path, but not selected a volume.", str(form.errors)) + + def test_invalid_subdomain(self): + invalid_data = self.valid_data.copy() + invalid_data["subdomain"] = "-some_invalid_subdomain!" + + form = CustomAppForm(invalid_data, project_pk=self.project.pk) + self.assertFalse(form.is_valid()) + self.assertIn("Subdomain must be 3-53 characters long", str(form.errors)) + + def test_source_url_enforced_when_public(self): + invalid_data = self.valid_data.copy() + invalid_data["source_code_url"] = "" + + form = CustomAppForm(invalid_data, project_pk=self.project.pk) + self.assertFalse(form.is_valid()) + + def test_link_only_note_enforced_when_link(self): + invalid_data = self.valid_data.copy() + invalid_data["access"] = "link" + + # Test no note + form = CustomAppForm(invalid_data, project_pk=self.project.pk) + self.assertFalse(form.is_valid()) + self.assertIn("Please, provide a reason for making the app accessible only via a link.", str(form.errors)) + + # Now add a note + valid_data = self.valid_data.copy() + valid_data["access"] = "link" + valid_data["note_on_linkonly_privacy"] = "A reason" + form = CustomAppForm(valid_data, project_pk=self.project.pk) + self.assertTrue(form.is_valid()) + + +class CustomAppFormRenderingTest(BaseAppFormTest): + def setUp(self): + super().setUp() + self.valid_data = { + "name": "Valid Name", + "description": "A valid description", + "subdomain": "valid-subdomain", + "volume": self.volume, + "path": "/home/user", + "flavor": self.flavor, + "access": "public", + "source_code_url": "http://example.com", + "note_on_linkonly_privacy": None, + "port": 8000, + "image": "ghcr.io/scilifelabdatacentre/image:tag", + "tags": ["tag1", "tag2", "tag3"], + } + + def test_form_rendering(self): + valid_data = self.valid_data.copy() + form = CustomAppForm(valid_data, project_pk=self.project.pk) + + template = Template("{% load crispy_forms_tags %}{% crispy form %}") + context = Context({"form": form}) + rendered_form = template.render(context) + for key, value in valid_data.items(): + if key == "tags": + value = "".join(tag for tag in key) + if key == "volume": + value = self.volume.name + if key == "flavor": + value = self.flavor.name + if key == "port": + value = str(value) + if value is None: + continue + + self.assertIn(value, rendered_form) + self.assertIn(f'name="{key}"', rendered_form) + self.assertIn(f'id="id_{key}"', rendered_form) + + self.assertIn('value="project"', rendered_form) + self.assertIn('value="private"', rendered_form) + self.assertIn('value="link"', rendered_form) + self.assertIn('value="public"', rendered_form) diff --git a/apps/tests/test_generate_form.py b/apps/tests/test_generate_form.py deleted file mode 100644 index eadd19bcf..000000000 --- a/apps/tests/test_generate_form.py +++ /dev/null @@ -1,305 +0,0 @@ -from copy import deepcopy - -from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings - -from projects.models import Environment, Project - -from ..generate_form import get_form_environments, get_form_primitives -from ..models import AppInstance, Apps - -User = get_user_model() - - -class GenerateFormTestCase(TestCase): - def setUp(self) -> None: - self.app_settings_pvc = { - "volume": { - "size": { - "type": "select", - "title": "Size", - "default": "1Gi", - "user_can_edit": False, - "items": [ - {"name": "1Gi", "value": "1Gi"}, - {"name": "2Gi", "value": "2Gi"}, - {"name": "5Gi", "value": "5Gi"}, - ], - }, - "accessModes": { - "type": "string", - "title": "AccessModes", - "default": "ReadWriteMany", - }, - "storageClass": { - "type": "string", - "title": "StorageClass", - "default": "", - }, - }, - "permissions": { - "public": {"value": "false", "option": "false"}, - "private": {"value": "false", "option": "true"}, - "project": {"value": "true", "option": "true"}, - }, - "default_values": {"port": "port", "targetport": "targetport"}, - "environment": { - "name": "from", - "type": "match", - "title": "Image", - "quantity": "one", - }, - } - self.user = User.objects.create_user("foo1", "foo@test.com", "bar") - - self.project = Project.objects.create_project(name="test-perm-generate_form", owner=self.user, description="") - self.app = Apps.objects.create(name="Persistent Volume", slug="volumeK8s") - super().setUp() - - # primatives - - @override_settings(DISABLED_APP_INSTANCE_FIELDS=[]) - def test_get_form_primitives_should_return_complete(self): - app_settings = deepcopy(self.app_settings_pvc) - - result = get_form_primitives(app_settings, None) - - result_items = result["volume"] - result_keys = result_items.keys() - expected_items = self.app_settings_pvc["volume"].items() - - result_length = len(result_items) - expected_length = len(expected_items) + 1 - - self.assertEqual(result_length, expected_length) - - for key, val in expected_items: - is_in_keys = key in result_keys - - self.assertTrue(is_in_keys) - - is_string = isinstance(val, str) - - if is_string: - continue - - result_item = result_items[key] - - for _key, _val in val.items(): - result_val = result_item[_key] - self.assertEqual(result_val, _val) - - @override_settings(DISABLED_APP_INSTANCE_FIELDS=["accessModes", "storageClass"]) - def test_get_form_primitives_should_remove_two(self): - app_settings = deepcopy(self.app_settings_pvc) - - result = get_form_primitives(app_settings, None) - - result_items = result["volume"] - result_keys = result_items.keys() - - result_length = len(result_items) - expected_length = 2 - - self.assertEqual(result_length, expected_length) - - has_expected_keys = "meta_title" in result_keys and "size" in result_keys - - self.assertTrue(has_expected_keys) - - @override_settings( - DISABLED_APP_INSTANCE_FIELDS=[ - "accessModes", - "storageClass", - "madeUpValue", - ] - ) - def test_get_form_primitives_field_not_in_model(self): - app_settings = deepcopy(self.app_settings_pvc) - - result = get_form_primitives(app_settings, None) - - result_items = result["volume"] - result_keys = result_items.keys() - - result_length = len(result_items) - expected_length = 2 - - self.assertEqual(result_length, expected_length) - - has_expected_keys = "meta_title" in result_keys and "size" in result_keys - - self.assertTrue(has_expected_keys) - - @override_settings(DISABLED_APP_INSTANCE_FIELDS=[]) - def test_get_form_primitives_should_set_default(self): - app_settings = deepcopy(self.app_settings_pvc) - - app_instance = AppInstance( - name="My app", - access="private", - app=self.app, - project=self.project, - parameters={ - "volume": { - "size": "5Gi", - "accessModes": "ReadWriteMany", - "storageClass": "microk8s-hostpath", - }, - }, - owner=self.user, - ) - app_instance.save() - - result = get_form_primitives(app_settings, app_instance) - - result_items = result["volume"] - - result_size = result_items["size"]["default"] - result_size_user_can_edit = result_items["size"]["user_can_edit"] - result_access_modes = result_items["accessModes"]["default"] - result_storage_class = result_items["storageClass"]["default"] - - self.assertEqual(result_size, "5Gi") - self.assertFalse(result_size_user_can_edit) - self.assertEqual(result_access_modes, "ReadWriteMany") - self.assertEqual(result_storage_class, "microk8s-hostpath") - - app_instance.parameters = {} - - app_instance.save() - - app_settings_default = deepcopy(self.app_settings_pvc) - - app_instance = AppInstance.objects.get(name="My app") - - result = get_form_primitives(app_settings_default, app_instance) - - result_items = result["volume"] - - result_size = result_items["size"]["default"] - result_access_modes = result_items["accessModes"]["default"] - result_storage_class = result_items["storageClass"]["default"] - - self.assertEqual(result_size, "1Gi") - self.assertEqual(result_access_modes, "ReadWriteMany") - self.assertEqual(result_storage_class, "") - - # environments - - def test_get_form_environments_single(self): - environment = Environment( - app=self.app, - project=self.project, - name="test", - slug="test", - repository="test-repo", - image="test-image", - ) - - environment.save() - app_settings = deepcopy(self.app_settings_pvc) - - result = get_form_environments(app_settings, self.project, self.app, None) - - dep_environment, environments = result - - self.assertEqual(dep_environment, True) - - objs = environments["objs"] - - self.assertEqual(len(objs), 1) - - result_item = objs[0] - - self.assertEqual(result_item.name, "test") - self.assertEqual(result_item.slug, "test") - - def test_get_form_environments_with_public(self): - environment = Environment( - app=self.app, - project=self.project, - name="test1", - slug="test1", - repository="test1-repo", - image="test1-image", - ) - - environment.save() - - environment2 = Environment( - app=self.app, - name="test2", - slug="test2", - repository="test2-repo", - image="test2-image", - public=True, - ) - - environment2.save() - - app_settings = deepcopy(self.app_settings_pvc) - - result = get_form_environments(app_settings, self.project, self.app, None) - - dep_environment, environments = result - - self.assertEqual(dep_environment, True) - - objs = environments["objs"] - - self.assertEqual(len(objs), 2) - - number_of_public = 0 - - for obj in objs: - self.assertIn(obj.name, ["test1", "test2"]) - self.assertIn(obj.slug, ["test1", "test2"]) - - if obj.public: - number_of_public += 1 - - self.assertEqual(number_of_public, 1) - - def test_get_form_environments_with_public_and_other_projects(self): - project = Project.objects.create_project(name="test-perm-generate_form2", owner=self.user, description="") - - environment = Environment( - app=self.app, - project=project, - name="test1", - slug="test1", - repository="test1-repo", - image="test1-image", - ) - - environment.save() - - environment2 = Environment( - app=self.app, - name="test2", - slug="test2", - repository="test2-repo", - image="test2-image", - public=True, - ) - - environment2.save() - - app_settings = deepcopy(self.app_settings_pvc) - - result = get_form_environments(app_settings, self.project, self.app, None) - - dep_environment, environments = result - - self.assertEqual(dep_environment, True) - - objs = environments["objs"] - - self.assertEqual(len(objs), 1) - - result_item = objs[0] - - self.assertEqual(result_item.name, "test2") - self.assertEqual(result_item.slug, "test2") - self.assertTrue(result_item.public) diff --git a/apps/tests/test_get_status_view.py b/apps/tests/test_get_status_view.py index 4130724bb..ae5c06802 100644 --- a/apps/tests/test_get_status_view.py +++ b/apps/tests/test_get_status_view.py @@ -3,7 +3,7 @@ from projects.models import Project -from ..models import AppCategories, AppInstance, Apps +from ..models import AppCategories, Apps, AppStatus, JupyterInstance, Subdomain User = get_user_model() @@ -23,15 +23,16 @@ def setUp(self) -> None: self.project = Project.objects.create_project(name="test-perm-get_status", owner=self.user, description="") - self.app_instance = AppInstance.objects.create( + subdomain = Subdomain.objects.create(subdomain="test_internal") + app_status = AppStatus.objects.create(status="Created") + self.app_instance = JupyterInstance.objects.create( access="public", owner=self.user, name="test_app_instance_public", app=self.app, project=self.project, - parameters={ - "environment": {"pk": ""}, - }, + subdomain=subdomain, + app_status=app_status, ) def test_user_has_access(self): diff --git a/apps/tests/test_subdomain_candidate.py b/apps/tests/test_subdomain_candidate.py new file mode 100644 index 000000000..748cd91a6 --- /dev/null +++ b/apps/tests/test_subdomain_candidate.py @@ -0,0 +1,63 @@ +import pytest +from django.core.exceptions import ValidationError + +from ..types_.subdomain import SubdomainCandidateName + + +@pytest.mark.django_db +def test_is_available_for_available_subdomains(): + """Tests for available subdomains""" + candidate = SubdomainCandidateName("test-name-is-unique", 1) + assert candidate.is_available() + + candidate = SubdomainCandidateName("test-name-999-is-unique", 1) + assert candidate.is_available() + + +def test_is_available_for_unavailable_subdomains(): + """Tests for unavailable subdomains""" + + # Test using the reserved word serve + candidate = SubdomainCandidateName("serve", 1) + assert not candidate.is_available() + + +@pytest.mark.parametrize("name", [("test-with-hyphens"), ("0n9"), ("z-a"), ("01234567890")]) +def test_is_valid_for_valid_names(name): + """Tests for valid subdomain names""" + candidate = SubdomainCandidateName(name, 1) + assert candidate.is_valid() + + +@pytest.mark.parametrize("name", [("a"), ("Test-Uppercase"), ("-test-starthyphen"), ("test-endhyphen-")]) +def test_is_valid_for_invalid_names(name): + """Tests for invalid subdomain names""" + candidate = SubdomainCandidateName(name, 1) + assert not candidate.is_valid() + + +def test_validate_subdomain(): + """ + Tests validity of subdomain names. + Expects a raised ValidationError exception for invalid names. + """ + + # Test with valid subdomain + + candidate = SubdomainCandidateName("test-subdomain", 1) + candidate.validate_subdomain() + + # Test with invalid subdomain (too short) + candidate = SubdomainCandidateName("a", 1) + with pytest.raises(ValidationError): + candidate.validate_subdomain() + + # Test with invalid subdomain (contains uppercase) + candidate = SubdomainCandidateName("Test-Subdomain", 1) + with pytest.raises(ValidationError): + candidate.validate_subdomain() + + # Test with invalid subdomain (starts with hyphen) + candidate = SubdomainCandidateName("-test-subdomain", 1) + with pytest.raises(ValidationError): + candidate.validate_subdomain() diff --git a/apps/tests/test_update_status_handler.py b/apps/tests/test_update_status_handler.py index eaee26c4a..3bd81fed2 100644 --- a/apps/tests/test_update_status_handler.py +++ b/apps/tests/test_update_status_handler.py @@ -11,7 +11,7 @@ from projects.models import Project from ..helpers import HandleUpdateStatusResponseCode, handle_update_status_request -from ..models import AppCategories, AppInstance, Apps, AppStatus +from ..models import AppCategories, Apps, AppStatus, JupyterInstance, Subdomain utc = pytz.UTC @@ -49,77 +49,79 @@ def setUp(self) -> None: self.project = Project.objects.create_project(name="test-perm-get_status", owner=self.user, description="") - self.app_instance = AppInstance.objects.create( - access="public", + subdomain = Subdomain.objects.create(subdomain=self.ACTUAL_RELEASE_NAME) + self.app_instance = JupyterInstance.objects.create( + access="private", owner=self.user, - name="test_app_instance_public", + name="test_app_instance_private", app=self.app, project=self.project, - parameters={ + subdomain=subdomain, + k8s_values={ "environment": {"pk": ""}, "release": self.ACTUAL_RELEASE_NAME, }, - state=self.INITIAL_STATUS, ) + print(f"######## {self.INITIAL_EVENT_TS}") def setUpCreateAppStatus(self): - self.status_object = AppStatus(appinstance=self.app_instance) - self.status_object.status_type = self.INITIAL_STATUS - self.status_object.save() - # Must re-save with the desired timeUpdate the app instance object - self.status_object.time = self.INITIAL_EVENT_TS - self.status_object.save(update_fields=["time"]) + app_status = AppStatus.objects.create(status=self.INITIAL_STATUS) + app_status.time = self.INITIAL_EVENT_TS + app_status.save() + self.app_instance.app_status = app_status + self.app_instance.save(update_fields=["app_status"]) + print(f"######## {app_status.time}") def test_handle_old_event_time_should_ignore_update(self): self.setUpCreateAppStatus() - older_ts = self.INITIAL_EVENT_TS - timedelta(seconds=1) + older_ts = self.app_instance.app_status.time - timedelta(seconds=1) actual = handle_update_status_request(self.ACTUAL_RELEASE_NAME, "NewStatus", older_ts) assert actual == HandleUpdateStatusResponseCode.NO_ACTION # Fetch the app instance and status objects and verify values - actual_app_instance = AppInstance.objects.filter( - parameters__contains={"release": self.ACTUAL_RELEASE_NAME} + actual_app_instance = JupyterInstance.objects.filter( + k8s_values__contains={"release": self.ACTUAL_RELEASE_NAME} ).last() - assert actual_app_instance.state == self.INITIAL_STATUS - actual_appstatus = actual_app_instance.status.latest() - assert actual_appstatus.status_type == self.INITIAL_STATUS + assert actual_app_instance.app_status.status == self.INITIAL_STATUS + actual_appstatus = actual_app_instance.app_status + assert actual_appstatus.status == self.INITIAL_STATUS assert actual_appstatus.time == self.INITIAL_EVENT_TS def test_handle_same_status_newer_time_should_update_time(self): self.setUpCreateAppStatus() - newer_ts = self.INITIAL_EVENT_TS + timedelta(seconds=1) + newer_ts = self.app_instance.app_status.time + timedelta(seconds=1) actual = handle_update_status_request(self.ACTUAL_RELEASE_NAME, self.INITIAL_STATUS, newer_ts) assert actual == HandleUpdateStatusResponseCode.UPDATED_TIME_OF_STATUS # Fetch the app instance and status objects and verify values - actual_app_instance = AppInstance.objects.filter( - parameters__contains={"release": self.ACTUAL_RELEASE_NAME} + actual_app_instance = JupyterInstance.objects.filter( + k8s_values__contains={"release": self.ACTUAL_RELEASE_NAME} ).last() - assert actual_app_instance.state == self.INITIAL_STATUS - actual_appstatus = actual_app_instance.status.latest() - assert actual_appstatus.status_type == self.INITIAL_STATUS + assert actual_app_instance.app_status.status == self.INITIAL_STATUS + actual_appstatus = actual_app_instance.app_status + assert actual_appstatus.status == self.INITIAL_STATUS assert actual_appstatus.time == newer_ts def test_handle_different_status_newer_time_should_update_status(self): self.setUpCreateAppStatus() - newer_ts = self.INITIAL_EVENT_TS + timedelta(seconds=1) + newer_ts = self.app_instance.app_status.time + timedelta(seconds=1) new_status = self.INITIAL_STATUS + "-test01" actual = handle_update_status_request(self.ACTUAL_RELEASE_NAME, new_status, newer_ts) assert actual == HandleUpdateStatusResponseCode.UPDATED_STATUS # Fetch the app instance and status objects and verify values - actual_app_instance = AppInstance.objects.filter( - parameters__contains={"release": self.ACTUAL_RELEASE_NAME} + actual_app_instance = JupyterInstance.objects.filter( + k8s_values__contains={"release": self.ACTUAL_RELEASE_NAME} ).last() - assert actual_app_instance.state == new_status - actual_appstatus = actual_app_instance.status.latest() - assert actual_appstatus.status_type == new_status + assert actual_app_instance.app_status.status == new_status + actual_appstatus = actual_app_instance.app_status + assert actual_appstatus.status == new_status assert actual_appstatus.time == newer_ts def test_handle_missing_app_status_should_create_and_update_status(self): @@ -130,13 +132,13 @@ def test_handle_missing_app_status_should_create_and_update_status(self): assert actual == HandleUpdateStatusResponseCode.CREATED_FIRST_STATUS # Fetch the app instance and status objects and verify values - actual_app_instance = AppInstance.objects.filter( - parameters__contains={"release": self.ACTUAL_RELEASE_NAME} + actual_app_instance = JupyterInstance.objects.filter( + k8s_values__contains={"release": self.ACTUAL_RELEASE_NAME} ).last() - assert actual_app_instance.state == new_status - actual_appstatus = actual_app_instance.status.latest() - assert actual_appstatus.status_type == new_status + assert actual_app_instance.app_status.status == new_status + actual_appstatus = actual_app_instance.app_status + assert actual_appstatus.status == new_status assert actual_appstatus.time == newer_ts def test_handle_long_status_text_should_trim_status(self): @@ -149,16 +151,18 @@ def test_handle_long_status_text_should_trim_status(self): assert actual == HandleUpdateStatusResponseCode.CREATED_FIRST_STATUS # Fetch the app instance and status objects and verify values - actual_app_instance = AppInstance.objects.filter( - parameters__contains={"release": self.ACTUAL_RELEASE_NAME} + actual_app_instance = JupyterInstance.objects.filter( + k8s_values__contains={"release": self.ACTUAL_RELEASE_NAME} ).last() - assert actual_app_instance.state == expected_status_text - actual_appstatus = actual_app_instance.status.latest() - assert actual_appstatus.status_type == expected_status_text + assert actual_app_instance.app_status.status == expected_status_text + actual_appstatus = actual_app_instance.app_status + assert actual_appstatus.status == expected_status_text assert actual_appstatus.time == newer_ts +''' +#TODO: THIS TEST NEEDS TO BE UPDATED TO ADHERE TO NEW LOGIC @pytest.mark.skip( reason="This test requires a modification to the handle_update_status_request function to add a delay parameter." ) @@ -269,3 +273,4 @@ def submit_request_new_status(self): new_status = "StatusB" actual = handle_update_status_request(self.ACTUAL_RELEASE_NAME, new_status, newer_ts) return "submit_request_new_status", actual +''' diff --git a/collections_module/__init__.py b/apps/types_/__init__.py similarity index 100% rename from collections_module/__init__.py rename to apps/types_/__init__.py diff --git a/apps/types_/app_registry.py b/apps/types_/app_registry.py new file mode 100644 index 000000000..07555e178 --- /dev/null +++ b/apps/types_/app_registry.py @@ -0,0 +1,35 @@ +from apps.types_.app_types import ModelFormTuple, OptionalModelFormTuple + + +class AppRegistry: + def __init__(self): + self._apps: dict[str, ModelFormTuple] = {} + + def register(self, app_slug: str, app: ModelFormTuple): + self._apps[app_slug] = app + + def __getitem__(self, app_slug: str) -> OptionalModelFormTuple: + return self._apps.get(app_slug, (None, None)) + + def get(self, app_slug: str) -> OptionalModelFormTuple: + return self[app_slug] + + def get_apps(self): + return self._apps + + def get_orm_model(self, app_slug: str): + return self[app_slug][0] + + def get_form_class(self, app_slug: str): + return self[app_slug][1] + + def iter_orm_models(self): + for app in self._apps.values(): + yield app.Model + + def iter_forms(self): + for app in self._apps.values(): + yield app.Form + + def __contains__(self, item): + return item in self._apps diff --git a/apps/types_/app_types.py b/apps/types_/app_types.py new file mode 100644 index 000000000..186f98dad --- /dev/null +++ b/apps/types_/app_types.py @@ -0,0 +1,14 @@ +from typing import NamedTuple, Type, Union + +from apps.forms import BaseForm +from apps.models import BaseAppInstance + + +class ModelFormTuple(NamedTuple): + Model: Type[BaseAppInstance] + Form: Type[BaseForm] + + +NoneTuple = tuple[None, None] + +OptionalModelFormTuple = Union[ModelFormTuple, NoneTuple] diff --git a/apps/types_/subdomain.py b/apps/types_/subdomain.py new file mode 100644 index 000000000..8a9a22e22 --- /dev/null +++ b/apps/types_/subdomain.py @@ -0,0 +1,66 @@ +from typing import NamedTuple + +from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator + +from apps.models import BaseAppInstance, Subdomain + + +class SubdomainCandidateName: + """ + A candidate subdomain name that may or may not be available or allowed. + """ + + __name = None + + def __init__(self, name: str, project_id: int): + self.__name = name + self.__project_id = project_id + + def is_available(self) -> bool: + """Determines if the candidate name is available in Serve.""" + if self.__name == "serve": + # Reserved words + return False + elif Subdomain.objects.filter(subdomain=self.__name).exists(): + if str(Subdomain.objects.get(subdomain=self.__name).project_id) == str(self.__project_id): + if BaseAppInstance.objects.filter(subdomain__subdomain=self.__name).exists(): + return False + else: + return True + else: + return False + else: + return True + + def is_valid(self) -> bool: + """Determines if the candidate name is valid.""" + try: + self.validate_subdomain() + return True + except ValidationError: + return False + + def validate_subdomain(self): + """ + Validates a subdomain text. + The RegexValidator will raise a ValidationError if the input does not match the regular expression. + It is up to the caller to handle the raised exception if desired. + """ + + # Check if the subdomain adheres to helm rules + regex_validator = RegexValidator( + regex=r"^(?!-)[a-z0-9-]{3,53}(? str: + return self.subdomain diff --git a/apps/urls.py b/apps/urls.py index 9936f60c1..4b0448f93 100644 --- a/apps/urls.py +++ b/apps/urls.py @@ -1,24 +1,14 @@ from django.urls import path -from apps.views import AppSettingsView, CreateServeView, CreateView - from . import views app_name = "apps" urlpatterns = [ - path("apps/", views.index, name="index"), path("status", views.GetStatusView.as_view(), name="get_status"), - path("logs", views.GetLogsView.as_view(), name="get_logs"), - path("", views.FilteredView.as_view(), name="filtered"), - path("create/", CreateView.as_view(), name="create"), - path("create//create_releasename", views.create_releasename, name="create_releasename"), - path("serve//", CreateServeView.as_view(), name="serve"), - path("logs/", views.GetLogsView.as_view(), name="logs"), - path("settings/", AppSettingsView.as_view(), name="appsettings"), - path("settings//add_tag", views.add_tag, name="add_tag"), - path("settings//remove_tag", views.remove_tag, name="remove_tag"), - path("delete//", views.delete, name="delete"), - path("publish//", views.publish, name="publish"), - path("unpublish//", views.unpublish, name="unpublish"), + path("logs", views.GetLogs.as_view(), name="get_logs"), + path("logs//", views.GetLogs.as_view(), name="logs"), + path("create/", views.CreateApp.as_view(), name="create"), + path("settings//", views.CreateApp.as_view(), name="appsettings"), + path("delete//", views.delete, name="delete"), ] diff --git a/apps/views.py b/apps/views.py index d822eb441..e3c500bee 100644 --- a/apps/views.py +++ b/apps/views.py @@ -1,33 +1,25 @@ -import re from datetime import datetime import requests -from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model -from django.db.models import Q +from django.core.exceptions import PermissionDenied +from django.db import transaction from django.http import HttpResponseForbidden, JsonResponse from django.shortcuts import HttpResponseRedirect, render, reverse from django.utils.decorators import method_decorator from django.views import View from guardian.decorators import permission_required_or_403 -from projects.models import Flavor -from studio.settings import DOMAIN +from projects.models import Project from studio.utils import get_logger -from .generate_form import generate_form -from .helpers import can_access_app_instances, create_app_instance, handle_permissions -from .models import AppCategories, AppInstance, Apps -from .serialize import serialize_app -from .tasks import delete_and_deploy_resource, delete_resource, deploy_resource +from .app_registry import APP_REGISTRY +from .helpers import create_instance_from_form +from .tasks import delete_resource logger = get_logger(__name__) - -Project = apps.get_model(app_label=settings.PROJECTS_MODEL) -ReleaseName = apps.get_model(app_label=settings.RELEASENAME_MODEL) - User = get_user_model() @@ -37,122 +29,103 @@ def get_status_defs(): return status_success, status_warning -# Create your views here. -@permission_required_or_403("can_view_project", (Project, "slug", "project")) -def index(request, user, project): - category = "store" - template = "index_apps.html" - - cat_obj = AppCategories.objects.get(slug=category) - apps = Apps.objects.filter(category=cat_obj) - project = Project.objects.get(slug=project) - appinstances = AppInstance.objects.filter( - Q(owner=request.user) | Q(permission__projects__slug=project.slug) | Q(permission__public=True), - app__category=cat_obj, - ) - - return render(request, template, locals()) - - @method_decorator( permission_required_or_403("can_view_project", (Project, "slug", "project")), name="dispatch", ) -class GetLogsView(View): - def get(self, request, project, ai_id): - template = "apps/logs.html" - app = AppInstance.objects.get(pk=ai_id) - project = Project.objects.get(slug=project) - return render(request, template, locals()) +class GetLogs(View): + template = "apps/logs.html" - def post(self, request, project): - body = request.POST.get("app", "") - container = request.POST.get("container", "") - app = AppInstance.objects.get(pk=body) - project = Project.objects.get(slug=project) - app_settings = app.app.settings - logs = [] - # Looks for logs in app settings. TODO: this logs entry is not used. Remove or change this later. - if "logs" in app_settings: - try: - url = settings.LOKI_SVC + "/loki/api/v1/query_range" - app_params = app.parameters - if app.app.slug == "shinyproxyapp": - log_query = '{release="' + app_params["release"] + '",container="' + "serve" + '"}' - else: - log_query = '{release="' + app_params["release"] + '",container="' + container + '"}' - logger.info(log_query) - query = { - "query": log_query, - "limit": 500, - "since": "24h", - } - res = requests.get(url, params=query) - res_json = res.json()["data"]["result"] - # TODO: change timestamp logic. Timestamps are different in prod and dev - for item in res_json: - for log_line in reversed(item["values"]): - # separate timestamp and log - separated_log = log_line[1].split(None, 1) - # improve timestamp formatting for table - filtered_log = separated_log[0][:-4] if settings.DEBUG else separated_log[0][:-10] - formatted_time = datetime.strptime(filtered_log, "%Y-%m-%dT%H:%M:%S.%f") - separated_log[0] = datetime.strftime(formatted_time, "%Y-%m-%d, %H:%M:%S") - logs.append(separated_log) - - except Exception as e: - logger.error(str(e), exc_info=True) - - return JsonResponse({"data": logs}) - - -@method_decorator( - permission_required_or_403("can_view_project", (Project, "slug", "project")), - name="dispatch", -) -class FilteredView(View): - template_name = "apps/new.html" - - def get(self, request, user, project, category): - project = Project.objects.get(slug=project) + def get_instance(self, app_slug, app_id, post=False): + model_class = APP_REGISTRY.get_orm_model(app_slug) + if model_class: + return model_class.objects.get(pk=app_id) + else: + message = f"Could not find model for slug {app_slug}" + if post: + return JsonResponse({"error": message}, status=404) + else: + logger.error(message) + raise PermissionDenied() + + def get_project(self, project_slug, post=False): + try: + project = Project.objects.get(slug=project_slug) + return project + except Project.DoesNotExist: + message = "error: Project not found" + if post: + return JsonResponse({"error": message}, status=404) + else: + logger.error(message) + raise PermissionDenied() - def filter_func(): - filter = AppInstance.objects.get_app_instances_of_project_filter( - user=request.user, project=project, deleted_time_delta=5 - ) + def get(self, request, project, app_slug, app_id): + project = self.get_project(project) + instance = self.get_instance(app_slug, app_id) - filter &= Q(app__category__slug=category) + context = {"instance": instance, "project": project} + return render(request, self.template, context) - return filter + def post(self, request, project, app_slug, app_id): + # Validate project and instance existence + project = self.get_project(project, post=True) + instance = self.get_instance(app_slug, app_id, post=True) - app_instances_of_category = AppInstance.objects.filter(filter_func()).order_by("-created_on") + # container name is often same as subdomain name + container = instance.subdomain.subdomain - app_ids = [obj.id for obj in app_instances_of_category] + if not getattr(instance, "logs_enabled", False): + return JsonResponse({"error": "Logs not enabled for this instance"}, status=403) - apps_of_category = ( - Apps.objects.filter(category=category, user_can_create=True).order_by("slug", "-revision").distinct("slug") - ) + if not settings.LOKI_SVC: + return JsonResponse({"error": "LOKI_SVC not set"}, status=403) - def category2title(cat): - if cat == "compute": - return "Notebooks" - else: - return cat.capitalize() + logs = [] + try: + url = settings.LOKI_SVC + "/loki/api/v1/query_range" + container = "serve" if instance.app.slug == "shinyproxyapp" else container + log_query = f'{{release="{instance.subdomain.subdomain}",container="{container}"}}' + logger.info(f"Log query: {log_query}") + + query_params = { + "query": log_query, + "limit": 500, + "since": "24h", + } - context = { - "apps": apps_of_category, - "appinstances": app_instances_of_category, - "app_ids": app_ids, - "project": project, - "category": category, - "title": category2title(category), - } + res = requests.get(url, params=query_params) + res.raise_for_status() # Raise an HTTPError for bad responses (4xx and 5xx) + + res_json = res.json().get("data", {}).get("result", []) + + for item in res_json: + for log_line in reversed(item["values"]): + # Separate timestamp and log message + timestamp, log_message = log_line[0], log_line[1] + if len(log_message) < 2: + continue # Skip log lines that do not have a message + + # Parse and format the timestamp + try: + formatted_time = datetime.fromtimestamp(int(timestamp) / 1e9).strftime("%Y-%m-%d %H:%M:%S") + logs.append([formatted_time, log_message]) + except ValueError as ve: + logger.warning(f"Timestamp parsing failed: {ve}") + logs.append(["-", log_message]) + continue + + except requests.RequestException as e: + logger.error(f"HTTP request failed: {e}", exc_info=True) + return JsonResponse({"error": "Failed to retrieve logs from Loki"}, status=500) + except KeyError as e: + logger.error(f"Unexpected response format: {e}", exc_info=True) + return JsonResponse({"error": "Unexpected response format from Loki"}, status=500) + except Exception as e: + logger.error(f"An unexpected error occurred: {e}", exc_info=True) + return JsonResponse({"error": "An unexpected error occurred"}, status=500) - return render( - request=request, - context=context, - template_name=self.template_name, - ) + return JsonResponse({"data": logs}) @method_decorator( @@ -169,524 +142,140 @@ def post(self, request, project): arr = body.split(",") status_success, status_warning = get_status_defs() - app_instances = AppInstance.objects.filter(pk__in=arr) + for orm_model in APP_REGISTRY.iter_orm_models(): + instances = orm_model.objects.filter(pk__in=arr) - for instance in app_instances: - try: - status = instance.status.latest().status_type - except: # noqa E722 TODO: Add exception - status = instance.state + for instance in instances: + status_object = instance.app_status + if status_object: + status = status_object.status + else: + status = None - status_group = ( - "success" if status in status_success else "warning" if status in status_warning else "danger" - ) + status_group = ( + "success" if status in status_success else "warning" if status in status_warning else "danger" + ) - obj = { - "status": status, - "statusGroup": status_group, - } + obj = { + "status": status, + "statusGroup": status_group, + } - result[f"{instance.pk}"] = obj + result[f"{instance.app.slug}-{instance.pk}"] = obj return JsonResponse(result) return JsonResponse(result) -@method_decorator( - permission_required_or_403("can_view_project", (Project, "slug", "project")), - name="dispatch", -) -class AppSettingsView(View): - def get_shared_data(self, project_slug, ai_id): - project = Project.objects.get(slug=project_slug) - appinstance = AppInstance.objects.get(pk=ai_id) - - return [project, appinstance] - - def get(self, request, project, ai_id): - project, appinstance = self.get_shared_data(project, ai_id) - all_tags = AppInstance.tags.tag_model.objects.all() - template = "apps/update.html" - - existing_app_name = appinstance.name - existing_app_description = appinstance.description - existing_app_release_name = appinstance.parameters.get("release", None) - existing_userid = None - existing_path = None - existing_source_code_url = appinstance.source_code_url - existing_proxyheartbeatrate = None - existing_proxyheartbeattimeout = None - existing_proxycontainerwaittime = None - - # Settings for custom app and shinyproxy - if "appconfig" in appinstance.parameters: - appconfig = appinstance.parameters["appconfig"] - existing_userid = appconfig.get("userid", None) - if "path" in appconfig: - # check if app created by admin user then don't show path change option to normal user - created_by_admin = appinstance.parameters.get("created_by_admin") is True - existing_path = appconfig["path"] - - if not created_by_admin: - existing_path = existing_path.replace("/home/", "", 1) - existing_proxyheartbeatrate = appconfig.get("proxyheartbeatrate", None) - existing_proxyheartbeattimeout = appconfig.get("proxyheartbeattimeout", None) - existing_proxycontainerwaittime = appconfig.get("proxycontainerwaittime", None) - - app = appinstance.app - do_display_description_field = app.category.name is not None and app.category.name.lower() == "serve" - - if not app.user_can_edit: - return HttpResponseForbidden() - - app_settings = appinstance.app.settings - form = generate_form(app_settings, project, app, request.user, appinstance) - - # This handles the volume cases. If the app is mounted, then that volume should be pre-selected and vice-versa. - # Note that this assumes only ONE volume per app. - current_volumes = appinstance.parameters.get("apps", {}).get("volumeK8s", {}).keys() - current_volume = AppInstance.objects.filter(project=project, name__in=current_volumes).first() - - def filter_func(): - name = current_volume.name if current_volume else None - return Q(app__name="Persistent Volume") & ~Q(state="Deleted") & ~Q(name=name) - - available_volumes = AppInstance.objects.get_app_instances_of_project( - user=request.user, - project=project, - filter_func=filter_func(), - ) - - show_permissions = request.user.id == appinstance.owner.id or request.user.is_superuser - - context = { - "app": app, - "do_display_description_field": do_display_description_field, - "form": form, - "current_volume": current_volume, - "available_volumes": available_volumes, - "appinstance": appinstance, - "project": project, - "domain": DOMAIN, - "all_tags": all_tags, - "show_permissions": show_permissions, - "existing_app_name": existing_app_name, - "existing_path": existing_path, - "existing_app_description": existing_app_description, - "existing_app_release_name": existing_app_release_name, - "existing_userid": existing_userid, - "existing_source_code_url": existing_source_code_url, - "existing_proxyheartbeatrate": existing_proxyheartbeatrate, - "existing_proxyheartbeattimeout": existing_proxyheartbeattimeout, - "existing_proxycontainerwaittime": existing_proxycontainerwaittime, - } - - return render(request, template, context) - - def post(self, request, project, ai_id): - project, appinstance = self.get_shared_data(project, ai_id) - - app = appinstance.app - app_settings = app.settings - body = request.POST.copy() - - if not app.user_can_edit: - return HttpResponseForbidden() - - self.update_app_instance(request, project, appinstance, app_settings, body) - - return HttpResponseRedirect( - reverse( - "projects:details", - kwargs={ - "project_slug": str(project.slug), - }, - ) - ) - - def update_app_instance(self, request, project, appinstance, app_settings, body): - if not body.get("permission", None): - body.update({"permission": appinstance.access}) - current_release_name = appinstance.parameters["release"] - parameters, app_deps, model_deps = serialize_app(body, project, app_settings, request.user.username) - - authorized = can_access_app_instances(app_deps, request.user, project) - - if not authorized: - raise Exception("Not authorized to use specified app dependency") - - access = handle_permissions(parameters, project) - - flavor_id = request.POST.get("flavor") - appinstance.flavor = Flavor.objects.get(pk=flavor_id, project=project) - - appinstance.name = request.POST.get("app_name") - appinstance.description = request.POST.get("app_description") - appinstance.note_on_linkonly_privacy = body.get("link_privacy_type_note", "") - if "appconfig" in appinstance.parameters: - created_by_admin = False # default created by admin - userid = "1000" # default userid - if "path" in appinstance.parameters["appconfig"]: - # check if app created by admin user then don't show path change option to normal user - if "created_by_admin" in appinstance.parameters: - if appinstance.parameters["created_by_admin"] is True: - created_by_admin = True - existing_path = appinstance.parameters["appconfig"]["path"] - if "userid" in appinstance.parameters["appconfig"]: - userid = appinstance.parameters["appconfig"]["userid"] - appinstance.parameters.update(parameters) - appinstance.access = access - appinstance.app_dependencies.set(app_deps) - appinstance.model_dependencies.set(model_deps) - appinstance.source_code_url = request.POST.get("source_code_url") - if "appconfig" in appinstance.parameters and appinstance.app.slug == "customapp": - # remove trailing / in all cases - if appinstance.parameters["appconfig"]["path"] != "/": - appinstance.parameters["appconfig"]["path"] = appinstance.parameters["appconfig"]["path"].rstrip("/") - appinstance.parameters["created_by_admin"] = created_by_admin - # if app is created by admin but admin user is not updating it dont change path. - if created_by_admin: - if not request.user.is_superuser: - appinstance.parameters["appconfig"]["userid"] = userid - appinstance.parameters["appconfig"]["path"] = existing_path - else: - appinstance.parameters["appconfig"]["path"] = "/home/" + appinstance.parameters["appconfig"]["path"] - if not request.user.is_superuser: - appinstance.parameters["appconfig"]["userid"] = userid - - appinstance.save( - update_fields=[ - "flavor", - "name", - "description", - "parameters", - "access", - "note_on_linkonly_privacy", - "source_code_url", - ] - ) - self.update_resource(request, appinstance, current_release_name) - - def update_resource(self, request, appinstance, current_release_name): - domain = appinstance.parameters["global"]["domain"] - # if subdomain is set as --generated--, then use appname - new_release_name = request.POST.get("app_release_name") - if not new_release_name: - new_release_name = appinstance.parameters["appname"] - - new_url = f"https://{new_release_name}.{domain}" - appinstance.table_field.update({"url": new_url}) - - if new_release_name and current_release_name != new_release_name: - # This handles the case where a user creates a new subdomain, we must update the helm release aswell - delete_and_deploy_resource.delay(appinstance.pk, new_release_name) - else: - deploy_resource.delay(appinstance.pk, "update") - - appinstance.save() - - @permission_required_or_403("can_view_project", (Project, "slug", "project")) -def create_releasename(request, project, app_slug): - pattern = re.compile("^[a-z0-9][a-z0-9-]+[a-z0-9]$") - available = "invalid" - system_subdomains = ["keycloak", "grafana", "prometheus", "studio"] - if pattern.match(request.POST.get("rn")): - available = "false" - count_rn = ReleaseName.objects.filter(name=request.POST.get("rn")).count() - if count_rn == 0 and request.POST.get("rn") not in system_subdomains: - available = "true" - release = ReleaseName() - release.name = request.POST.get("rn") - release.status = "active" - release.project = Project.objects.get(slug=project) - release.save() - logger.info("RELEASE_NAME: %s %s", request.POST.get("rn"), count_rn) - return JsonResponse( - { - "available": available, - "rn": request.POST.get("rn"), - } - ) +def delete(request, project, app_slug, app_id): + model_class = APP_REGISTRY.get_orm_model(app_slug) + logger.info(f"Deleting app type {model_class} with id {app_id}") + if model_class is None: + raise PermissionDenied() -@permission_required_or_403("can_view_project", (Project, "slug", "project")) -def add_tag(request, project, ai_id): - appinstance = AppInstance.objects.get(pk=ai_id) - if request.method == "POST": - new_tags = request.POST.get("tag", "") - for new_tag in new_tags.split(","): - logger.info("New Tag: %s", new_tag) - appinstance.tags.add(new_tag.strip().lower().replace('"', "")) - appinstance.save() + instance = model_class.objects.get(pk=app_id) if app_id else None - return HttpResponseRedirect( - reverse( - "apps:appsettings", - kwargs={"project": project, "ai_id": ai_id}, - ) - ) + if instance is None: + raise PermissionDenied() + if not instance.app.user_can_delete: + return HttpResponseForbidden() -@permission_required_or_403("can_view_project", (Project, "slug", "project")) -def remove_tag(request, project, ai_id): - appinstance = AppInstance.objects.get(pk=ai_id) - if request.method == "POST": - logger.info(request.POST) - new_tag = request.POST.get("tag", "") - logger.info("Remove Tag: %s", new_tag) - appinstance.tags.remove(new_tag) - appinstance.save() + serialized_instance = instance.serialize() + + delete_resource.delay(serialized_instance) + # fix: in case appinstance is public swich to private + instance.access = "private" + instance.save() return HttpResponseRedirect( reverse( - "apps:appsettings", - kwargs={"project": project, "ai_id": ai_id}, + "projects:details", + kwargs={ + "project_slug": str(project), + }, ) ) -@method_decorator( - permission_required_or_403( - "can_view_project", - (Project, "slug", "project"), - ), - name="dispatch", -) -class CreateServeView(View): - def get_shared_data(self, project_slug, app_slug): - project = Project.objects.get(slug=project_slug) - app = Apps.objects.filter(slug=app_slug).order_by("-revision")[0] - app_settings = app.settings - - return [project, app, app_settings] - - def get(self, request, user, project, app_slug, version): - template = "apps/create.html" - project, app, app_settings = self.get_shared_data(project, app_slug) - domain = DOMAIN - user = request.user - if "from" in request.GET: - from_page = request.GET.get("from") - else: - from_page = "filtered" - - do_display_description_field = app.category is not None and app.category.name.lower() == "serve" - - form = generate_form(app_settings, project, app, user, []) - - for model in form["models"]: - if model.version == version: - model.selected = "selected" - - return render(request, template, locals()) - - @method_decorator( permission_required_or_403("can_view_project", (Project, "slug", "project")), name="dispatch", ) -class CreateView(View): - def get_shared_data(self, project_slug, app_slug): +class CreateApp(View): + template_name = "apps/create_view.html" + + def get(self, request, project, app_slug, app_id=None): + # TODO This is a bit confusing. project is actually project_slug. So it would be better to rename it + # Look in studio/urls.py There is . It's being passed from here there + # But need to make sure, that that's the only place where it's being passed + project_slug = project project = Project.objects.get(slug=project_slug) - app = Apps.objects.filter(slug=app_slug).order_by("-revision")[0] - app_settings = app.settings - return [project, app, app_settings] - - def get(self, request, project, app_slug, data=[], wait=False, call=False): - template = "apps/create.html" - project, app, app_settings = self.get_shared_data(project, app_slug) - - domain = DOMAIN - if not call: - user = request.user - if "from" in request.GET: - from_page = request.GET.get("from") - else: - from_page = "filtered" - else: - from_page = "" - user = User.objects.get(username=user) + form = self.get_form(request, project, app_slug, app_id) - user_can_create = AppInstance.objects.user_can_create(user, project, app_slug) + if form is None or not getattr(form, "is_valid", False): + raise PermissionDenied() - if not user_can_create: - return HttpResponseForbidden() + form_header = "Update" if app_id else "Create" - do_display_description_field = app.category is not None and app.category.name.lower() == "serve" - - form = generate_form(app_settings, project, app, user, []) - - return render(request, template, locals()) - - def post(self, request, project, app_slug, data=[], wait=False): - project, app, app_settings = self.get_shared_data(project, app_slug) - data = request.POST - user = request.user - - user_can_create = AppInstance.objects.user_can_create(user, project, app_slug) + return render( + request, + self.template_name, + {"form": form, "project": project, "app_id": app_id, "app_slug": app_slug, "form_header": form_header}, + ) - if not user_can_create: - return HttpResponseForbidden() + @transaction.atomic + def post(self, request, project, app_slug, app_id=None): + # App id is used when updataing an instance - # Nikita Churikov @ nikita.churikov@scilifelab.uu.se on 25.01.2024 - # TODO: This is questionable but I won't touch it for now - # 1. We should not be throwing just a generic Exception - # 2. Couldn't we add this to the check above? - if not app.user_can_create: - raise Exception("User not allowed to create app") + # TODO Same as in get method + project_slug = project + project = Project.objects.get(slug=project_slug) - successful, project_slug, app_category_slug = create_app_instance(user, project, app, app_settings, data, wait) + form = self.get_form(request, project, app_slug, app_id) + if form is None: + raise PermissionDenied() - if not successful: - return HttpResponseRedirect( - reverse( - "projects:details", - kwargs={ - "project_slug": str(project.slug), - }, - ) - ) + if not form.is_valid(): + return render(request, self.template_name, {"form": form}) - if "from" in request.GET: - from_page = request.GET.get("from") - if from_page == "overview": - return HttpResponseRedirect( - reverse( - "projects:details", - kwargs={ - "project_slug": str(project_slug), - }, - ) - ) + # Otherwise we can create the instance + create_instance_from_form(form, project, app_slug, app_id) return HttpResponseRedirect( reverse( - "apps:filtered", + "projects:details", kwargs={ - "project": str(project_slug), - "category": app_category_slug, + "project_slug": str(project_slug), }, ) ) + def get_form(self, request, project, app_slug, app_id): + model_class, form_class = APP_REGISTRY.get(app_slug) -@permission_required_or_403("can_view_project", (Project, "slug", "project")) -def publish(request, user, project, category, ai_id): - try: - app = AppInstance.objects.get(pk=ai_id) - app.access = "public" - - if app.parameters["permissions"] is not None: - app.parameters["permissions"] = { - "public": True, - "project": False, - "private": False, - "link": False, - } + logger.info(f"Creating app type {model_class}") + if not model_class or not form_class: + logger.error("Could not fetch model or form") + return None - app.save() - except Exception as err: - logger.error(err, exc_info=True) + # Check if user is allowed + user_can_edit = False + user_can_create = False - return HttpResponseRedirect( - reverse( - "apps:filtered", - kwargs={ - "user": request.user, - "project": str(project), - "category": category, - }, - ) - ) - - -@permission_required_or_403("can_view_project", (Project, "slug", "project")) -def unpublish(request, user, project, category, ai_id): - try: - app = AppInstance.objects.get(pk=ai_id) - app.access = "project" - - if app.parameters["permissions"] is not None: - app.parameters["permissions"] = { - "public": False, - "project": True, - "private": False, - } - - app.save() - except Exception as err: - logger.error(err, exc_info=True) - - return HttpResponseRedirect( - reverse( - "apps:filtered", - kwargs={ - "user": request.user, - "project": str(project), - "category": category, - }, - ) - ) - - -@permission_required_or_403("can_view_project", (Project, "slug", "project")) -def delete(request, project, category, ai_id): - if "from" in request.GET: - from_page = request.GET.get("from") - else: - from_page = "filtered" - - app_instance = AppInstance.objects.get(pk=ai_id) - - if not app_instance.app.user_can_delete: - return HttpResponseForbidden() - - delete_resource.delay(ai_id) - # fix: in case appinstance is public swich to private - app_instance.access = "private" - app_instance.save() - - if "from" in request.GET: - from_page = request.GET.get("from") - if from_page == "overview": - return HttpResponseRedirect( - reverse( - "projects:details", - kwargs={ - "project_slug": str(project), - }, - ) - ) - elif from_page == "filtered": - return HttpResponseRedirect( - reverse( - "apps:filtered", - kwargs={ - "project": str(project), - "category": category, - }, - ) - ) + if app_id: + user_can_edit = model_class.objects.user_can_edit(request.user, project, app_slug) + instance = model_class.objects.get(pk=app_id) else: - return HttpResponseRedirect( - reverse( - "apps:filtered", - kwargs={ - "project": str(project), - "category": category, - }, - ) - ) + user_can_create = model_class.objects.user_can_create(request.user, project, app_slug) + instance = None - return HttpResponseRedirect( - reverse( - "apps:filtered", - kwargs={ - "project": str(project), - "category": category, - }, - ) - ) + if user_can_edit or user_can_create: + return form_class(request.POST or None, project_pk=project.pk, instance=instance) + # Maybe this makes typing hard. + else: + return None diff --git a/collections_module/admin.py b/collections_module/admin.py deleted file mode 100644 index 70ef7aa98..000000000 --- a/collections_module/admin.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.contrib import admin - -from .models import Collection - - -class CollectionAdmin(admin.ModelAdmin): - readonly_fields = ["connected_apps"] - - def connected_apps(self, obj): - apps = obj.app_instances.all() - app_list = ", ".join([app.name for app in apps]) - return app_list or "No apps connected" - - -admin.site.register(Collection, CollectionAdmin) diff --git a/collections_module/apps.py b/collections_module/apps.py deleted file mode 100644 index beb230524..000000000 --- a/collections_module/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class CollectionsModuleConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "collections_module" diff --git a/collections_module/migrations/0001_initial.py b/collections_module/migrations/0001_initial.py deleted file mode 100644 index a56c4ae28..000000000 --- a/collections_module/migrations/0001_initial.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 4.2.7 on 2024-03-20 15:02 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="Collection", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_on", models.DateTimeField(auto_now_add=True)), - ("name", models.CharField(default="", max_length=200, unique=True)), - ("description", models.TextField(blank=True, default="", max_length=1000)), - ("website", models.URLField(blank=True)), - ("logo", models.ImageField(blank=True, null=True, upload_to="collections/logos/")), - ("slug", models.SlugField(blank=True, unique=True)), - ( - "maintainer", - models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="collection_maintainer", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - ] diff --git a/collections_module/migrations/0002_collection_zenodo_community_id.py b/collections_module/migrations/0002_collection_zenodo_community_id.py deleted file mode 100644 index bb4589585..000000000 --- a/collections_module/migrations/0002_collection_zenodo_community_id.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.7 on 2024-03-28 17:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("collections_module", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="collection", - name="zenodo_community_id", - field=models.CharField(blank=True, max_length=200, null=True), - ), - ] diff --git a/collections_module/models.py b/collections_module/models.py deleted file mode 100644 index 6f3a6d2a1..000000000 --- a/collections_module/models.py +++ /dev/null @@ -1,28 +0,0 @@ -from django.contrib.auth import get_user_model -from django.db import models -from django.utils.text import slugify - - -class Collection(models.Model): - created_on = models.DateTimeField(auto_now_add=True) - maintainer = models.ForeignKey( - get_user_model(), - on_delete=models.CASCADE, - related_name="collection_maintainer", - null=True, - ) - name = models.CharField(max_length=200, unique=True, default="") - description = models.TextField(blank=True, default="", max_length=1000) - website = models.URLField(max_length=200, blank=True) - logo = models.ImageField(upload_to="collections/logos/", null=True, blank=True) - slug = models.SlugField(unique=True, blank=True) - zenodo_community_id = models.CharField(max_length=200, null=True, blank=True) - # repositories source would be another field - - def __str__(self): - return self.name - - def save(self, *args, **kwargs): - if not self.slug: - self.slug = slugify(self.name) - super(Collection, self).save(*args, **kwargs) diff --git a/collections_module/tests.py b/collections_module/tests.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/collections_module/urls.py b/collections_module/urls.py deleted file mode 100644 index a00fb3e0d..000000000 --- a/collections_module/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.urls import path - -from . import views - -app_name = "collections_module" - -urlpatterns = [ - path("collections/", views.index, name="index"), - path("collections//", views.collection, name="collection"), -] diff --git a/collections_module/views.py b/collections_module/views.py deleted file mode 100644 index d44b66649..000000000 --- a/collections_module/views.py +++ /dev/null @@ -1,36 +0,0 @@ -from django.apps import apps -from django.conf import settings -from django.db.models import Q -from django.shortcuts import get_object_or_404, render - -from portal.views import get_public_apps - -from .models import Collection - -PublishedModel = apps.get_model(app_label=settings.PUBLISHEDMODEL_MODEL) - - -def index(request): - template = "collections/index.html" - - collection_objects = Collection.objects.all().order_by("-created_on") - - context = {"collection_objects": collection_objects} - - return render(request, template, context=context) - - -def collection(request, slug, id=0): - template = "collections/collection.html" - - collection = get_object_or_404(Collection, slug=slug) - collection_published_apps, request = get_public_apps(request, id=id, collection=slug) - collection_published_models = PublishedModel.objects.all().filter(collections__slug=slug) - - context = { - "collection": collection, - "collection_published_apps": collection_published_apps, - "collection_published_models": collection_published_models, - } - - return render(request, template, context=context) diff --git a/common/management/commands/create_locust_apps.py b/common/management/commands/create_locust_apps.py index d324e2a3f..917448098 100644 --- a/common/management/commands/create_locust_apps.py +++ b/common/management/commands/create_locust_apps.py @@ -1,7 +1,8 @@ from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand, CommandError -from apps.helpers import create_app_instance +# TODO: revisit: +from apps.helpers import create_app_instance # type:ignore[attr-defined] from apps.models import Apps from projects.models import Flavor, Project @@ -33,7 +34,7 @@ def handle(self, *args, **options): successful, project_slug, app_category_slug = create_app_instance( user, project, app, app.settings, data=data - ) + ) # TODO: revisit. type:ignore[attr-defined] else: self.stdout.write(self.style.WARNING(f"Flavor or app not found for user: {user.email}")) else: diff --git a/common/management/commands/install_fixtures.py b/common/management/commands/install_fixtures.py index 5931722d4..bba665c94 100644 --- a/common/management/commands/install_fixtures.py +++ b/common/management/commands/install_fixtures.py @@ -12,13 +12,13 @@ class Command(BaseCommand): def handle(self, *args, **kwargs): # Define all fixtures here files = [ - "projects_templates.json", - "intervals_fixtures.json", - "periodic_tasks_fixtures.json", "appcats_fixtures.json", "apps_fixtures.json", + "intervals_fixtures.json", + "periodic_tasks_fixtures.json", "objecttype_fixtures.json", "groups_fixtures.json", + "projects_templates.json", ] fixture_files = ["fixtures/" + file for file in files] diff --git a/common/migrations/0001_initial.py b/common/migrations/0001_initial.py index 05cd958db..b96ecccac 100644 --- a/common/migrations/0001_initial.py +++ b/common/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.5 on 2023-11-16 08:07 +# Generated by Django 5.0.2 on 2024-05-27 07:28 import django.db.models.deletion from django.conf import settings @@ -13,12 +13,41 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name="FixtureVersion", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("filename", models.CharField(max_length=255, unique=True)), + ("hash", models.CharField(max_length=64)), + ], + ), + migrations.CreateModel( + name="MaintenanceMode", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("login_and_signup_disabled", models.BooleanField(default=False)), + ("message_in_header", models.TextField(blank=True, max_length=1000)), + ("message_in_footer", models.TextField(blank=True, max_length=1000)), + ], + ), + migrations.CreateModel( + name="EmailVerificationTable", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("token", models.CharField(max_length=100)), + ( + "user", + models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ], + ), migrations.CreateModel( name="UserProfile", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("affiliation", models.CharField(blank=True, max_length=100)), ("department", models.CharField(blank=True, max_length=100)), + ("deleted_on", models.DateTimeField(blank=True, null=True)), ("why_account_needed", models.TextField(blank=True, max_length=1000)), ("is_approved", models.BooleanField(default=False)), ("note", models.TextField(blank=True, max_length=1000)), diff --git a/common/migrations/0002_emailverificationtable.py b/common/migrations/0002_emailverificationtable.py deleted file mode 100644 index 5cbf987f0..000000000 --- a/common/migrations/0002_emailverificationtable.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 4.2.5 on 2023-11-24 13:27 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("common", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="EmailVerificationTable", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("token", models.CharField(max_length=100)), - ( - "user", - models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ], - ), - ] diff --git a/common/migrations/0003_fixtureversion.py b/common/migrations/0003_fixtureversion.py deleted file mode 100644 index 33ac5e93e..000000000 --- a/common/migrations/0003_fixtureversion.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.2.7 on 2024-03-07 09:31 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("common", "0002_emailverificationtable"), - ] - - operations = [ - migrations.CreateModel( - name="FixtureVersion", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("filename", models.CharField(max_length=255, unique=True)), - ("hash", models.CharField(max_length=64)), - ], - ), - ] diff --git a/common/migrations/0004_userprofile_deleted_on.py b/common/migrations/0004_userprofile_deleted_on.py deleted file mode 100644 index a8ab363ab..000000000 --- a/common/migrations/0004_userprofile_deleted_on.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.7 on 2024-04-03 06:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("common", "0003_fixtureversion"), - ] - - operations = [ - migrations.AddField( - model_name="userprofile", - name="deleted_on", - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/common/migrations/0005_maintenancemode.py b/common/migrations/0005_maintenancemode.py deleted file mode 100644 index a8bc56551..000000000 --- a/common/migrations/0005_maintenancemode.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.7 on 2024-04-15 07:48 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("common", "0004_userprofile_deleted_on"), - ] - - operations = [ - migrations.CreateModel( - name="MaintenanceMode", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("login_and_signup_disabled", models.BooleanField(default=False)), - ("message_in_header", models.TextField(blank=True, max_length=1000)), - ("message_in_footer", models.TextField(blank=True, max_length=1000)), - ], - ), - ] diff --git a/collections_module/migrations/__init__.py b/common/templatetags/__init__.py similarity index 100% rename from collections_module/migrations/__init__.py rename to common/templatetags/__init__.py diff --git a/customtags/templatetags/can_create_app.py b/common/templatetags/can_create_app.py similarity index 62% rename from customtags/templatetags/can_create_app.py rename to common/templatetags/can_create_app.py index 56f490e5b..192099da0 100644 --- a/customtags/templatetags/can_create_app.py +++ b/common/templatetags/can_create_app.py @@ -1,6 +1,6 @@ from django import template -from apps.models import AppInstance +from apps.app_registry import APP_REGISTRY register = template.Library() @@ -10,6 +10,7 @@ def can_create_app(user, project, app): app_slug = app if isinstance(app, str) else app.slug - user_can_create = AppInstance.objects.user_can_create(user=user, project=project, app_slug=app_slug) + model_class = APP_REGISTRY.get_orm_model(app_slug) + user_can_create = model_class.objects.user_can_create(user=user, project=project, app_slug=app_slug) return user_can_create diff --git a/customtags/templatetags/get_dict_key.py b/common/templatetags/get_dict_key.py similarity index 100% rename from customtags/templatetags/get_dict_key.py rename to common/templatetags/get_dict_key.py diff --git a/customtags/templatetags/get_range.py b/common/templatetags/get_range.py similarity index 100% rename from customtags/templatetags/get_range.py rename to common/templatetags/get_range.py diff --git a/customtags/templatetags/get_setting.py b/common/templatetags/get_setting.py similarity index 100% rename from customtags/templatetags/get_setting.py rename to common/templatetags/get_setting.py diff --git a/customtags/templatetags/get_version.py b/common/templatetags/get_version.py similarity index 100% rename from customtags/templatetags/get_version.py rename to common/templatetags/get_version.py diff --git a/customtags/templatetags/is_login_signup_disabled.py b/common/templatetags/is_login_signup_disabled.py similarity index 100% rename from customtags/templatetags/is_login_signup_disabled.py rename to common/templatetags/is_login_signup_disabled.py diff --git a/customtags/__init__.py b/customtags/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/customtags/migrations/__init__.py b/customtags/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/customtags/templatetags/__init__.py b/customtags/templatetags/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/cypress/e2e/setup-scripts/seed_collections_user.py b/cypress/e2e/setup-scripts/seed_collections_user.py index 58f38ef36..bed4e8703 100755 --- a/cypress/e2e/setup-scripts/seed_collections_user.py +++ b/cypress/e2e/setup-scripts/seed_collections_user.py @@ -5,10 +5,12 @@ from django.conf import settings from django.contrib.auth.models import User +from django.db import transaction -from apps.helpers import create_app_instance +from apps.app_registry import APP_REGISTRY +from apps.helpers import create_instance_from_form from apps.models import Apps -from projects.models import Environment, Flavor, Project, ProjectTemplate +from projects.models import Flavor, Project, ProjectTemplate from projects.tasks import create_resources_from_template cypress_path = os.path.join(settings.BASE_DIR, "cypress/fixtures") @@ -17,36 +19,60 @@ with open(os.path.join(cypress_path, "users.json"), "r") as f: testdata = json.load(f) - userdata = testdata["collections_user"] - username = userdata["username"] - email = userdata["email"] - pwd = userdata["password"] +userdata = testdata["collections_user"] +username = userdata["username"] +email = userdata["email"] +pwd = userdata["password"] + +with transaction.atomic(): # Create a superuser because that's the one that can currently create collections superuser = User.objects.create_superuser(username, email, pwd) superuser.save() + project_template = ProjectTemplate.objects.get(pk=1) # Create a project for apps to be included in the collection project = Project.objects.create_project( - name="e2e-collections-test-proj", owner=superuser, description="e2e-collections-test-proj-desc" + name="e2e-collections-test-proj", + owner=superuser, + description="e2e-collections-test-proj-desc", + project_template=project_template, ) project.save() # Create an app to be included in a collection # create resources inside the project - project_template = ProjectTemplate.objects.get(pk=1) + create_resources_from_template(superuser.username, project.slug, project_template.template) # define variables needed app = Apps.objects.filter(slug="dashapp").order_by("-revision").first() flavor = Flavor.objects.filter(project=project).first() - environment = Environment.objects.filter(project=project).first() + + # define variables needed + app_slug = "dashapp" + data = { - "app_name": "collection-app-name", - "app_description": "collection-app-description", + "name": "collection-app-name", + "description": "collection-app-description", "flavor": str(flavor.pk), - "permission": "public", - "environment": str(environment.pk), + "access": "public", + "port": 8000, + "image": "some-image", + "source_code_url": "https://someurlthatdoesnotexist.com", } - # now create app - create_app_instance(superuser, project, app, app.settings, data=data) + + # Check if the model form tuple exists + if app_slug not in APP_REGISTRY: + raise ValueError(f"Form class not found for app slug {app_slug}") + + form_class = APP_REGISTRY.get_form_class(app_slug) + + # Create form + form = form_class(data, project_pk=project.pk) + + if form.is_valid(): + # now create app + create_instance_from_form(form, project, app_slug) + else: + raise ValueError(f"Form is invalid: {form.errors.as_data()}") diff --git a/cypress/e2e/setup-scripts/seed_contributor.py b/cypress/e2e/setup-scripts/seed_contributor.py index a99741694..0fc4506bc 100755 --- a/cypress/e2e/setup-scripts/seed_contributor.py +++ b/cypress/e2e/setup-scripts/seed_contributor.py @@ -6,7 +6,7 @@ from django.conf import settings from django.contrib.auth import get_user_model -from projects.models import Project +from projects.models import Project, ProjectTemplate User = get_user_model() @@ -26,10 +26,13 @@ user = User.objects.create_user(username, email, pwd) else: user = User.objects.get(username=email) + project_template = ProjectTemplate.objects.get(pk=1) # Check if project exists, otherwise, create it if not Project.objects.filter(name="e2e-delete-proj-test").exists(): - _ = Project.objects.create_project(name="e2e-delete-proj-test", owner=user, description="") + _ = Project.objects.create_project( + name="e2e-delete-proj-test", owner=user, description="", project_template=project_template + ) # Create the contributor's collaborator user co_userdata = testdata["contributor_collaborator"] @@ -44,4 +47,6 @@ # Check if project exists, otherwise, create it if not Project.objects.filter(name="e2e-collaborator-proj-test").exists(): - _ = Project.objects.create_project(name="e2e-collaborator-proj-test", owner=co_user, description="") + _ = Project.objects.create_project( + name="e2e-collaborator-proj-test", owner=co_user, description="", project_template=project_template + ) diff --git a/cypress/e2e/setup-scripts/seed_superuser.py b/cypress/e2e/setup-scripts/seed_superuser.py index 337622e1a..0e36e4399 100755 --- a/cypress/e2e/setup-scripts/seed_superuser.py +++ b/cypress/e2e/setup-scripts/seed_superuser.py @@ -5,54 +5,73 @@ from django.conf import settings from django.contrib.auth.models import User +from django.db import transaction -from apps.helpers import create_app_instance -from apps.models import Apps -from projects.models import Environment, Flavor, Project, ProjectTemplate +from apps.app_registry import APP_REGISTRY +from apps.helpers import create_instance_from_form +from projects.models import Flavor, Project, ProjectTemplate from projects.tasks import create_resources_from_template +from studio.utils import get_logger + +logger = get_logger(__name__) cypress_path = os.path.join(settings.BASE_DIR, "cypress/fixtures") -print(f"Now loading the json users file from fixtures path: {cypress_path}") # /app/cypress/fixtures +logger.info("RUNNING CYPRESS TESTS SETUP SCRIPTS") +logger.debug(f"Now loading the json users file from fixtures path: {cypress_path}") # /app/cypress/fixtures with open(os.path.join(cypress_path, "users.json"), "r") as f: testdata = json.load(f) - s_userdata = testdata["superuser"] - s_username = s_userdata["username"] - s_email = s_userdata["email"] - s_pwd = s_userdata["password"] - # Create the superuser - superuser = User.objects.create_superuser(s_username, s_email, s_pwd) - superuser.save() - - userdata = testdata["superuser_testuser"] - username = userdata["username"] - email = userdata["email"] - pwd = userdata["password"] - # Create the regular user +s_userdata = testdata["superuser"] +s_username = s_userdata["username"] +s_email = s_userdata["email"] +s_pwd = s_userdata["password"] +# Create the superuser +superuser = User.objects.create_superuser(s_username, s_email, s_pwd) +superuser.save() + +userdata = testdata["superuser_testuser"] +username = userdata["username"] +email = userdata["email"] +pwd = userdata["password"] + +with transaction.atomic(): + logger.debug("Creating regular user") user = User.objects.create_user(username, email, pwd) user.save() - # Create a dummy project belonging to the regular user to be inspected by the superuser tests + project_template = ProjectTemplate.objects.get(pk=1) + + logger.debug("Create a dummy project belonging to the regular user to be inspected by the superuser tests") project = Project.objects.create_project( - name="e2e-superuser-testuser-proj-test", owner=user, description="Description by regular user" + name="e2e-superuser-testuser-proj-test", + owner=user, + description="Description by regular user", + project_template=project_template, ) project.save() # Create a private app belonging to the regular user to be inspected by the superuser - # create resources inside the project - project_template = ProjectTemplate.objects.get(pk=1) + create_resources_from_template(user.username, project.slug, project_template.template) - # define variables needed - app = Apps.objects.filter(slug="jupyter-lab").order_by("-revision").first() + flavor = Flavor.objects.filter(project=project).first() - environment = Environment.objects.filter(project=project).first() - data = { - "app_name": "Regular user's private app", - "app_description": "Test app for superuser testing", - "flavor": str(flavor.pk), - "permission": "private", - "environment": str(environment.pk), - } - # now create app - create_app_instance(user, project, app, app.settings, data=data) + + # define variables needed + app_slug = "jupyter-lab" + + data = {"name": "Regular user's private app", "flavor": str(flavor.pk), "access": "private", "volume": None} + + if app_slug not in APP_REGISTRY: + raise ValueError(f"Form class not found for app slug {app_slug}") + + form_class = APP_REGISTRY.get_form_class(app_slug) + + # Create form + form = form_class(data, project_pk=project.pk) + + if form.is_valid(): + # now create app + create_instance_from_form(form, project, app_slug) + else: + raise ValueError(f"Form is invalid: {form.errors.as_data()}") diff --git a/cypress/e2e/ui-tests/test-collections.cy.js b/cypress/e2e/ui-tests/test-collections.cy.js index 381e01aad..46ba9eb07 100644 --- a/cypress/e2e/ui-tests/test-collections.cy.js +++ b/cypress/e2e/ui-tests/test-collections.cy.js @@ -50,7 +50,7 @@ describe("Test collections functionality", () => { cy.get('li.success').should('contain', "was added successfully") // confirm collection was created cy.log("Adding an app to the collection") - cy.get('tr.model-appinstance').find('a').first().click() + cy.get('tr.model-dashinstance').find('a').first().click() cy.get('tr').find('a').contains(collection_app_name).click() cy.get('#id_collections').select(collection_name) cy.get('input[name=_save]').click() diff --git a/cypress/e2e/ui-tests/test-deploy-app.cy.js b/cypress/e2e/ui-tests/test-deploy-app.cy.js index 85be502d5..659d2adb0 100644 --- a/cypress/e2e/ui-tests/test-deploy-app.cy.js +++ b/cypress/e2e/ui-tests/test-deploy-app.cy.js @@ -52,13 +52,15 @@ describe("Test deploying app", () => { const image_name_2 = "ghcr.io/scilifelabdatacentre/example-streamlit:230921-1443" const image_port = "8501" const image_port_2 = "8502" - const app_path = "username" - const app_path_2 = "username/app" + const app_path = "/home/username" + const app_path_2 = "/home/username/app" const link_privacy_type_note = "some-text-on-link-only-app" const createResources = Cypress.env('create_resources'); const app_type = "Custom App" const app_source_code_public = "https://doi.org/example" + let volume_display_text = "project-vol (" + project_name + ")" + if (createResources === true) { cy.visit("/projects/") cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() @@ -66,13 +68,14 @@ describe("Test deploying app", () => { // Create an app with project permissions cy.log("Now creating a project app") cy.get('div.card-body:contains("' + app_type + '")').find('a:contains("Create")').click() - cy.get('input[name=app_name]').type(app_name_project) - cy.get('textarea[name=app_description]').type(app_description) - cy.get('#permission').select('project') - cy.get('input[name="appconfig.port"]').clear().type(image_port) - cy.get('input[name="appconfig.image"]').clear().type(image_name) - cy.get('input[name="appconfig.path"]').clear().type(app_path) - cy.get('button').contains('Create').click() + cy.get('#id_name').type(app_name_project) + cy.get('#id_description').type(app_description) + cy.get('#id_access').select('Project') + cy.get('#id_volume').select(volume_display_text) + cy.get('#id_port').clear().type(image_port) + cy.get('#id_image').clear().type(image_name) + cy.get('#id_path').clear().type(app_path) + cy.get('#submit-id-submit').contains('Submit').click() // check that the app was created cy.get('tr:contains("' + app_name_project + '")').find('span').should('contain', 'Running') cy.get('tr:contains("' + app_name_project + '")').find('span').should('contain', 'project') @@ -87,9 +90,9 @@ describe("Test deploying app", () => { cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.get('tr:contains("' + app_name_project + '")').find('i.bi-three-dots-vertical').click() cy.get('tr:contains("' + app_name_project + '")').find('a').contains('Settings').click() - cy.get('#permission').select('public') - cy.get('input[name=source_code_url]').type(app_source_code_public) - cy.get('button').contains('Update').click() + cy.get('#id_access').select('Public') + cy.get('#id_source_code_url').type(app_source_code_public) + cy.get('#submit-id-submit').contains('Submit').click() cy.get('tr:contains("' + app_name_project + '")').find('span').should('contain', 'Running') cy.get('tr:contains("' + app_name_project + '")').find('span').should('contain', 'public') @@ -102,15 +105,15 @@ describe("Test deploying app", () => { // Create a public app and verify that it is displayed on the public apps page cy.log("Now creating a public app") cy.get('div.card-body:contains("' + app_type + '")').find('a:contains("Create")').click() - cy.get('input[name=app_name]').type(app_name_public) - cy.get('textarea[name=app_description]').type(app_description) - cy.get('#permission').select('public') - cy.get('input[name=source_code_url]').type(app_source_code_public) - cy.get('input[name="appconfig.port"]').clear().type(image_port) - cy.get('input[name="appconfig.image"]').clear().type(image_name) - cy.get('input[name="appconfig.path"]').clear().type(app_path) - cy.get('#Persistent\\ Volume').select('project-vol') - cy.get('button').contains('Create').click() + cy.get('#id_name').type(app_name_public) + cy.get('#id_description').type(app_description) + cy.get('#id_access').select('Public') + cy.get('#id_source_code_url').type(app_source_code_public) + cy.get('#id_port').clear().type(image_port) + cy.get('#id_image').clear().type(image_name) + cy.get('#id_path').clear().type(app_path) + cy.get('#id_volume').select(volume_display_text) + cy.get('#submit-id-submit').contains('Submit').click() cy.get('tr:contains("' + app_name_public + '")').find('span').should('contain', 'Running') cy.get('tr:contains("' + app_name_public + '")').find('span').should('contain', 'public') @@ -151,24 +154,23 @@ describe("Test deploying app", () => { cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.get('tr:contains("' + app_name_public + '")').find('i.bi-three-dots-vertical').click() cy.get('tr:contains("' + app_name_public + '")').find('a').contains('Settings').click() - cy.get('input[name=app_name]').should('have.value', app_name_public) // name should be same as before - cy.get('input[name=app_name]').clear().type(app_name_public_2) // now change name - cy.get('textarea[name=app_description]').should('have.value', app_description) // description should be same as set before - cy.get('textarea[name=app_description]').clear().type(app_description_2) // now change description - cy.get('#permission').find(':selected').should('contain', 'public') - // checking that a) permissions can be changed to 'link'; b) that the corresponding text field is shown and mandatory - cy.get('#permission').select('link') - cy.get('textarea[name=link_privacy_type_note]').should('be.visible') - cy.get('textarea[name=link_privacy_type_note]').should('have.attr', 'required') - cy.get('textarea[name=link_privacy_type_note]').clear().type(link_privacy_type_note) - cy.get('#Persistent\\ Volume').find(':selected').should('contain', 'project-vol') - cy.get('input[name="appconfig.port"]').should('have.value', image_port) - cy.get('input[name="appconfig.port"]').clear().type(image_port_2) - cy.get('input[name="appconfig.image"]').should('have.value', image_name) - cy.get('input[name="appconfig.image"]').clear().type(image_name_2) - cy.get('input[name="appconfig.path"]').should('have.value', app_path) - cy.get('input[name="appconfig.path"]').clear().type(app_path_2) - cy.get('button').contains('Update').click() + cy.get('#id_name').should('have.value', app_name_public) // name should be same as before + cy.get('#id_name').clear().type(app_name_public_2) // now change name + cy.get('#id_description').should('have.value', app_description) // description should be same as set before + cy.get('#id_description').clear().type(app_description_2) // now change description + cy.get('#id_access').find(':selected').should('contain', 'Public') + // checking that a) permissions can be changed to 'Link'; b) that the corresponding text field is shown and mandatory + cy.get('#id_access').select('Link') + cy.get('#id_note_on_linkonly_privacy').should('be.visible') + cy.get('#id_note_on_linkonly_privacy').clear().type(link_privacy_type_note) + cy.get('#id_volume').find(':selected').should('contain', 'project-vol') + cy.get('#id_port').should('have.value', image_port) + cy.get('#id_port').clear().type(image_port_2) + cy.get('#id_image').should('have.value', image_name) + cy.get('#id_image').clear().type(image_name_2) + cy.get('#id_path').should('have.value', app_path) + cy.get('#id_path').clear().type(app_path_2) + cy.get('#submit-id-submit').contains('Submit').click() cy.get('tr:contains("' + app_name_public_2 + '")').find('span').should('contain', 'link') cy.get('tr:contains("' + app_name_public_2 + '")').find('span').should('contain', 'Running') // NB: it will get status "Running" but it won't work because the new port is incorrect // Check that the changes were saved @@ -176,13 +178,13 @@ describe("Test deploying app", () => { cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.get('tr:contains("' + app_name_public_2 + '")').find('i.bi-three-dots-vertical').click() cy.get('tr:contains("' + app_name_public_2 + '")').find('a').contains('Settings').click() - cy.get('input[name=app_name]').should('have.value', app_name_public_2) - cy.get('textarea[name=app_description]').should('have.value', app_description_2) - cy.get('#permission').find(':selected').should('contain', 'link') - cy.get('textarea[name=link_privacy_type_note]').should('have.value', link_privacy_type_note) - cy.get('input[name="appconfig.port"]').should('have.value', image_port_2) - cy.get('input[name="appconfig.image"]').should('have.value', image_name_2) - cy.get('input[name="appconfig.path"]').should('have.value', app_path_2) + cy.get('#id_name').should('have.value', app_name_public_2) + cy.get('#id_description').should('have.value', app_description_2) + cy.get('#id_access').find(':selected').should('contain', 'Link') + cy.get('#id_note_on_linkonly_privacy').should('have.value', link_privacy_type_note) + cy.get('#id_port').should('have.value', image_port_2) + cy.get('#id_image').should('have.value', image_name_2) + cy.get('#id_path').should('have.value', app_path_2) // Remove the created public app and verify that it is deleted from public apps page cy.log("Now deleting the public app") @@ -218,13 +220,13 @@ describe("Test deploying app", () => { cy.visit("/projects/") cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.get('div.card-body:contains("' + app_type + '")').find('a:contains("Create")').click() - cy.get('input[name=app_name]').type(app_name) - cy.get('textarea[name=app_description]').type(app_description) - cy.get('input[name=source_code_url]').type(source_code_url) - cy.get('#permission').select('public') - cy.get('input[name="appconfig.image"]').clear().type(image_name) - cy.get('input[name="appconfig.port"]').clear().type(image_port) - cy.get('button').contains('Create').click() + cy.get('#id_name').type(app_name) + cy.get('#id_description').type(app_description) + cy.get('#id_access').select('Public') + cy.get('#id_source_code_url').type(source_code_url) + cy.get('#id_image').clear().type(image_name) + cy.get('#id_port').clear().type(image_port) + cy.get('#submit-id-submit').contains('Submit').click() // cy.get('tr:contains("' + app_name + '")').find('span').should('contain', 'Running') // for now commented out because it takes shinyproxy a really long time to start up and therefore status "Running" can take 5 minutes to show up cy.get('tr:contains("' + app_name + '")').find('span').should('contain', 'public') @@ -233,11 +235,11 @@ describe("Test deploying app", () => { cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.get('tr:contains("' + app_name + '")').find('i.bi-three-dots-vertical').click() cy.get('tr:contains("' + app_name + '")').find('a').contains('Settings').click() - cy.get('input[name=app_name]').should('have.value', app_name) - cy.get('textarea[name=app_description]').should('have.value', app_description) - cy.get('#permission').find(':selected').should('contain', 'public') - cy.get('input[name="appconfig.image"]').should('have.value', image_name) - cy.get('input[name="appconfig.port"]').should('have.value', image_port) + cy.get('#id_name').should('have.value', app_name) + cy.get('#id_description').should('have.value', app_description) + cy.get('#id_access').find(':selected').should('contain', 'Public') + cy.get('#id_image').should('have.value', image_name) + cy.get('#id_port').should('have.value', image_port) cy.log("Checking that the shiny app is displayed on the public apps page") cy.visit("/apps") @@ -287,13 +289,13 @@ describe("Test deploying app", () => { cy.visit("/projects/") cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.get('div.card-body:contains("' + app_type + '")').find('a:contains("Create")').click() - cy.get('input[name=app_name]').type(app_name) - cy.get('textarea[name=app_description]').type(app_description) - cy.get('input[name=source_code_url]').type(source_code_url) - cy.get('#permission').select('public') - cy.get('input[name="appconfig.image"]').clear().type(image_name) - cy.get('input[name="appconfig.port"]').clear().type(image_port) - cy.get('button').contains('Create').click() + cy.get('#id_name').type(app_name) + cy.get('#id_description').type(app_description) + cy.get('#id_access').select('Public') + cy.get('#id_source_code_url').type(source_code_url) + cy.get('#id_image').clear().type(image_name) + cy.get('#id_port').clear().type(image_port) + cy.get('#submit-id-submit').contains('Submit').click() cy.get('tr:contains("' + app_name + '")').find('span').should('contain', 'Running') cy.get('tr:contains("' + app_name + '")').find('span').should('contain', 'public') @@ -302,11 +304,11 @@ describe("Test deploying app", () => { cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.get('tr:contains("' + app_name + '")').find('i.bi-three-dots-vertical').click() cy.get('tr:contains("' + app_name + '")').find('a').contains('Settings').click() - cy.get('input[name=app_name]').should('have.value', app_name) - cy.get('textarea[name=app_description]').should('have.value', app_description) - cy.get('#permission').find(':selected').should('contain', 'public') - cy.get('input[name="appconfig.image"]').should('have.value', image_name) - cy.get('input[name="appconfig.port"]').should('have.value', image_port) + cy.get('#id_name').should('have.value', app_name) + cy.get('#id_description').should('have.value', app_description) + cy.get('#id_access').find(':selected').should('contain', 'Public') + cy.get('#id_image').should('have.value', image_name) + cy.get('#id_port').should('have.value', image_port) cy.log("Deleting the dash app") cy.visit("/projects/") @@ -332,17 +334,19 @@ describe("Test deploying app", () => { const createResources = Cypress.env('create_resources'); const app_type = "TissUUmaps App" + let volume_display_text = "project-vol (" + project_name + ")" + if (createResources === true) { cy.visit("/projects/") cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.log("Creating a tisuumaps app") cy.get('div.card-body:contains("' + app_type + '")').find('a:contains("Create")').click() - cy.get('input[name=app_name]').type(app_name) - cy.get('textarea[name=app_description]').type(app_description) - cy.get('#permission').select('public') - cy.get('#Persistent\\ Volume').select('project-vol') - cy.get('button').contains('Create').click() + cy.get('#id_name').type(app_name) + cy.get('#id_description').type(app_description) + cy.get('#id_access').select('Public') + cy.get('#id_volume').select(volume_display_text) + cy.get('#submit-id-submit').contains('Submit').click() cy.get('tr:contains("' + app_name + '")').find('span').should('contain', 'Running') cy.get('tr:contains("' + app_name + '")').find('span').should('contain', 'public') @@ -351,10 +355,10 @@ describe("Test deploying app", () => { cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.get('tr:contains("' + app_name + '")').find('i.bi-three-dots-vertical').click() cy.get('tr:contains("' + app_name + '")').find('a').contains('Settings').click() - cy.get('input[name=app_name]').should('have.value', app_name) - cy.get('textarea[name=app_description]').should('have.value', app_description) - cy.get('#permission').find(':selected').should('contain', 'public') - cy.get('#Persistent\\ Volume').find(':selected').should('contain', 'project-vol') + cy.get('#id_name').should('have.value', app_name) + cy.get('#id_description').should('have.value', app_description) + cy.get('#id_access').find(':selected').should('contain', 'Public') + cy.get('#id_volume').find(':selected').should('contain', 'project-vol') cy.log("Deleting the tissuumaps app") cy.visit("/projects/") @@ -376,6 +380,7 @@ describe("Test deploying app", () => { // Names of objects to create const project_name = "e2e-deploy-app-test" const app_name = "e2e-subdomain-example" + const app_name_2 = "e2e-second-subdomain-example" const app_description = "e2e-subdomain-description" const image_name = "ghcr.io/scilifelabdatacentre/example-streamlit:latest" const createResources = Cypress.env('create_resources'); @@ -384,6 +389,8 @@ describe("Test deploying app", () => { const subdomain_2 = "subdomain-test2" const subdomain_3 = "subdomain-test3" + let volume_display_text = "project-vol (" + project_name + ")" + if (createResources === true) { cy.visit("/projects/") cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() @@ -391,36 +398,41 @@ describe("Test deploying app", () => { cy.log("Now creating an app with a custom subdomain") cy.get('div.card-body:contains("' + app_type + '")').find('a:contains("Create")').click() // fill out other fields - cy.get('input[name=app_name]').type(app_name) - cy.get('textarea[name=app_description]').type(app_description) - cy.get('input[name="appconfig.port"]').clear().type("8501") - cy.get('input[name="appconfig.image"]').clear().type(image_name) - cy.get('input[name="appconfig.path"]').clear().type("/home") + cy.get('#id_name').type(app_name) + cy.get('#id_description').type(app_description) + cy.get('#id_port').clear().type("8501") + cy.get('#id_image').clear().type(image_name) + cy.get('#id_volume').select(volume_display_text) + cy.get('#id_path').clear().type("/home") // fill out subdomain field - cy.get('[id="subdomain"]').find('button').click() - cy.get('[id="subdomain-add"]').find('[id="rn"]').type(subdomain) - cy.get('[id="subdomain-add"]').find('button').click() - cy.wait(5000) - cy.get('[id="subdomain"]').find('select#app_release_name option:selected').should('contain', subdomain) + cy.get('#id_subdomain').type(subdomain) + // create the app - cy.get('button').contains('Create').click() + cy.get('#submit-id-submit').contains('Submit').click() // check that the app was created with the correct subdomain cy.get('a').contains(app_name).should('have.attr', 'href').and('include', subdomain) // Try using the same subdomain the second time cy.log("Now trying to create an app with an already taken subdomain") cy.get('div.card-body:contains("' + app_type + '")').find('a:contains("Create")').click() + + cy.get('#id_name').type(app_name_2) + cy.get('#id_port').clear().type("8501") + cy.get('#id_image').clear().type(image_name) + // fill out subdomain field - cy.get('[id="subdomain"]').find('button').click() - cy.get('[id="subdomain-add"]').find('[id="rn"]').type(subdomain) - cy.get('[id="subdomain-add"]').find('button').click() - cy.wait(5000) - cy.get('[id="subdomain-add"]').find('[id="subdomain-invalid"]').should('be.visible') // display errror when same subdomain - cy.get('[id="subdomain-add"]').find('[id="rn"]').clear().type(subdomain_2) - cy.get('[id="subdomain-add"]').find('button').click() - cy.wait(5000) - cy.get('[id="subdomain-add"]').find('[id="subdomain-invalid"]').should('not.be.visible') // do not display error when different subdomain - cy.get('[id="subdomain"]').find('select#app_release_name option:selected').should('contain', subdomain_2) // and the newly added subdomain as selected again + cy.get('#id_subdomain').type(subdomain) + cy.get('#id_subdomain').blur(); + cy.get('#div_id_subdomain').should('contain.text', 'The subdomain is not available'); + + + cy.get('#id_subdomain').clear().type(subdomain_2) + cy.get('#id_subdomain').blur(); + cy.get('#div_id_subdomain').should('contain.text', 'The subdomain is available'); + // create the app + cy.get('#submit-id-submit').contains('Submit').click() + // check that the app was created with the correct subdomain + cy.get('a').contains(app_name_2).should('have.attr', 'href').and('include', subdomain_2) // Change subdomain of a previously created app cy.log("Now changing subdomain of an already created app") @@ -428,12 +440,9 @@ describe("Test deploying app", () => { cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.get('tr:contains("' + app_name + '")').find('i.bi-three-dots-vertical').click() cy.get('tr:contains("' + app_name + '")').find('a').contains("Settings").click() - cy.get('[id="subdomain"]').find('button').click() - cy.get('[id="subdomain-add"]').find('[id="rn"]').type(subdomain_3) - cy.get('[id="subdomain-add"]').find('button').click() - cy.wait(5000) - // update the app - cy.get('button').contains('Update').click() + cy.get('#id_subdomain').type(subdomain_3) + + cy.get('#submit-id-submit').contains('Submit').click() // check that the app was updated with the correct subdomain cy.get('a').contains(app_name).should('have.attr', 'href').and('include', subdomain_3) @@ -460,19 +469,19 @@ describe("Test deploying app", () => { // Create an app with project permissions cy.log("Now creating an app with a non-existent image reference - expecting Image Error") cy.get('div.card-body:contains("' + app_type + '")').find('a:contains("Create")').click() - cy.get('input[name=app_name]').type(app_name_statuses) - cy.get('textarea[name=app_description]').type(app_description) - cy.get('#permission').select('project') - cy.get('input[name="appconfig.port"]').type("8501") - cy.get('input[name="appconfig.image"]').type("hkqxqxkhkqwxhkxwh") // input random string - cy.get('button').contains('Create').click() + cy.get('#id_name').type(app_name_statuses) + cy.get('#id_description').type(app_description) + cy.get('#id_access').select('Project') + cy.get('#id_port').type("8501") + cy.get('#id_image').type("hkqxqxkhkqwxhkxwh") // input random string + cy.get('#submit-id-submit').contains('Submit').click() // check that the app was created cy.get('tr:contains("' + app_name_statuses + '")').find('span').should('contain', 'Image Error') cy.log("Now updating the app to give a correct image reference - expecting Running") cy.get('tr:contains("' + app_name_statuses + '")').find('i.bi-three-dots-vertical').click() cy.get('tr:contains("' + app_name_statuses + '")').find('a').contains('Settings').click() - cy.get('input[name="appconfig.image"]').clear().type(image_name) - cy.get('button').contains('Update').click() + cy.get('#id_image').clear().type(image_name) + cy.get('#submit-id-submit').contains('Submit').click() cy.get('tr:contains("' + app_name_statuses + '")').find('span').should('contain', 'Running') } else { cy.log('Skipped because create_resources is not true'); diff --git a/cypress/e2e/ui-tests/test-project-as-contributor.cy.js b/cypress/e2e/ui-tests/test-project-as-contributor.cy.js index db8a9cbaa..5395cb589 100644 --- a/cypress/e2e/ui-tests/test-project-as-contributor.cy.js +++ b/cypress/e2e/ui-tests/test-project-as-contributor.cy.js @@ -228,8 +228,8 @@ describe("Test project contributor user functionality", () => { // step 1. create 3 jupyter lab instances (current limit) Cypress._.times(3, () => { cy.get('[data-cy="create-app-card"]').contains('Jupyter Lab').parent().siblings().find('.btn').click() - cy.get('input[name=app_name]').type("e2e-create-jl") - cy.get('.btn-primary').contains('Create').click() + cy.get('#id_name').type("e2e-create-jl") + cy.get('#submit-id-submit').contains('Submit').click() }); // step 2. check that the button to create another one does not work cy.get('[data-cy="create-app-card"]').contains('Jupyter Lab').parent().siblings().find('.btn').should('not.have.attr', 'href') @@ -257,8 +257,8 @@ describe("Test project contributor user functionality", () => { // Names of projects to create const project_name = "e2e-create-proj-test" - // Create 5 projects (current limit) - Cypress._.times(5, () => { + // Create 10 projects (current limit) + Cypress._.times(10, () => { cy.visit("/projects/") cy.get("a").contains('New project').click() cy.get("a").contains('Create').first().click() @@ -276,7 +276,7 @@ describe("Test project contributor user functionality", () => { cy.request({url: "/projects/templates/", failOnStatusCode: false}).its('status').should('equal', 403) // Now delete all created projects - Cypress._.times(5, () => { + Cypress._.times(10, () => { cy.visit("/projects/") cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('.confirm-delete').click() .then((href) => { @@ -357,17 +357,17 @@ describe("Test project contributor user functionality", () => { // Create private app cy.log("Now creating a private app") cy.get('div.card-body:contains("' + app_type + '")').find('a:contains("Create")').click() - cy.get('input[name=app_name]').type(private_app_name) - cy.get('select[id=permission]').select('private') - cy.get('button').contains('Create').click() // create app + cy.get('#id_name').type(private_app_name) + cy.get('#id_access').select('Private') + cy.get('#submit-id-submit').contains('Submit').click() // create app cy.get('tr:contains("' + private_app_name + '")').find('span').should('contain', 'private') // check that the app got greated // Create project app cy.log("Now creating a project app") cy.get('div.card-body:contains("' + app_type + '")').find('a:contains("Create")').click() - cy.get('input[name=app_name]').type(project_app_name) - cy.get('select[id=permission]').select('project') - cy.get('button').contains('Create').click() // create app + cy.get('#id_name').type(project_app_name) + cy.get('#id_access').select('Project') + cy.get('#submit-id-submit').contains('Submit').click() // create app cy.get('tr:contains("' + project_app_name + '")').find('span').should('contain', 'project') // check that the app got greated // Give access to this project to a collaborator user @@ -453,7 +453,7 @@ describe("Test project contributor user functionality", () => { }) }) - it("can create a file management instance", () => { + it("can create a file management instance", { defaultCommandTimeout: 100000 }, () => { const project_name = "e2e-create-proj-test" cy.log("Creating a blank project") @@ -463,13 +463,9 @@ describe("Test project contributor user functionality", () => { cy.visit("/projects/") cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() - cy.get('div.card-body:contains("Activate File Manager")').find('a:contains("Activate")').click() - cy.get('button').contains("Activate").first().click() - cy.get('#manage-files > .card > .row').should('contain', 'File Manager is activated') + cy.get('div.card-body:contains("File Manager")').find('a:contains("Create")').click() + cy.get('#submit-id-submit').click() - // change the command to check for Created, Pending or Running - cy.get('#manage-files .card-header').find('span.badge').invoke('text').then((text) => { - expect(["Created", "Pending", "Running"]).to.include(text.trim()); - }); + cy.get('tr:contains("File Manager")').find('span').should('contain', 'Running') }) }) diff --git a/cypress/e2e/ui-tests/test-superuser-functionality.cy.js b/cypress/e2e/ui-tests/test-superuser-functionality.cy.js index 96e839a21..fb368fc19 100644 --- a/cypress/e2e/ui-tests/test-superuser-functionality.cy.js +++ b/cypress/e2e/ui-tests/test-superuser-functionality.cy.js @@ -60,11 +60,6 @@ describe("Test superuser access", () => { cy.get('h3').should('contain', project_name) cy.get('.card-text').should('contain', project_description) - cy.log("Checking that the correct deployment options are available (i.e. with extra deployment options)") - cy.get('.card-header').find('h5').should('contain', 'Develop') - cy.get('.card-header').find('h5').should('contain', 'Serve') - cy.get('.card-body').find('h5').should('contain', 'Shiny App (single copy)') - cy.get('.card-header').find('h5').should('contain', 'Additional options [admins only]') cy.log("Checking that project settings are available") cy.get('[data-cy="settings"]').click() @@ -73,8 +68,6 @@ describe("Test superuser access", () => { cy.log("Checking that the correct project settings are visible (i.e. with extra settings)") cy.get('.list-group').find('a').should('contain', 'Access') - cy.get('.list-group').find('a').should('contain', 'S3 storage') - cy.get('.list-group').find('a').should('contain', 'MLFlow') cy.get('.list-group').find('a').should('contain', 'Flavors') cy.get('.list-group').find('a').should('contain', 'Environments') @@ -132,10 +125,11 @@ describe("Test superuser access", () => { cy.log("Verifying that can edit the private app of a regular user") cy.get('tr:contains("' + private_app_name + '")').find('i.bi-three-dots-vertical').click() cy.get('tr:contains("' + private_app_name + '")').find('a').contains('Settings').click() - cy.get('input[name=app_name]').clear().type(private_app_name_2) // change name - cy.get('button').contains('Update').click() + cy.get('#id_name').clear().type(private_app_name_2) // change name + cy.get('#submit-id-submit').contains('Submit').click() cy.get('tr:contains("' + private_app_name_2 + '")').should('exist') // regular user's private app now has a different name - + cy.wait(10000) + cy.get('tr:contains("' + private_app_name_2 + '")').find('span').should('contain', 'Running') // add this because to make sure the app is running before deleting otherwise it gives an error, cy.log("Deleting a regular user's private app") cy.get('tr:contains("' + private_app_name_2 + '")').find('i.bi-three-dots-vertical').click() cy.get('tr:contains("' + private_app_name_2 + '")').find('a.confirm-delete').click() @@ -208,13 +202,13 @@ describe("Test superuser access", () => { cy.visit("/projects/") cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.get('div.card-body:contains("' + app_type + '")').find('a:contains("Create")').click() - cy.get('input[name=app_name]').type(app_name) - cy.get('textarea[name=app_description]').type(app_description) - cy.get('#permission').select('project') - cy.get('#flavor').select('2 vCPU, 4 GB RAM') - cy.get('input[name="appconfig.image"]').clear().type(image_name) - cy.get('input[name="appconfig.port"]').clear().type(image_port) - cy.get('button').contains('Create').click() + cy.get('#id_name').type(app_name) + cy.get('#id_description').type(app_description) + cy.get('#id_access').select('Project') + cy.get('#id_flavor').select('2 vCPU, 4 GB RAM') + cy.get('#id_image').clear().type(image_name) + cy.get('#id_port').clear().type(image_port) + cy.get('#submit-id-submit').contains('Submit').click() cy.get('tr:contains("' + app_name + '")').find('span').should('contain', 'Running') cy.log("Changing the flavor setting") @@ -222,9 +216,9 @@ describe("Test superuser access", () => { cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.get('tr:contains("' + app_name + '")').find('i.bi-three-dots-vertical').click() cy.get('tr:contains("' + app_name + '")').find('a').contains('Settings').click() - cy.get('#flavor').find(':selected').should('contain', '2 vCPU, 4 GB RAM') - cy.get('#flavor').select(new_flavor_name) - cy.get('button').contains('Update').click() + cy.get('#id_flavor').find(':selected').should('contain', '2 vCPU, 4 GB RAM') + cy.get('#id_flavor').select(new_flavor_name) + cy.get('#submit-id-submit').contains('Submit').click() cy.get('tr:contains("' + app_name + '")').find('span').should('contain', 'Running') cy.log("Checking that the new flavor setting was saved in the database") @@ -232,7 +226,7 @@ describe("Test superuser access", () => { cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.get('tr:contains("' + app_name + '")').find('i.bi-three-dots-vertical').click() cy.get('tr:contains("' + app_name + '")').find('a').contains('Settings').click() - cy.get('#flavor').find(':selected').should('contain', new_flavor_name) + cy.get('#id_flavor').find(':selected').should('contain', new_flavor_name) } else { cy.log('Skipped because create_resources is not true'); @@ -249,7 +243,10 @@ describe("Test superuser access", () => { }) }) - it("can create a persistent volume", () => { + it.skip("can create a persistent volume", () => { + // This test is not used, since creating PVCs like this is not the correct way any more. + // The correct way is to create a volume in the admin panel. + // Names of objects to create const project_name_pvc = "e2e-superuser-pvc-test" const volume_name = "e2e-project-vol" @@ -262,8 +259,8 @@ describe("Test superuser access", () => { cy.contains('.card-title', project_name_pvc).parents('.card-body').siblings('.card-footer').find('a:contains("Open")').first().click() cy.get('div.card-body:contains("Persistent Volume")').find('a:contains("Create")').click() - cy.get('input[name=app_name]').type(volume_name) - cy.get('button').contains('Create').click() + cy.get('#id_name').type(volume_name) + cy.get('#submit-id-submit').contains('Submit').click() cy.get('tr:contains("' + volume_name + '")').should('exist') // persistent volume has been created // This does not work in our CI. Disabled for now, needs to be enabled for runs against an instance of Serve running on the cluster @@ -294,8 +291,8 @@ describe("Test superuser access", () => { // Names of projects to create const project_name = "e2e-superuser-proj-limits-test" - cy.log("Create 5 projects (current limit for regular users)") - Cypress._.times(5, () => { + cy.log("Create 10 projects (current limit for regular users)") + Cypress._.times(10, () => { // better to write this out rather than use the createBlankProject command because then we can do a 5000 ms pause only once cy.visit("/projects/") cy.get("a").contains('New project').click() @@ -312,10 +309,10 @@ describe("Test superuser access", () => { cy.log("Create one more project to check it is possible to bypass the limit") cy.createBlankProject(project_name) cy.visit("/projects/") - cy.get('h5:contains("' + project_name + '")').its('length').should('eq', 6) // check that the superuser now bypassed the limit for regular users + cy.get('h5:contains("' + project_name + '")').its('length').should('eq', 11) // check that the superuser now bypassed the limit for regular users cy.log("Now delete all created projects") - Cypress._.times(6, () => { + Cypress._.times(11, () => { cy.visit("/projects/") cy.contains('.card-title', project_name).parents('.card-body').siblings('.card-footer').find('.confirm-delete').click() .then((href) => { @@ -337,15 +334,15 @@ describe("Test superuser access", () => { cy.log("Create 3 jupyter lab instances (current limit)") Cypress._.times(3, () => { cy.get('[data-cy="create-app-card"]').contains('Jupyter Lab').parent().siblings().find('.btn').click() - cy.get('input[name=app_name]').type(app_name) - cy.get('.btn-primary').contains('Create').click() + cy.get('#id_name').type(app_name) + cy.get('#submit-id-submit').contains('Submit').click() }); cy.log("Check that the button to create another one still works") cy.get('[data-cy="create-app-card"]').contains('Jupyter Lab').parent().siblings().find('.btn').should('have.attr', 'href') cy.log("Check that it is possible to create another one and therefore bypass the limit") cy.get('[data-cy="create-app-card"]').contains('Jupyter Lab').parent().siblings().find('.btn').click() - cy.get('input[name=app_name]').type(app_name) - cy.get('.btn-primary').contains('Create').click() + cy.get('#id_name').type(app_name) + cy.get('#submit-id-submit').contains('Submit').click() cy.get('tr:contains("' + app_name + '")').its('length').should('eq', 4) // we now have an extra app }) diff --git a/fixtures/apps_fixtures.json b/fixtures/apps_fixtures.json index 2a10a45ae..9cf122b40 100644 --- a/fixtures/apps_fixtures.json +++ b/fixtures/apps_fixtures.json @@ -2,47 +2,12 @@ { "fields": { "category": "develop", - "chart": "ghcr.io/scilifelabdatacentre/serve-charts/lab:1.0.0", + "chart": "ghcr.io/scilifelabdatacentre/serve-charts/lab:1.0.1", "created_on": "2021-02-19T21:34:37.815Z", "description": "", "logo": "jupyter-lab-logo.svg", "name": "Jupyter Lab", "priority": "500", - "settings": { - "apps": { - "Persistent Volume": "many" - }, - "default_values": { - "port": "80", - "targetport": "8888" - }, - "environment": { - "name": "from", - "quantity": "one", - "title": "Image", - "type": "match" - }, - "export-cli": "True", - "flavor": "one", - "permissions": { - "private": { - "option": "true", - "value": "false" - }, - "project": { - "option": "true", - "value": "true" - }, - "public": { - "option": "false", - "value": "false" - }, - "link": { - "option": "false", - "value": "false" - } - } - }, "slug": "jupyter-lab", "table_field": { "url": "https://{{ release }}.{{ global.domain }}" @@ -61,33 +26,6 @@ "logo": "filemanager-logo.svg", "name": "File Manager", "priority": "200", - "settings": { - "apps": { - "Persistent Volume": "one" - }, - "default_values": { - "port": "8080", - "targetport": "8080" - }, - "permissions": { - "private": { - "option": "true", - "value": "false" - }, - "project": { - "option": "true", - "value": "true" - }, - "public": { - "option": "false", - "value": "false" - }, - "link": { - "option": "false", - "value": "false" - } - } - }, "slug": "filemanager", "table_field": { "url": "https://{{ release }}.{{ global.domain }}" @@ -325,47 +263,6 @@ "description": "", "name": "Persistent Volume", "priority": "600", - "settings": { - "default_values": { - "port": "port", - "targetport": "targetport" - }, - "permissions": { - "private": { - "option": "true", - "value": "false" - }, - "project": { - "option": "true", - "value": "true" - }, - "public": { - "option": "false", - "value": "false" - }, - "link": { - "option": "false", - "value": "false" - } - }, - "volume": { - "accessModes": { - "default": "ReadWriteMany", - "title": "AccessModes", - "type": "string" - }, - "size": { - "default": "1Gi", - "title": "Size", - "type": "string" - }, - "storageClass": { - "default": "", - "title": "StorageClass", - "type": "string" - } - } - }, "slug": "volumeK8s", "table_field": {}, "updated_on": "2021-03-10T19:45:03.927Z" @@ -381,35 +278,6 @@ "description": "", "logo": "vscode-logo.svg", "name": "VS Code", - "settings": { - "apps": { - "Persistent Volume": "many" - }, - "default_values": { - "port": "80", - "targetport": "8080" - }, - "export-cli": "True", - "flavor": "one", - "permissions": { - "private": { - "option": "true", - "value": "false" - }, - "project": { - "option": "true", - "value": "true" - }, - "public": { - "option": "false", - "value": "false" - }, - "link": { - "option": "false", - "value": "false" - } - } - }, "slug": "vscode", "table_field": { "url": "https://{{ release }}.{{ global.domain }}" @@ -449,63 +317,6 @@ "logo": "default-logo.svg", "name": "Custom App", "priority": "400", - "settings": { - "appconfig": { - "image": { - "default": "registry/repository/image:tag", - "title": "Image", - "type": "string" - }, - "meta": { - "title": "Docker image configurations" - }, - "path": { - "default": "", - "title": "Path to folder for persistent storage (leave blank if Persistent Volume is set to None)", - "type": "string" - }, - "port": { - "default": "8000", - "title": "Port", - "type": "number" - }, - "userid": { - "default": "1000", - "title": "User ID", - "type": "number" - } - }, - "apps": { - "Persistent Volume": "one" - }, - "default_values": { - "port": "80", - "targetport": "8000" - }, - "flavor": "one", - "logs": [ - "serve" - ], - "permissions": { - "private": { - "option": "true", - "value": "false" - }, - "project": { - "option": "true", - "value": "true" - }, - "public": { - "option": "true", - "value": "false" - }, - "link": { - "option": "true", - "value": "false" - } - }, - "publishable": "true" - }, "slug": "customapp", "table_field": { "url": "https://{{ release }}.{{ global.domain }}" @@ -524,34 +335,6 @@ "logo": "rstudio-logo.svg", "name": "RStudio", "priority": "600", - "settings": { - "apps": { - "Persistent Volume": "many" - }, - "default_values": { - "port": "80", - "targetport": "8787" - }, - "flavor": "one", - "permissions": { - "private": { - "option": "true", - "value": "false" - }, - "project": { - "option": "true", - "value": "true" - }, - "public": { - "option": "false", - "value": "false" - }, - "link": { - "option": "false", - "value": "false" - } - } - }, "slug": "rstudio", "table_field": { "url": "https://{{ release }}.{{ global.domain }}" @@ -570,50 +353,6 @@ "logo": "dashapp-logo.svg", "name": "Dash App", "priority": "400", - "settings": { - "appconfig": { - "image": { - "default": "registry/repository/image:tag", - "title": "Image", - "type": "string" - }, - "meta": { - "title": "Docker image onfigurations" - }, - "port": { - "default": "8000", - "title": "Port", - "type": "number" - } - }, - "default_values": { - "port": "80", - "targetport": "8000" - }, - "flavor": "one", - "logs": [ - "serve" - ], - "permissions": { - "private": { - "option": "true", - "value": "false" - }, - "project": { - "option": "true", - "value": "true" - }, - "public": { - "option": "true", - "value": "false" - }, - "link": { - "option": "true", - "value": "false" - } - }, - "publishable": "true" - }, "slug": "dashapp", "table_field": { "url": "https://{{ release }}.{{ global.domain }}" @@ -626,56 +365,12 @@ { "fields": { "category": "serve", - "chart": "ghcr.io/scilifelabdatacentre/serve-charts/shinyapp:1.0.1", + "chart": "ghcr.io/scilifelabdatacentre/serve-charts/shinyapp:1.0.2", "created_on": "2023-08-25T21:34:37.815Z", - "description": "Shiny app without ShinyProxy as a middle layer. [admins only]", + "description": "", "logo": "shinyapp-logo.svg", - "name": "Shiny App (single copy)", + "name": "Shiny App", "priority": "400", - "settings": { - "appconfig": { - "image": { - "default": "registry/repository/image:tag", - "title": "Image", - "type": "string" - }, - "meta": { - "title": "Docker image configurations" - }, - "port": { - "default": "3838", - "title": "Port", - "type": "number" - } - }, - "default_values": { - "port": "80", - "targetport": "3838" - }, - "flavor": "one", - "logs": [ - "serve" - ], - "permissions": { - "private": { - "option": "true", - "value": "false" - }, - "project": { - "option": "true", - "value": "true" - }, - "public": { - "option": "true", - "value": "false" - }, - "link": { - "option": "true", - "value": "false" - } - }, - "publishable": "true" - }, "slug": "shinyapp", "table_field": { "url": "https://{{ release }}.{{ global.domain }}" @@ -688,71 +383,12 @@ { "fields": { "category": "serve", - "chart": "ghcr.io/scilifelabdatacentre/serve-charts/shinyproxy:1.1.0", + "chart": "ghcr.io/scilifelabdatacentre/serve-charts/shinyproxy:1.3.1", "created_on": "2023-08-25T21:34:37.815Z", "description": "", "logo": "shinyapp-logo.svg", - "name": "Shiny App", + "name": "ShinyProxy App", "priority": "400", - "settings": { - "appconfig": { - "image": { - "default": "registry/repository/image:tag", - "title": "Image", - "type": "string" - }, - "meta": { - "title": "Docker image configurations" - }, - "port": { - "default": "3838", - "title": "Port", - "type": "number" - }, - "proxyheartbeatrate": { - "default": "10000", - "title": "Proxy heartbeat rate", - "type": "number" - }, - "proxyheartbeattimeout": { - "default": "60000", - "title": "Proxy heartbeat timeout", - "type": "number" - }, - "proxycontainerwaittime": { - "default": "30000", - "title": "Proxy container wait time", - "type": "number" - } - }, - "default_values": { - "port": "80", - "targetport": "3838" - }, - "flavor": "one", - "logs": [ - "serve" - ], - "permissions": { - "private": { - "option": "true", - "value": "false" - }, - "project": { - "option": "true", - "value": "true" - }, - "public": { - "option": "true", - "value": "false" - }, - "link": { - "option": "true", - "value": "false" - } - }, - "publishable": "true" - }, "slug": "shinyproxyapp", "table_field": { "url": "https://{{ release }}.{{ global.domain }}" @@ -831,52 +467,6 @@ "model": "apps.apps", "pk": 26 }, - { - "fields": { - "category": "admin-apps", - "chart": "ghcr.io/scilifelabdatacentre/serve-charts/filemanager:1.0.2", - "created_on": "2023-02-19T21:34:37.815Z", - "description": "", - "logo": "filemanager-logo.svg", - "name": "File Manager Admin", - "priority": "200", - "settings": { - "apps": { - "Persistent Volume": "one" - }, - "default_values": { - "port": "8080", - "targetport": "8080" - }, - "flavor": "one", - "permissions": { - "private": { - "option": "true", - "value": "false" - }, - "project": { - "option": "true", - "value": "true" - }, - "public": { - "option": "false", - "value": "false" - }, - "link": { - "option": "true", - "value": "false" - } - } - }, - "slug": "filemanager-admin", - "table_field": { - "url": "https://{{ release }}.{{ global.domain }}" - }, - "updated_on": "2023-03-10T19:45:03.927Z" - }, - "model": "apps.apps", - "pk": 27 - }, { "fields": { "category": "serve", @@ -886,38 +476,6 @@ "logo": "tissuumaps-logo.svg", "name": "TissUUmaps App", "priority": "400", - "settings": { - "apps": { - "Persistent Volume": "one" - }, - "default_values": { - "port": "80", - "targetport": "80" - }, - "flavor": "one", - "logs": [ - "serve" - ], - "permissions": { - "private": { - "option": "true", - "value": "true" - }, - "project": { - "option": "true", - "value": "false" - }, - "public": { - "option": "true", - "value": "false" - }, - "link": { - "option": "true", - "value": "false" - } - }, - "publishable": "true" - }, "slug": "tissuumaps", "table_field": { "url": "https://{{ release }}.{{ global.domain }}" diff --git a/fixtures/periodic_tasks_fixtures.json b/fixtures/periodic_tasks_fixtures.json index 5c3f3ef53..939409ab9 100644 --- a/fixtures/periodic_tasks_fixtures.json +++ b/fixtures/periodic_tasks_fixtures.json @@ -27,62 +27,7 @@ "model": "django_celery_beat.periodictask", "pk": 1 }, - { - "fields": { - "args": "[]", - "clocked": null, - "crontab": null, - "date_changed": "2021-02-26T14:03:40.168Z", - "description": "", - "enabled": true, - "exchange": null, - "expire_seconds": null, - "expires": null, - "headers": "{}", - "interval": 1, - "kwargs": "{}", - "last_run_at": "2021-02-26T14:03:37.169Z", - "name": "sync_mlflow_models", - "one_off": false, - "priority": null, - "queue": null, - "routing_key": null, - "solar": null, - "start_time": null, - "task": "apps.tasks.sync_mlflow_models", - "total_run_count": 174 - }, - "model": "django_celery_beat.periodictask", - "pk": 4 - }, - { - "fields": { - "args": "[]", - "clocked": null, - "crontab": 1, - "date_changed": "2021-02-26T14:03:40.168Z", - "description": "", - "enabled": true, - "exchange": null, - "expire_seconds": null, - "expires": null, - "headers": "{}", - "interval": null, - "kwargs": "{}", - "last_run_at": "2021-02-26T14:03:37.169Z", - "name": "purge_tasks", - "one_off": false, - "priority": null, - "queue": null, - "routing_key": null, - "solar": null, - "start_time": null, - "task": "apps.tasks.purge_tasks", - "total_run_count": 174 - }, - "model": "django_celery_beat.periodictask", - "pk": 5 - }, + { "fields": { "args": "[]", diff --git a/fixtures/projects_templates.json b/fixtures/projects_templates.json index 44c959664..fc59f871c 100644 --- a/fixtures/projects_templates.json +++ b/fixtures/projects_templates.json @@ -1,24 +1,15 @@ [ { "fields": { - "description": "Use this template if you intend to only deploy apps or use integrated development environments (IDEs).", + "description": "Use this template if you intend to deploy apps or ML models packaged into containers, or if you intend to use web-based notebooks.", "name": "Default project", "slug": "blank", + "available_apps": [8, 9, 19, 21, 22, 23, 24, 28], "template": { "apps": { - "project-netpolicy": { - "permission": "private", - "slug": "netpolicy" - }, - "project-vol": { - "app_id": "", - "app_release_name": "", - "permission": "project", - "slug": "volumeK8s", - "storageClass": "microk8s-hostpath", - "volume.accessModes": "ReadWriteMany", - "volume.size": "1Gi", - "volume.storageClass": "microk8s-hostpath" + + "netpolicy": { + "name": "project-netpolicy" } }, "environments": { @@ -38,6 +29,11 @@ "repository": "ghcr.io/scilifelabdatacentre" } }, + "volumes": { + "project-vol": { + "size": "1" + } + }, "flavors": { "2 vCPU, 4 GB RAM": { "cpu": { @@ -65,31 +61,27 @@ }, { "fields": { - "description": "Use this template if you intend to deploy machine learning models using the serving functionality.", - "name": "Project with ML serving", + "description": "This project type allows to deploy machine learning models using the specialized model serving frameworks.", + "name": "Project with specialized ML serving", "slug": "default", "template": { "apps": { - "project-netpolicy": { - "permission": "private", - "slug": "netpolicy" + "netpolicy": { + "name": "project-netpolicy" }, - "project-vol": { - "app_id": "", - "app_release_name": "", - "permission": "project", - "slug": "volumeK8s", - "storageClass": "microk8s-hostpath", - "volume.accessModes": "ReadWriteMany", - "volume.size": "1Gi", - "volume.storageClass": "microk8s-hostpath" + "jupyter-lab": { + "name": "My Jupyter Lab", + "description": "Deployed via project template", + "volume": "project-vol", + "access": "private", + "flavor": "2 vCPU, 4 GB RAM" }, - "project-filemanager": { - "app:volumeK8s": [ - "project-vol" - ], - "permission": "project", - "slug": "filemanager-admin" + "filemanager": { + "name": "project-filemanager", + "volume": "project-vol", + "persistent": "True", + "flavor": "2 vCPU, 4 GB RAM", + "access": "private" } }, "environments": { @@ -109,6 +101,11 @@ "repository": "ghcr.io/scilifelabdatacentre" } }, + "volumes": { + "project-vol": { + "size": "1" + } + }, "flavors": { "2 vCPU, 4 GB RAM": { "cpu": { diff --git a/models/.github/dependabot.yml b/models/.github/dependabot.yml deleted file mode 100644 index 9e5479c22..000000000 --- a/models/.github/dependabot.yml +++ /dev/null @@ -1,22 +0,0 @@ -version: 2 -updates: - - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - target-branch: "develop" - labels: - - "github-actions dependencies" - - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - # Raise pull requests for version updates - # to pip against the `develop` branch - target-branch: "develop" - # Labels on pull requests for version updates only - labels: - - "pip dependencies" diff --git a/models/.github/workflows/branch-name-check.yaml b/models/.github/workflows/branch-name-check.yaml deleted file mode 100644 index 03c9b39bf..000000000 --- a/models/.github/workflows/branch-name-check.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: "branch name check" - -on: - push: - branches-ignore: - - develop - - main - -env: - BRANCH_REGEX: '^((feature|hotfix|bug|docs|dependabot|fix)\/.+)|(release\/v((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?))$' - -jobs: - branch-name-check: - runs-on: ubuntu-20.04 - steps: - - name: checkout - uses: actions/checkout@v2 - - - name: branch name check - run: | - git rev-parse --abbrev-ref HEAD | grep -P "$BRANCH_REGEX" diff --git a/models/.github/workflows/code-checks.yaml b/models/.github/workflows/code-checks.yaml deleted file mode 100644 index 68676755a..000000000 --- a/models/.github/workflows/code-checks.yaml +++ /dev/null @@ -1,52 +0,0 @@ -name: Code checks - -on: - push: - branches: - - main - - develop - pull_request: - branches: - - main - - develop - release: - types: [published] - -jobs: - check-code: - - runs-on: ubuntu-20.04 - services: - postgres: - image: postgres - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - - - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - #Python version should be the same as the base image in Dockerfile - python-version: "3.8.10" - - name: Install dependencies - - run: | - python -m pip install --upgrade pip - pip install black==22.10.0 isort flake8 - if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi - - name: Check Python imports - run: | - isort . --check --diff --skip migrations --skip .venv --profile black -p studio -p projects -p models -p apps -p portal --line-length 79 - - name: Check Python formatting - run: | - black . --check --diff --line-length 79 --exclude migrations - - name: Check Python linting - run: | - flake8 . --exclude migrations diff --git a/models/.gitignore b/models/.gitignore deleted file mode 100644 index b6e47617d..000000000 --- a/models/.gitignore +++ /dev/null @@ -1,129 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ diff --git a/models/LICENSE b/models/LICENSE deleted file mode 100644 index 261eeb9e9..000000000 --- a/models/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/models/MANIFEST.in b/models/MANIFEST.in deleted file mode 100644 index f621c09e4..000000000 --- a/models/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.md -recursive-include models/templates * diff --git a/models/README.md b/models/README.md deleted file mode 100644 index 7efda4a3d..000000000 --- a/models/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# studio-models - -Models Django module for [Studio/STACKn](https://github.com/scaleoutsystems/stackn). To include source package in a Django project: - -``` -$ python3 -m venv .venv -$ source .venv/bin/activate -$ pip install . -``` -And add to installed apps in settings.py: - -``` -INSTALLED_APPS=[ - "models" -] -``` - -For a complete project follow the link above and navigate to settings.py diff --git a/studio/migrations/models/0001_initial.py b/models/migrations/0001_initial.py similarity index 96% rename from studio/migrations/models/0001_initial.py rename to models/migrations/0001_initial.py index eafcdc77d..bde819f25 100644 --- a/studio/migrations/models/0001_initial.py +++ b/models/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.5 on 2023-11-16 08:07 +# Generated by Django 5.0.2 on 2024-05-27 07:28 import django.db.models.deletion import django.db.models.manager @@ -26,26 +26,19 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name="Tagulous_Model_tags", + name="Metadata", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=255, unique=True)), - ("slug", models.SlugField()), - ( - "count", - models.IntegerField(default=0, help_text="Internal counter of how many times this tag is in use"), - ), - ( - "protected", - models.BooleanField(default=False, help_text="Will not be deleted when the count reaches 0"), - ), + ("run_id", models.CharField(max_length=32)), + ("trained_model", models.CharField(default="", max_length=32)), + ("project", models.CharField(default="", max_length=255)), + ("model_details", models.TextField(blank=True)), + ("parameters", models.TextField(blank=True)), + ("metrics", models.TextField(blank=True)), ], options={ - "ordering": ("name",), - "abstract": False, - "unique_together": {("slug",)}, + "unique_together": {("run_id", "trained_model")}, }, - bases=(tagulous.models.models.BaseTagModel, models.Model), ), migrations.CreateModel( name="ModelLog", @@ -73,19 +66,26 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name="Metadata", + name="Tagulous_Model_tags", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("run_id", models.CharField(max_length=32)), - ("trained_model", models.CharField(default="", max_length=32)), - ("project", models.CharField(default="", max_length=255)), - ("model_details", models.TextField(blank=True)), - ("parameters", models.TextField(blank=True)), - ("metrics", models.TextField(blank=True)), + ("name", models.CharField(max_length=255, unique=True)), + ("slug", models.SlugField()), + ( + "count", + models.IntegerField(default=0, help_text="Internal counter of how many times this tag is in use"), + ), + ( + "protected", + models.BooleanField(default=False, help_text="Will not be deleted when the count reaches 0"), + ), ], options={ - "unique_together": {("run_id", "trained_model")}, + "ordering": ("name",), + "abstract": False, + "unique_together": {("slug",)}, }, + bases=(tagulous.models.models.BaseTagModel, models.Model), ), migrations.CreateModel( name="Model", @@ -131,7 +131,6 @@ class Migration(migrations.Migration): to="projects.environment", ), ), - ("object_type", models.ManyToManyField(blank=True, to="models.objecttype")), ( "project", models.ForeignKey( @@ -141,12 +140,7 @@ class Migration(migrations.Migration): to="projects.project", ), ), - ( - "s3", - models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="projects.s3" - ), - ), + ("object_type", models.ManyToManyField(blank=True, to="models.objecttype")), ( "tags", tagulous.models.fields.TagField( diff --git a/models/models.py b/models/models.py index 7c7662f26..2a861d76a 100644 --- a/models/models.py +++ b/models/models.py @@ -99,7 +99,7 @@ class Model(models.Model): resource = models.URLField(max_length=2048, null=True, blank=True) object_type = models.ManyToManyField(ObjectType, blank=True) url = models.URLField(max_length=512, null=True, blank=True) - s3 = models.ForeignKey("projects.S3", null=True, blank=True, on_delete=models.CASCADE) + bucket = models.CharField(max_length=200, null=True, blank=True, default="models") path = models.CharField(max_length=200, null=True, blank=True, default="models") uploaded_at = models.DateTimeField(auto_now_add=True) diff --git a/models/setup.py b/models/setup.py deleted file mode 100644 index d45fa4f15..000000000 --- a/models/setup.py +++ /dev/null @@ -1,33 +0,0 @@ -from setuptools import setup - -setup( - name="studio-models", - version="0.0.1", - description="""Django app for handling portal in Studio""", - url="https://www.scaleoutsystems.com", - include_package_data=True, - package=["models"], - package_dir={"models": "."}, - python_requires=">=3.6,<4", - install_requires=[ - "django==4.2.1", - "requests==2.31.0", - "django-guardian==2.4.0", - "Pillow==9.4.0", - "Markdown==3.4.1", - "django-tagulous==1.3.3", - "minio==7.0.2", - "s3fs==2022.1.0", - ], - license="Copyright Scaleout Systems AB. See license for details", - zip_safe=False, - keywords="", - classifiers=[ - "Development Status :: 2 - Pre-Alpha", - "Intended Audience :: Developers", - "Natural Language :: English", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - ], -) diff --git a/models/tests.py b/models/tests.py index 1ec96e7d7..e69de29bb 100644 --- a/models/tests.py +++ b/models/tests.py @@ -1,418 +0,0 @@ -import os - -import boto3 -import pytest -from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase -from django.urls import reverse -from moto.server import ThreadedMotoServer - -from projects.models import S3, Project - -from . import views -from .models import Model, ObjectType - -User = get_user_model() - -os.environ["AWS_ACCESS_KEY_ID"] = "testing" -os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" -os.environ["AWS_SECURITY_TOKEN"] = "testing" -os.environ["AWS_SESSION_TOKEN"] = "testing" -os.environ["AWS_DEFAULT_REGION"] = "us-east-1" - -test_user = {"username": "foo1", "email": "foo@test.com", "password": "bar"} - - -class ModelViewTests(TestCase): - bucket_name = "test-bucket" - - def setUp(self): - # Set up mocked S3 - self.server = ThreadedMotoServer(port=5000) - self.server.start() - - s3 = boto3.resource("s3", endpoint_url="http://localhost:5000") - bucket = s3.Bucket(self.bucket_name) - bucket.create() - s3.meta.client.put_object(Bucket=self.bucket_name, Key="public_uid", Body=b"test") - s3.meta.client.put_object(Bucket=self.bucket_name, Key="test_uid", Body=b"test") - # Create user - self.user = User.objects.create_user(test_user["username"], test_user["email"], test_user["password"]) - - self.project = Project.objects.create_project(name="test-perm", owner=self.user, description="") - - new_model = Model( - uid="test_uid", - name="test", - bucket=self.bucket_name, - description="model_description", - model_card="", - project=self.project, - access="PR", - s3=S3.objects.create( - access_key=os.environ.get("AWS_ACCESS_KEY_ID"), - host="localhost:5000", - secret_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), - owner=self.user, - project=self.project, - ), - ) - new_model.save() - self.private_model = new_model - public_model = Model( - uid="public_uid", - name="public", - bucket=self.bucket_name, - description="model_description", - model_card="", - project=self.project, - access="PR", - s3=S3.objects.create( - access_key=os.environ.get("AWS_ACCESS_KEY_ID"), - host="localhost:5000", - secret_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), - owner=self.user, - project=self.project, - ), - ) - public_model.save() - self.client.login(username=test_user["email"], password="bar") - self.client.post( - reverse( - "models:publish_model", - kwargs={ - "user": self.user, - "project": self.project.slug, - "id": public_model.id, - }, - ) - ) - self.client.post( - reverse( - "models:publish_model", - kwargs={ - "user": self.user, - "project": self.project.slug, - "id": new_model.id, - }, - ) - ) - self.client.post( - reverse( - "models:unpublish_model", - kwargs={ - "user": self.user, - "project": self.project.slug, - "id": new_model.id, - }, - ) - ) - - def tearDown(self): - self.server.stop() - - def test_models_view(self): - # Get correct request - request = RequestFactory().get(reverse("models:index")) - response = views.index(request, user=self.user, project=self.project) - - # Check status code - assert response.status_code == 200 - assert "Models | SciLifeLab Serve (beta)" in response.content.decode() - - @pytest.mark.skip(reason="It's not working") - def test_public_model_details_view(self): - response = self.client.get(reverse("models:details_public", kwargs={"id": 1})) - - # Check status code - assert response.status_code == 200 - self.assertTemplateUsed(response, "models/models_details_public.html") - assert "Model Details" in response.content.decode() - assert "Model public Details | SciLifeLab Serve (beta)" in response.content.decode() - - @pytest.mark.skip(reason="Could not make this work") - def test_private_model_details_view(self): - response = self.client.get( - reverse( - "models:details_private", - kwargs={"user": self.user, "project": self.project.name, "id": self.private_model.id}, - ) - ) - - # Check status code - assert response.status_code == 200 - self.assertTemplateUsed(response, "models/models_details_private.html") - assert "Model Details" in response.content.decode() - assert "Private model test Details | SciLifeLab Serve (beta)" in response.content.decode() - - @pytest.mark.skip(reason="I am even not sure that it's being invoked") - def test_model_create_view(self): - response = self.client.get(reverse("models:create", kwargs={"user": self.user, "project": self.project.name})) - - # Check status code - assert response.status_code == 200 - self.assertTemplateUsed(response, "models/models_details_private.html") - assert "Model Details" in response.content.decode() - assert "Private model test Details | SciLifeLab Serve (beta)" in response.content.decode() - - @pytest.mark.skip(reason="I think that the project is not created for this") - def test_models_list_view(self): - response = self.client.get(reverse("models:list", kwargs={"user": self.user, "project": self.project.name})) - # Check status code - assert response.status_code == 200 - self.assertTemplateUsed(response, "models/models_list.html") - assert "Models" in response.content.decode() - assert f"{self.project.name} - Models | SciLifeLab Serve (beta)" in response.content.decode() - - -class ModelViewForbidden(TestCase): - def setUp(self): - user = User.objects.create_user(test_user["username"], test_user["email"], test_user["password"]) - - project = Project.objects.create_project(name="test-perm", owner=user, description="") - - new_model = Model( - uid="test_uid", - name="test", - bucket="", - description="model_description", - model_card="", - project=project, - access="PR", - ) - new_model.save() - - user = User.objects.create_user("member", "bar@test.com", "bar") - self.client.login(username="bar@test.com", password="bar") - - def test_forbidden_models_list(self): - """ - Test non-project member not allowed to access /models - """ - owner = User.objects.get(username=test_user["email"]) - project = Project.objects.get(name="test-perm") - response = self.client.get(reverse("models:list", kwargs={"user": owner, "project": project.slug})) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - assert "Forbidden | SciLifeLab Serve (beta)" in response.content.decode() - - def test_forbidden_models_create(self): - """ - Test non-project member not allowed to access /models/create - """ - owner = User.objects.get(username=test_user["email"]) - project = Project.objects.get(name="test-perm") - response = self.client.get( - reverse( - "models:create", - kwargs={"user": owner, "project": project.slug}, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_models_details_private(self): - """ - Test non-project member not allowed to access /models/ - """ - owner = User.objects.get(username=test_user["email"]) - project = Project.objects.get(name="test-perm") - model = Model.objects.get(name="test") - response = self.client.get( - reverse( - "models:details_private", - kwargs={ - "user": owner, - "project": project.slug, - "id": model.id, - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_models_delete(self): - """ - Test non-project member not allowed to access /models//delete - """ - owner = User.objects.get(username=test_user["email"]) - project = Project.objects.get(name="test-perm") - model = Model.objects.get(name="test") - response = self.client.get( - reverse( - "models:delete", - kwargs={ - "user": owner, - "project": project.slug, - "id": model.id, - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_models_publish(self): - """ - Test non-project member not allowed to access /models//publish - """ - owner = User.objects.get(username=test_user["email"]) - project = Project.objects.get(name="test-perm") - model = Model.objects.get(name="test") - response = self.client.get( - reverse( - "models:publish_model", - kwargs={ - "user": owner, - "project": project.slug, - "id": model.id, - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_models_add_tag(self): - """ - Test non-project member not allowed to access /models//add_tag - """ - owner = User.objects.get(username=test_user["email"]) - project = Project.objects.get(name="test-perm") - model = Model.objects.get(name="test") - response = self.client.get( - reverse( - "models:add_tag_private", - kwargs={ - "user": owner, - "project": project.slug, - "id": model.id, - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_models_remove_tag(self): - """ - Test non-project member not allowed to - access /models//remove_tag - """ - owner = User.objects.get(username=test_user["email"]) - project = Project.objects.get(name="test-perm") - model = Model.objects.get(name="test") - response = self.client.get( - reverse( - "models:remove_tag_private", - kwargs={ - "user": owner, - "project": project.slug, - "id": model.id, - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_models_unpublidh(self): - """ - Test non-project member not allowed to - access /models//unpublish - """ - owner = User.objects.get(username=test_user["email"]) - project = Project.objects.get(name="test-perm") - model = Model.objects.get(name="test") - response = self.client.get( - reverse( - "models:unpublish_model", - kwargs={ - "user": owner, - "project": project.slug, - "id": model.id, - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_models_access(self): - """ - Test non-project member not allowed to access /models//access - """ - owner = User.objects.get(username=test_user["email"]) - project = Project.objects.get(name="test-perm") - model = Model.objects.get(name="test") - response = self.client.get( - reverse( - "models:change_access", - kwargs={ - "user": owner, - "project": project.slug, - "id": model.id, - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_models_upload(self): - """ - Test non-project member not allowed to access /models//upload - """ - owner = User.objects.get(username=test_user["email"]) - project = Project.objects.get(name="test-perm") - model = Model.objects.get(name="test") - response = self.client.get( - reverse( - "models:upload_model_headline", - kwargs={ - "user": owner, - "project": project.slug, - "id": model.id, - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_models_docker(self): - """ - Test non-project member not allowed to access /models//docker - """ - owner = User.objects.get(username=test_user["email"]) - project = Project.objects.get(name="test-perm") - model = Model.objects.get(name="test") - response = self.client.get( - reverse( - "models:add_docker_image", - kwargs={ - "user": owner, - "project": project.slug, - "id": model.id, - }, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - -class TestFixtures(TestCase): - fixtures = ["models/fixtures/objecttype_fixtures.json"] - - def test_objecttype_mlflow(self): - obj_type = ObjectType.objects.get(slug="mlflow") - self.assertEqual(obj_type.slug, "mlflow") - - def test_objecttype_tfmodel(self): - obj_type = ObjectType.objects.get(slug="tensorflow") - self.assertEqual(obj_type.slug, "tensorflow") - - def test_objecttype_pythonmodel(self): - obj_type = ObjectType.objects.get(slug="python") - self.assertEqual(obj_type.slug, "python") - - def test_objecttype_defaultmodel(self): - obj_type = ObjectType.objects.get(slug="default") - self.assertEqual(obj_type.slug, "default") - - def test_objecttype_pytorchmodel(self): - obj_type = ObjectType.objects.get(slug="pytorch") - self.assertEqual(obj_type.slug, "pytorch") diff --git a/models/views.py b/models/views.py index bab5c04cc..bfb358f53 100644 --- a/models/views.py +++ b/models/views.py @@ -27,7 +27,7 @@ logger = logging.getLogger(__name__) Apps = apps.get_model(app_label=settings.APPS_MODEL) -AppInstance = apps.get_model(app_label=settings.APPINSTANCE_MODEL) +BaseAppInstance = apps.get_model(app_label="apps.BaseAppInstance") Project = apps.get_model(app_label=settings.PROJECTS_MODEL) ProjectLog = apps.get_model(app_label=settings.PROJECTLOG_MODEL) @@ -53,7 +53,7 @@ def get(self, request, user, project): # For showing the persistent volumes currently # available within the project volumeK8s_set = Apps.objects.get(slug="volumeK8s") - volumes = AppInstance.objects.filter(Q(app=volumeK8s_set), Q(state="Running")) + volumes = BaseAppInstance.objects.filter(Q(app=volumeK8s_set), Q(app_status__status="Running")) # Passing the current project to the view/template project = ( @@ -75,7 +75,7 @@ def get(self, request, user, project): # for the time being is hard-coded to # jupyter-lab where usually models are trained app_set = Apps.objects.get(slug="jupyter-lab") - apps = AppInstance.objects.filter(Q(app=app_set), Q(state="Running")) + apps = BaseAppInstance.objects.filter(Q(app=app_set), Q(app_status__status="Running")) return render(request, self.template, locals()) @@ -125,7 +125,9 @@ def post(self, request, user, project): # The minio sidecar does this. # First find the minio release name minio_set = Apps.objects.get(slug="minio") - minio = AppInstance.objects.filter(Q(app=minio_set), Q(project=model_project), Q(state="Running")).first() + minio = BaseAppInstance.objects.filter( + Q(app=minio_set), Q(project=model_project), Q(app_status__status="Running") + ).first() minio_release = minio.parameters["release"] # e.g 'rfc058c6f' # Now find the related pod diff --git a/monitor/.github/dependabot.yml b/monitor/.github/dependabot.yml deleted file mode 100644 index 9e5479c22..000000000 --- a/monitor/.github/dependabot.yml +++ /dev/null @@ -1,22 +0,0 @@ -version: 2 -updates: - - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - target-branch: "develop" - labels: - - "github-actions dependencies" - - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - # Raise pull requests for version updates - # to pip against the `develop` branch - target-branch: "develop" - # Labels on pull requests for version updates only - labels: - - "pip dependencies" diff --git a/monitor/.github/workflows/branch-name-check.yaml b/monitor/.github/workflows/branch-name-check.yaml deleted file mode 100644 index 03c9b39bf..000000000 --- a/monitor/.github/workflows/branch-name-check.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: "branch name check" - -on: - push: - branches-ignore: - - develop - - main - -env: - BRANCH_REGEX: '^((feature|hotfix|bug|docs|dependabot|fix)\/.+)|(release\/v((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?))$' - -jobs: - branch-name-check: - runs-on: ubuntu-20.04 - steps: - - name: checkout - uses: actions/checkout@v2 - - - name: branch name check - run: | - git rev-parse --abbrev-ref HEAD | grep -P "$BRANCH_REGEX" diff --git a/monitor/.github/workflows/code-checks.yaml b/monitor/.github/workflows/code-checks.yaml deleted file mode 100644 index 68676755a..000000000 --- a/monitor/.github/workflows/code-checks.yaml +++ /dev/null @@ -1,52 +0,0 @@ -name: Code checks - -on: - push: - branches: - - main - - develop - pull_request: - branches: - - main - - develop - release: - types: [published] - -jobs: - check-code: - - runs-on: ubuntu-20.04 - services: - postgres: - image: postgres - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - - - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - #Python version should be the same as the base image in Dockerfile - python-version: "3.8.10" - - name: Install dependencies - - run: | - python -m pip install --upgrade pip - pip install black==22.10.0 isort flake8 - if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi - - name: Check Python imports - run: | - isort . --check --diff --skip migrations --skip .venv --profile black -p studio -p projects -p models -p apps -p portal --line-length 79 - - name: Check Python formatting - run: | - black . --check --diff --line-length 79 --exclude migrations - - name: Check Python linting - run: | - flake8 . --exclude migrations diff --git a/monitor/.gitignore b/monitor/.gitignore deleted file mode 100644 index b6e47617d..000000000 --- a/monitor/.gitignore +++ /dev/null @@ -1,129 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ diff --git a/monitor/LICENSE b/monitor/LICENSE deleted file mode 100644 index 261eeb9e9..000000000 --- a/monitor/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/monitor/MANIFEST.in b/monitor/MANIFEST.in deleted file mode 100644 index a40571a8b..000000000 --- a/monitor/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.md -recursive-include monitor/templates * diff --git a/monitor/README.md b/monitor/README.md deleted file mode 100644 index fee3cf432..000000000 --- a/monitor/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# studio-monitor - -Monitor Django module for [Studio/STACKn](https://github.com/scaleoutsystems/stackn). To include source package in a Django project: - -``` -$ python3 -m venv .venv -$ source .venv/bin/activate -$ pip install . -``` -And add to installed apps in settings.py: - -``` -INSTALLED_APPS=[ - "monitor" -] -``` - -For a complete project follow the link above and navigate to settings.py diff --git a/monitor/__init__.py b/monitor/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/monitor/admin.py b/monitor/admin.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/monitor/apps.py b/monitor/apps.py deleted file mode 100644 index e11e7ae7e..000000000 --- a/monitor/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class MonitorConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "monitor" - verbose_name = "Studio Monitor" diff --git a/monitor/dash_demo.py b/monitor/dash_demo.py deleted file mode 100644 index 05e9b1572..000000000 --- a/monitor/dash_demo.py +++ /dev/null @@ -1,201 +0,0 @@ -import dash_bootstrap_components as dbc -import dash_core_components as dcc -import dash_html_components as html -import requests -from dash.dependencies import Input, Output -from dash.exceptions import PreventUpdate -from django_plotly_dash import DjangoDash - -from apps.models import AppInstance, Apps -from projects.models import Project - -app = DjangoDash("FEDnDashboard") - - -menu = html.Div( - [ - dbc.Row( - [ - dbc.Col( - [ - dbc.Card( - [ - dbc.CardBody( - [ - dbc.Select( - id="reducer-select", - options=[], - className="form-control", - ) - ] - ) - ] - ) - ], - className="col-4", - ), - dbc.Col(id="reducer-state", className="col-2"), - ] - ), - dbc.Row(id="submenu"), - ] -) - -main = dbc.Row( - [ - dbc.Col( - [ - dbc.Card( - [ - dbc.CardBody( - [ - html.H4("Combiners", className="card-title"), - dbc.Table( - [ - html.Thead( - [ - html.Tr( - [ - html.Th("Name"), - html.Th("Active Clients"), - html.Th("IP"), - html.Th("Country"), - html.Th("Region"), - html.Th("City"), - ] - ) - ] - ), - html.Tbody([], id="combiner-info-table"), - ] - ), - ] - ) - ] - ), - dbc.Card( - [ - dbc.CardBody( - [ - html.H4("Round Time", className="card-title"), - dcc.Graph(id="combiners-round-plot"), - ] - ) - ] - ), - dbc.Card( - [ - dbc.CardBody( - [ - html.H4("Combiner Load", className="card-title"), - dcc.Graph(id="combiners-combiner-plot"), - ] - ) - ] - ), - dbc.Card( - [ - dbc.CardBody( - [ - html.H4( - "Controller MEM and CPU Monitoring", - className="card-title", - ), - dcc.Graph(id="combiners-memcpu-plot"), - ] - ) - ] - ), - ] - ) - ] -) - -app.layout = html.Div([menu, main]) - - -@app.callback( - Output(component_id="combiner-info-table", component_property="children"), - Output(component_id="combiners-round-plot", component_property="figure"), - Output(component_id="combiners-combiner-plot", component_property="figure"), - Output(component_id="combiners-memcpu-plot", component_property="figure"), - Output(component_id="reducer-state", component_property="children"), - Output(component_id="reducer-select", component_property="options"), - Input(component_id="reducer-select", component_property="value"), -) -def reducer_select(red_select, request): - if "current_project" in request.session: - project_slug = request.session["current_project"] - else: - raise PreventUpdate() - reducer_app = Apps.objects.get(slug="reducer") - this_project = Project.objects.get(slug=project_slug) - reducers = AppInstance.objects.filter(app=reducer_app, project=this_project) - options = [] - for reducer in reducers: - options.append({"label": reducer.name, "value": str(reducer.pk)}) - - combiners_info = [] - roundplot = {} - combinerplot = {} - memcpuplot = {} - state = "" - if red_select is not None: - # TODO: Check that user has access to project. - sel_reducer = AppInstance.objects.get(pk=red_select, project__slug=project_slug) - reducer_params = sel_reducer.parameters - r_host = reducer_params["release"] - r_domain = reducer_params["global"]["domain"] - - try: - url = "https://{}.{}/api/state".format(r_host, r_domain) - res = requests.get(url, verify=False) - current_state = res.json()["state"] - except Exception as err: - print(err) - state = dbc.Card([dbc.CardBody(current_state)]) - - try: - url = "https://{}.{}/api/combiners/info".format(r_host, r_domain) - print(url) - res = requests.get(url, verify=False) - combiners_raw = res.json() - except Exception as err: - print(err) - - for combiner_raw in combiners_raw: - row = [ - html.Td(combiner_raw["name"]), - html.Td(combiner_raw["nr_active_clients"]), - html.Td(combiner_raw["ip"]), - html.Td(combiner_raw["country"]), - html.Td(combiner_raw["region"]), - html.Td(combiner_raw["city"]), - ] - combiners_info.append(html.Tr(row)) - - try: - url = "https://{}.{}/api/combiners/roundplot".format(r_host, r_domain) - print(url) - res = requests.get(url, verify=False) - roundplot = res.json() - except Exception as err: - print(err) - - try: - url = "https://{}.{}/api/combiners/combinerplot".format(r_host, r_domain) - print(url) - res = requests.get(url, verify=False) - combinerplot = res.json() - except Exception as err: - print(err) - - try: - url = "https://{}.{}/api/combiners/memcpuplot".format(r_host, r_domain) - print(url) - res = requests.get(url, verify=False) - memcpuplot = res.json() - except Exception as err: - print(err) - - return combiners_info, roundplot, combinerplot, memcpuplot, state, options diff --git a/monitor/helpers.py b/monitor/helpers.py deleted file mode 100644 index 6540dea26..000000000 --- a/monitor/helpers.py +++ /dev/null @@ -1,178 +0,0 @@ -import logging - -import requests as r -from django.conf import settings - - -def pod_up(app_name): - num_pods_up = 0 - num_pods_count = 0 - try: - query_up = 'sum(up{app="' + app_name + '"})' - query_count = 'count(up{app="' + app_name + '"})' - print(query_up) - print(query_count) - print(settings.PROMETHEUS_SVC + "/api/v1/query") - response = r.get( - settings.PROMETHEUS_SVC + "/api/v1/query", - params={"query": query_up}, - ) - num_pods_up = 0 - num_pods_count = 0 - if response: - print(response.text) - result_up_json = response.json() - result_up = result_up_json["data"]["result"] - - response = r.get( - settings.PROMETHEUS_SVC + "/api/v1/query", - params={"query": query_count}, - ) - if response: - result_count_json = response.json() - result_count = result_count_json["data"]["result"] - num_pods_up = result_up[0]["value"][1] - num_pods_count = result_count[0]["value"][1] - except Exception: - logging.warning("Failed to fetch status of app " + app_name) - pass - - return num_pods_up, num_pods_count - - -def get_count_over_time(name, app_name, path, status_code, time_span): - total_count = 0 - # Strip path of potential / - path = path.replace("/", "") - try: - query = ( - "sum(max_over_time(" - + name - + '{app="' - + app_name - + '", path="/' - + path - + '/", status_code="' - + status_code - + '"}[' - + time_span - + "]))" - ) - response = r.get(settings.PROMETHEUS_SVC + "/api/v1/query", params={"query": query}) - if response: - total_count = response.json()["data"]["result"][0]["value"][1] - except Exception: - print("Failed to get total count for: {}, {}, {}.".format(app_name, path, status_code)) - - return total_count - - -def get_total_labs_cpu_usage_60s(project_slug): - query = ( - 'sum(sum (rate (container_cpu_usage_seconds_total{image!=""}[60s])) by (pod) * on(pod) group_left kube_pod_labels{label_project="' # noqa: E501 - + project_slug - + '", label_app="lab"})' - ) - print(query) - response = r.get(settings.PROMETHEUS_SVC + "/api/v1/query", params={"query": query}) - result = response.json()["data"]["result"] - if result: - cpu_usage = result[0]["value"][1] - return "{:.1f}".format(float(cpu_usage)) - return 0 - - -def get_total_cpu_usage_60s_ts(project_slug, resource_type): - query = ( - '''(sum (sum ( irate (container_cpu_usage_seconds_total{image!=""}[60s] ) ) by (pod) * on(pod) group_left kube_pod_labels{label_type="''' # noqa: E501 - + resource_type - + '",label_project="' - + project_slug - + '"})) [30m:30s]' - ) - print(query) - response = r.get(settings.PROMETHEUS_SVC + "/api/v1/query", params={"query": query}) - # print(response.json()) - result = response.json()["data"]["result"] - if result: - return result[0]["values"] - return 0 - - -def get_total_labs_memory_usage_60s(project_slug): - query = ( - 'sum(sum (rate (container_memory_usage_bytes{image!=""}[60s])) by (pod) * on(pod) group_left kube_pod_labels{label_project="' # noqa: E501 - + project_slug - + '", label_app="lab"})' - ) - response = r.get(settings.PROMETHEUS_SVC + "/api/v1/query", params={"query": query}) - result = response.json()["data"]["result"] - if result: - memory_usage = result[0]["value"][1] - return "{:.3f}".format(float(memory_usage) / 1e9 * 0.931323) - return 0 - - -def get_labs_memory_requests(project_slug): - query = ( - ( - "sum(kube_pod_container_resource_requests_memory_bytes" - ' * on(pod) group_left kube_pod_labels{label_project="' - ) - + project_slug - + '"})' - ) - # query = 'kube_pod_container_resource_requests_cpu_cores' - - response = r.get(settings.PROMETHEUS_SVC + "/api/v1/query", params={"query": query}) - result = response.json()["data"]["result"] - if result: - memory = result[0]["value"][1] - return "{:.2f}".format(float(memory) / 1e9 * 0.931323) - return 0 - - -def get_labs_cpu_requests(project_slug): - query = ( - 'sum(kube_pod_container_resource_requests_cpu_cores * on(pod) group_left kube_pod_labels{label_project="' # noqa: E501 - + project_slug - + '"})' - ) - - response = r.get(settings.PROMETHEUS_SVC + "/api/v1/query", params={"query": query}) - result = response.json()["data"]["result"] - if result: - num_cpus = result[0]["value"][1] - return num_cpus - return 0 - - -def get_resource(project_slug, resource_type, q_type, mem_or_cpu, app_name=[]): - query = ( - "sum(kube_pod_container_resource_" - + q_type - + "_" - + mem_or_cpu - + ' * on(pod) group_left kube_pod_labels{label_project="' - + project_slug - + '", label_type="' - + resource_type - + '"' - ) - if app_name: - query += ', label_app="' + app_name + '"})' - else: - query += "})" - response = r.get(settings.PROMETHEUS_SVC + "/api/v1/query", params={"query": query}) - result = response.json()["data"]["result"] - if result: - res = result[0]["value"][1] - return res - return "0.0" - - -def get_all(): - query = 'kube_pod_container_resource_limits_memory_bytes * on(pod) group_left kube_pod_labels{label_type="lab", label_project="stochss-dev-tiz"}' # noqa: E501 - response = r.get(settings.PROMETHEUS_SVC + "/api/v1/query", params={"query": query}) - result = response.json()["data"]["result"] - print(result) diff --git a/monitor/migrations/__init__.py b/monitor/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/monitor/models.py b/monitor/models.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/monitor/setup.py b/monitor/setup.py deleted file mode 100644 index 43a00a99e..000000000 --- a/monitor/setup.py +++ /dev/null @@ -1,27 +0,0 @@ -from setuptools import setup - -setup( - name="studio-monitor", - version="0.0.1", - description="""Django app for handling portal in Studio""", - url="https://www.scaleoutsystems.com", - include_package_data=True, - package=["monitor"], - package_dir={"monitor": "."}, - python_requires=">=3.6,<4", - install_requires=[ - "django==4.2.1", - "requests==2.31.0", - ], - license="Copyright Scaleout Systems AB. See license for details", - zip_safe=False, - keywords="", - classifiers=[ - "Development Status :: 2 - Pre-Alpha", - "Intended Audience :: Developers", - "Natural Language :: English", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - ], -) diff --git a/monitor/templates/monitor_new.html b/monitor/templates/monitor_new.html deleted file mode 100644 index 92282fdc5..000000000 --- a/monitor/templates/monitor_new.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends 'baseproject.html' %} -{% load static %} - -{% load plotly_dash %} - -{% block projects_dropdown %} - -{% endblock %} - -{% block content %} - -

FEDn Dashboard

- -{% plotly_direct name="FEDnDashboard" %} - -{% endblock %} diff --git a/monitor/templates/monitor_overview.html b/monitor/templates/monitor_overview.html deleted file mode 100644 index 291f0b326..000000000 --- a/monitor/templates/monitor_overview.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends 'baseproject.html' %} - -{% block content %} - -

Monitor

- -
- -
-{% endblock %} diff --git a/monitor/tests.py b/monitor/tests.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/monitor/urls.py b/monitor/urls.py deleted file mode 100644 index a659306e0..000000000 --- a/monitor/urls.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.urls import path - -from . import views - -app_name = "monitor" - -urlpatterns = [ - path("monitor/", views.overview, name="overview"), - path("liveout", views.liveout, name="liveout"), - path("usage", views.usage, name="usage"), - path("/cpuchart", views.cpuchart, name="cpuchart"), - path("lab/delete/", views.delete_lab, name="delete_lab"), - path( - "serve/delete/", - views.delete_deployment, - name="delete_deployment", - ), -] diff --git a/monitor/views.py b/monitor/views.py deleted file mode 100644 index c3a2d7531..000000000 --- a/monitor/views.py +++ /dev/null @@ -1,267 +0,0 @@ -import itertools -import time -from datetime import datetime - -from django.conf import settings as sett -from django.contrib.auth.decorators import login_required -from django.db.models import F, Q, Sum -from django.http import HttpResponse, HttpResponseRedirect, JsonResponse -from django.shortcuts import render, reverse - -from apps.models import ResourceData -from models.models import Model -from projects.models import Project - -from .helpers import get_resource, get_total_cpu_usage_60s_ts - - -def get_cpu_mem(resources, project_slug, resource_type): - res_list = list() - for resource in resources: - res_cpu_limit = get_resource( - project_slug, - resource_type, - "limits", - "cpu_cores", - app_name=resource.appname, - ) - res_cpu_limit = "{:.2f}".format(float(res_cpu_limit)) - res_cpu_request = get_resource( - project_slug, - resource_type, - "requests", - "cpu_cores", - app_name=resource.appname, - ) - res_cpu_limit = "{:.2f}".format(float(res_cpu_request)) - res_mem_limit = get_resource( - project_slug, - resource_type, - "limits", - "memory_bytes", - app_name=resource.appname, - ) - res_mem_limit = "{:.2f}".format(float(res_mem_limit) / 1e9 * 0.931323) - res_mem_request = get_resource( - project_slug, - resource_type, - "requests", - "memory_bytes", - app_name=resource.appname, - ) - res_mem_request = "{:.2f}".format(float(res_mem_request) / 1e9 * 0.931323) - - if resource_type == "lab": - res_owner = resource.lab_session_owner.username - res_flavor = resource.flavor_slug - res_id = str(resource.id) - res_name = resource.name - res_project = resource.project.name - res_status = resource.status - res_created = resource.created_at - res_updated = resource.updated_at - - res_list.append( - ( - res_owner, - res_flavor, - res_cpu_limit, - res_cpu_request, - res_mem_limit, - res_mem_request, - res_id, - res_name, - res_project, - res_status, - res_created, - res_updated, - ) - ) - - elif resource_type == "deployment": - res_owner = resource.created_by - res_model = resource.model.name - res_version = resource.model.version - res_id = resource.model.id - res_project = resource.deployment.project.name - res_name = resource.deployment.name - res_access = resource.access - res_endpoint = resource.endpoint - res_created = resource.created_at - - res_list.append( - ( - res_owner, - res_cpu_limit, - res_cpu_request, - res_mem_limit, - res_mem_request, - res_id, - res_model, - res_version, - res_project, - res_name, - res_access, - res_endpoint, - res_created, - ) - ) - - return res_list - - -@login_required -def liveout(request, user, project): - is_authorized = True - user_permissions = get_permissions(request, project, sett.MONITOR_PERM) # noqa: F821 - - if not user_permissions["view"]: - request.session["oidc_id_token_expiration"] = -1 - request.session.save() - # return HttpResponse('Not authorized', status=401) - is_authorized = False - template = "monitor2.html" - project = Project.objects.filter(slug=project).first() - - return render(request, template, locals()) - - -@login_required -def overview(request, user, project): - try: - projects = Project.objects.filter(Q(owner=request.user) | Q(authorized=request.user), status="active").distinct( - "pk" - ) - except TypeError as err: - projects = [] - print(err) - - is_authorized = True - user_permissions = get_permissions(request, project, sett.MONITOR_PERM) # noqa: F821 - if not user_permissions["view"]: - request.session["oidc_id_token_expiration"] = -1 - request.session.save() - # return HttpResponse('Not authorized', status=401) - is_authorized = False - - request.session["current_project"] = project - template = "monitor_new.html" - project = Project.objects.filter(slug=project).first() - - # resource_types = ['lab', 'deployment'] - # q_types = ['requests', 'limits'] - # r_types = ['memory_bytes', 'cpu_cores'] - - # resource_status = dict() - # for resource_type in resource_types: - # resource_status[resource_type] = dict() - # for q_type in q_types: - # resource_status[resource_type][q_type] = dict() - # for r_type in r_types: - # tmp = get_resource(project.slug, resource_type, q_type, r_type) # noqa E501 - - # if r_type == 'memory_bytes': - # tmp ="{:.2f}".format(float(tmp)/1e9*0.931323) - # elif tmp: - # tmp = "{:.2f}".format(float(tmp)) - - # resource_status[resource_type][q_type][r_type] = tmp - - # total_cpu = float(resource_status['lab']['limits']['cpu_cores'])+float(resource_status['deployment']['limits']['cpu_cores']) # noqa E501 - # total_mem = float(resource_status['lab']['limits']['memory_bytes'])+float(resource_status['deployment']['limits']['memory_bytes']) # noqa E501 - # total_cpu_req = float(resource_status['lab']['requests']['cpu_cores'])+float(resource_status['deployment']['requests']['cpu_cores']) # noqa E501 - # total_mem_req = float(resource_status['lab']['requests']['memory_bytes'])+float(resource_status['deployment']['requests']['memory_bytes']) # noqa E501 - - # labs = Session.objects.filter(project=project) - # lab_list = get_cpu_mem(labs, project.slug, 'lab') - - # deps = DeploymentInstance.objects.filter(model__project=project) - # dep_list = get_cpu_mem(deps, project.slug, 'deployment') - - return render(request, template, locals()) - - -def delete_lab(request, user, project, uid): - # project = Project.objects.filter(Q(slug=project), Q(owner=request.user) | Q(authorized=request.user)).first() # noqa E501 - # session = Session.objects.filter(Q(id=id), Q(project=project), Q(lab_session_owner=request.user)).first() # noqa E501 - user_permissions = get_permissions(request, project, sett.MONITOR_PERM) # noqa F821 - if not user_permissions["view"]: - request.session["oidc_id_token_expiration"] = -1 - request.session.save() - return HttpResponse("Not authorized", status=401) - project = Project.objects.get(slug=project) - session = Session.objects.get(id=uid, project=project) # noqa F821 - if session: - session.helmchart.delete() - - return HttpResponseRedirect( - reverse( - "monitor:overview", - kwargs={"user": request.user, "project": str(project.slug)}, - ) - ) - - -def delete_deployment(request, user, project, model_id): - user_permissions = get_permissions(request, project, sett.MONITOR_PERM) # noqa F821 - if not user_permissions["view"]: - request.session["oidc_id_token_expiration"] = -1 - request.session.save() - return HttpResponse("Not authorized", status=401) - model = Model.objects.get(id=model_id) - instance = DeploymentInstance.objects.get(model=model) # noqa F821 - instance.helmchart.delete() - return HttpResponseRedirect( - reverse( - "monitor:overview", - kwargs={"user": request.user, "project": project}, - ) - ) - - -def cpuchart(request, user, project, resource_type): - # labels = ['a', 'b', 'c'] - # data = [1, 3, 2] - labels = [] - data = [] - test = get_total_cpu_usage_60s_ts(project, resource_type) - for value in test: - tod = datetime.fromtimestamp(value[0]).strftime("%H:%M") - labels.append(tod) - data.append(value[1]) - return JsonResponse( - data={ - "labels": labels, - "data": data, - } - ) - - -def usage(request, user, project): - curr_timestamp = time.time() - points = ResourceData.objects.filter( - time__gte=curr_timestamp - 2 * 3600, appinstance__project__slug=project - ).order_by("time") - all_cpus = list() - for point in points: - all_cpus.append(point.cpu) - total = points.annotate(timeP=F("time")).values("timeP").annotate(total_cpu=Sum("cpu"), total_mem=Sum("mem")) - - labels = list(total.values_list("timeP")) - labels = list(itertools.chain.from_iterable(labels)) - step = 1 - np = 200 - if len(labels) > np: - step = round(len(labels) / np) - labels = labels[::step] - x_data = list() - for label in labels: - x_data.append(datetime.fromtimestamp(label).strftime("%H:%M:%S")) - - total_mem = list(total.values_list("total_mem")) - total_mem = list(itertools.chain.from_iterable(total_mem))[::step] - - total_cpu = list(total.values_list("total_cpu")) - total_cpu = list(itertools.chain.from_iterable(total_cpu))[::step] - - return JsonResponse(data={"labels": x_data, "data_cpu": total_cpu, "data_mem": total_mem}) diff --git a/news/LICENSE b/news/LICENSE deleted file mode 100644 index 261eeb9e9..000000000 --- a/news/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/news/MANIFEST.in b/news/MANIFEST.in deleted file mode 100644 index 9268b357d..000000000 --- a/news/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.md -recursive-include news/templates * diff --git a/news/README.md b/news/README.md deleted file mode 100644 index 311cb3ccf..000000000 --- a/news/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# studio-portal - -Portal Django module for [Studio/STACKn](https://github.com/scaleoutsystems/stackn). To include source package in a Django project: - -``` -$ python3 -m venv .venv -$ source .venv/bin/activate -$ pip install . -``` -And add to installed apps in settings.py: - -``` -INSTALLED_APPS=[ - "portal" -] -``` - -For a complete project follow the link above and navigate to settings.py diff --git a/news/__init__.py b/news/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/news/admin.py b/news/admin.py deleted file mode 100644 index 9900e37a7..000000000 --- a/news/admin.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.contrib import admin - -from .models import NewsObject - -admin.site.register(NewsObject) diff --git a/news/apps.py b/news/apps.py deleted file mode 100644 index e491f8688..000000000 --- a/news/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class AppsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "news" - verbose_name = "Serve News" diff --git a/news/migrations/0001_initial.py b/news/migrations/0001_initial.py deleted file mode 100644 index a957fcf8b..000000000 --- a/news/migrations/0001_initial.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.2.5 on 2023-11-16 08:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [] - - operations = [ - migrations.CreateModel( - name="NewsObject", - fields=[ - ("created_on", models.DateTimeField(auto_now_add=True)), - ("title", models.CharField(default="", max_length=60, primary_key=True, serialize=False)), - ("body", models.TextField(blank=True, default="", max_length=2024, null=True)), - ], - ), - ] diff --git a/news/migrations/__init__.py b/news/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/news/models.py b/news/models.py deleted file mode 100644 index d70edea06..000000000 --- a/news/models.py +++ /dev/null @@ -1,18 +0,0 @@ -import random - -from django.conf import settings -from django.db import models - - -class NewsObject(models.Model): - created_on = models.DateTimeField(auto_now_add=True) - title = models.CharField(max_length=60, default="", primary_key=True) - body = models.TextField(blank=True, null=True, default="", max_length=2024) - - @property - def news_body(self): - return self.body - - @property - def news_title(self): - return self.title diff --git a/news/setup.py b/news/setup.py deleted file mode 100644 index 8afbd8171..000000000 --- a/news/setup.py +++ /dev/null @@ -1,28 +0,0 @@ -from setuptools import setup - -setup( - name="studio-portal", - version="0.0.1", - description="""Django app for handling news in Serve""", - url="https://www.scilifelab.se", - include_package_data=True, - package=["portal"], - package_dir={"portal": "."}, - python_requires=">=3.6,<4", - install_requires=[ - "django==4.2.5", - "requests==2.31.0", - "Pillow==9.4.0", - ], - license="Copyright Scaleout Systems AB. See license for details", - zip_safe=False, - keywords="", - classifiers=[ - "Development Status :: 2 - Pre-Alpha", - "Intended Audience :: Developers", - "Natural Language :: English", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - ], -) diff --git a/news/tasks.py b/news/tasks.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/news/tests.py b/news/tests.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/news/urls.py b/news/urls.py deleted file mode 100644 index 01008bdea..000000000 --- a/news/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path - -from projects.views import IndexView as IndexView - -from . import views - -app_name = "news" - -urlpatterns = [ - path("news/", views.news, name="news"), -] diff --git a/news/views.py b/news/views.py deleted file mode 100644 index 81c310cd6..000000000 --- a/news/views.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import Any - -import markdown -from django.http import HttpRequest, HttpResponse -from django.shortcuts import render - -from .models import NewsObject - - -def news(request: HttpRequest) -> HttpResponse: - news_objects = NewsObject.objects.all().order_by("-created_on") - for news in news_objects: - news.body_html = markdown.markdown(news.body) - return render(request, "news/news.html", {"news_objects": news_objects}) diff --git a/poetry.lock b/poetry.lock index 885e2410f..e8f365bb8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aiobotocore" @@ -158,13 +158,13 @@ vine = ">=5.0.0" [[package]] name = "annotated-types" -version = "0.6.0" +version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = true python-versions = ">=3.8" files = [ - {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, - {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] [[package]] @@ -216,13 +216,13 @@ tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "p [[package]] name = "aws-sam-translator" -version = "1.88.0" +version = "1.89.0" description = "AWS SAM Translator is a library that transform SAM templates into AWS CloudFormation templates" optional = true python-versions = "!=4.0,<=4.0,>=3.8" files = [ - {file = "aws_sam_translator-1.88.0-py3-none-any.whl", hash = "sha256:aa93d498d8de3fb3d485c316155b1628144b823bbc176099a20de06df666fcac"}, - {file = "aws_sam_translator-1.88.0.tar.gz", hash = "sha256:e77c65f3488566122277accd44a0f1ec018e37403e0d5fe25120d96e537e91a7"}, + {file = "aws_sam_translator-1.89.0-py3-none-any.whl", hash = "sha256:843be1b5ca7634f700ad0c844a7e0dc42858f35da502e91691473eadd1731ded"}, + {file = "aws_sam_translator-1.89.0.tar.gz", hash = "sha256:fff1005d0b1f3cb511d0ac7e85f54af06afc9d9e433df013a2338d7a0168d174"}, ] [package.dependencies] @@ -236,13 +236,13 @@ dev = ["black (==24.3.0)", "boto3 (>=1.23,<2)", "boto3-stubs[appconfig,serverles [[package]] name = "aws-xray-sdk" -version = "2.13.0" +version = "2.14.0" description = "The AWS X-Ray SDK for Python (the SDK) enables Python developers to record and emit information from within their applications to the AWS X-Ray service." optional = true python-versions = ">=3.7" files = [ - {file = "aws-xray-sdk-2.13.0.tar.gz", hash = "sha256:816186126917bc35ae4e6e2f304702a43d494ecef34a39e6330f5018bdecc9f5"}, - {file = "aws_xray_sdk-2.13.0-py2.py3-none-any.whl", hash = "sha256:d18604a8688b4bed03ce4a858cc9acd72b71400e085bf7512fc31cf657ca85f9"}, + {file = "aws_xray_sdk-2.14.0-py2.py3-none-any.whl", hash = "sha256:cfbe6feea3d26613a2a869d14c9246a844285c97087ad8f296f901633554ad94"}, + {file = "aws_xray_sdk-2.14.0.tar.gz", hash = "sha256:aab843c331af9ab9ba5cefb3a303832a19db186140894a523edafc024cc0493c"}, ] [package.dependencies] @@ -433,13 +433,13 @@ zstd = ["zstandard (==0.21.0)"] [[package]] name = "certifi" -version = "2024.2.2" +version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, - {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] [[package]] @@ -508,26 +508,29 @@ pycparser = "*" [[package]] name = "cfn-lint" -version = "0.87.2" +version = "1.9.1" description = "Checks CloudFormation templates for practices and behaviour that could potentially be improved" optional = true -python-versions = "!=4.0,<=4.0,>=3.8" +python-versions = ">=3.8" files = [ - {file = "cfn_lint-0.87.2-py3-none-any.whl", hash = "sha256:773ba1d2f232ffdbe1197cc6ce61ddbf0da1781925e9f4dde4c91b7fcd54cc80"}, - {file = "cfn_lint-0.87.2.tar.gz", hash = "sha256:00d47406841899c05ab6a0708df3f4e32bd7462be2097c10371d744c0050775e"}, + {file = "cfn_lint-1.9.1-py3-none-any.whl", hash = "sha256:ed0a1c49fa5a01e8c345816be899078774cffb3c75200f15249c311410d9241a"}, + {file = "cfn_lint-1.9.1.tar.gz", hash = "sha256:751034d9aeedb303e1dbd0f7fc68b2a1b8d475335ae7695b33dab3cb12d28c48"}, ] [package.dependencies] -aws-sam-translator = ">=1.87.0" -jschema-to-python = ">=1.2.3,<1.3.0" +aws-sam-translator = ">=1.89.0" jsonpatch = "*" -jsonschema = ">=3.0,<5" -junit-xml = ">=1.9,<2.0" networkx = ">=2.4,<4" pyyaml = ">5.4" -regex = ">=2021.7.1" -sarif-om = ">=1.0.4,<1.1.0" +regex = "*" sympy = ">=1.0.0" +typing-extensions = "*" + +[package.extras] +full = ["jschema-to-python (>=1.2.3,<1.3.0)", "junit-xml (>=1.9,<2.0)", "pydot", "sarif-om (>=1.0.4,<1.1.0)"] +graph = ["pydot"] +junit = ["junit-xml (>=1.9,<2.0)"] +sarif = ["jschema-to-python (>=1.2.3,<1.3.0)", "sarif-om (>=1.0.4,<1.1.0)"] [[package]] name = "charset-normalizer" @@ -721,63 +724,63 @@ development = ["black", "flake8", "mypy", "pytest", "types-colorama"] [[package]] name = "coverage" -version = "7.5.1" +version = "7.6.0" description = "Code coverage measurement for Python" optional = true python-versions = ">=3.8" files = [ - {file = "coverage-7.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0884920835a033b78d1c73b6d3bbcda8161a900f38a488829a83982925f6c2e"}, - {file = "coverage-7.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:39afcd3d4339329c5f58de48a52f6e4e50f6578dd6099961cf22228feb25f38f"}, - {file = "coverage-7.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7b0ceee8147444347da6a66be737c9d78f3353b0681715b668b72e79203e4a"}, - {file = "coverage-7.5.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a9ca3f2fae0088c3c71d743d85404cec8df9be818a005ea065495bedc33da35"}, - {file = "coverage-7.5.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd215c0c7d7aab005221608a3c2b46f58c0285a819565887ee0b718c052aa4e"}, - {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4bf0655ab60d754491004a5efd7f9cccefcc1081a74c9ef2da4735d6ee4a6223"}, - {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61c4bf1ba021817de12b813338c9be9f0ad5b1e781b9b340a6d29fc13e7c1b5e"}, - {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:db66fc317a046556a96b453a58eced5024af4582a8dbdc0c23ca4dbc0d5b3146"}, - {file = "coverage-7.5.1-cp310-cp310-win32.whl", hash = "sha256:b016ea6b959d3b9556cb401c55a37547135a587db0115635a443b2ce8f1c7228"}, - {file = "coverage-7.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:df4e745a81c110e7446b1cc8131bf986157770fa405fe90e15e850aaf7619bc8"}, - {file = "coverage-7.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:796a79f63eca8814ca3317a1ea443645c9ff0d18b188de470ed7ccd45ae79428"}, - {file = "coverage-7.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fc84a37bfd98db31beae3c2748811a3fa72bf2007ff7902f68746d9757f3746"}, - {file = "coverage-7.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6175d1a0559986c6ee3f7fccfc4a90ecd12ba0a383dcc2da30c2b9918d67d8a3"}, - {file = "coverage-7.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fc81d5878cd6274ce971e0a3a18a8803c3fe25457165314271cf78e3aae3aa2"}, - {file = "coverage-7.5.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:556cf1a7cbc8028cb60e1ff0be806be2eded2daf8129b8811c63e2b9a6c43bca"}, - {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9981706d300c18d8b220995ad22627647be11a4276721c10911e0e9fa44c83e8"}, - {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d7fed867ee50edf1a0b4a11e8e5d0895150e572af1cd6d315d557758bfa9c057"}, - {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef48e2707fb320c8f139424a596f5b69955a85b178f15af261bab871873bb987"}, - {file = "coverage-7.5.1-cp311-cp311-win32.whl", hash = "sha256:9314d5678dcc665330df5b69c1e726a0e49b27df0461c08ca12674bcc19ef136"}, - {file = "coverage-7.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fa567e99765fe98f4e7d7394ce623e794d7cabb170f2ca2ac5a4174437e90dd"}, - {file = "coverage-7.5.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b6cf3764c030e5338e7f61f95bd21147963cf6aa16e09d2f74f1fa52013c1206"}, - {file = "coverage-7.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ec92012fefebee89a6b9c79bc39051a6cb3891d562b9270ab10ecfdadbc0c34"}, - {file = "coverage-7.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16db7f26000a07efcf6aea00316f6ac57e7d9a96501e990a36f40c965ec7a95d"}, - {file = "coverage-7.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beccf7b8a10b09c4ae543582c1319c6df47d78fd732f854ac68d518ee1fb97fa"}, - {file = "coverage-7.5.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8748731ad392d736cc9ccac03c9845b13bb07d020a33423fa5b3a36521ac6e4e"}, - {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7352b9161b33fd0b643ccd1f21f3a3908daaddf414f1c6cb9d3a2fd618bf2572"}, - {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:7a588d39e0925f6a2bff87154752481273cdb1736270642aeb3635cb9b4cad07"}, - {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:68f962d9b72ce69ea8621f57551b2fa9c70509af757ee3b8105d4f51b92b41a7"}, - {file = "coverage-7.5.1-cp312-cp312-win32.whl", hash = "sha256:f152cbf5b88aaeb836127d920dd0f5e7edff5a66f10c079157306c4343d86c19"}, - {file = "coverage-7.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:5a5740d1fb60ddf268a3811bcd353de34eb56dc24e8f52a7f05ee513b2d4f596"}, - {file = "coverage-7.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e2213def81a50519d7cc56ed643c9e93e0247f5bbe0d1247d15fa520814a7cd7"}, - {file = "coverage-7.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5037f8fcc2a95b1f0e80585bd9d1ec31068a9bcb157d9750a172836e98bc7a90"}, - {file = "coverage-7.5.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3721c2c9e4c4953a41a26c14f4cef64330392a6d2d675c8b1db3b645e31f0e"}, - {file = "coverage-7.5.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca498687ca46a62ae590253fba634a1fe9836bc56f626852fb2720f334c9e4e5"}, - {file = "coverage-7.5.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cdcbc320b14c3e5877ee79e649677cb7d89ef588852e9583e6b24c2e5072661"}, - {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:57e0204b5b745594e5bc14b9b50006da722827f0b8c776949f1135677e88d0b8"}, - {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fe7502616b67b234482c3ce276ff26f39ffe88adca2acf0261df4b8454668b4"}, - {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9e78295f4144f9dacfed4f92935fbe1780021247c2fabf73a819b17f0ccfff8d"}, - {file = "coverage-7.5.1-cp38-cp38-win32.whl", hash = "sha256:1434e088b41594baa71188a17533083eabf5609e8e72f16ce8c186001e6b8c41"}, - {file = "coverage-7.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:0646599e9b139988b63704d704af8e8df7fa4cbc4a1f33df69d97f36cb0a38de"}, - {file = "coverage-7.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4cc37def103a2725bc672f84bd939a6fe4522310503207aae4d56351644682f1"}, - {file = "coverage-7.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fc0b4d8bfeabd25ea75e94632f5b6e047eef8adaed0c2161ada1e922e7f7cece"}, - {file = "coverage-7.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d0a0f5e06881ecedfe6f3dd2f56dcb057b6dbeb3327fd32d4b12854df36bf26"}, - {file = "coverage-7.5.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9735317685ba6ec7e3754798c8871c2f49aa5e687cc794a0b1d284b2389d1bd5"}, - {file = "coverage-7.5.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d21918e9ef11edf36764b93101e2ae8cc82aa5efdc7c5a4e9c6c35a48496d601"}, - {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c3e757949f268364b96ca894b4c342b41dc6f8f8b66c37878aacef5930db61be"}, - {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:79afb6197e2f7f60c4824dd4b2d4c2ec5801ceb6ba9ce5d2c3080e5660d51a4f"}, - {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d1d0d98d95dd18fe29dc66808e1accf59f037d5716f86a501fc0256455219668"}, - {file = "coverage-7.5.1-cp39-cp39-win32.whl", hash = "sha256:1cc0fe9b0b3a8364093c53b0b4c0c2dd4bb23acbec4c9240b5f284095ccf7981"}, - {file = "coverage-7.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:dde0070c40ea8bb3641e811c1cfbf18e265d024deff6de52c5950677a8fb1e0f"}, - {file = "coverage-7.5.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:6537e7c10cc47c595828b8a8be04c72144725c383c4702703ff4e42e44577312"}, - {file = "coverage-7.5.1.tar.gz", hash = "sha256:54de9ef3a9da981f7af93eafde4ede199e0846cd819eb27c88e2b712aae9708c"}, + {file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"}, + {file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"}, + {file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"}, + {file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"}, + {file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"}, + {file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"}, + {file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"}, + {file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"}, + {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"}, + {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"}, + {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"}, + {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"}, + {file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"}, + {file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"}, + {file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"}, + {file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"}, + {file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"}, + {file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"}, + {file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"}, + {file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"}, + {file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"}, + {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"}, ] [package.dependencies] @@ -786,6 +789,24 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "crispy-bootstrap5" +version = "2024.2" +description = "Bootstrap5 template pack for django-crispy-forms" +optional = false +python-versions = ">=3.8" +files = [ + {file = "crispy-bootstrap5-2024.2.tar.gz", hash = "sha256:7d1fa40c6faf472e30e85c72551a3d2c9eedbf0abfff920683315e4e6f670f2b"}, + {file = "crispy_bootstrap5-2024.2-py3-none-any.whl", hash = "sha256:3867e320920a6ef156e94f9e0f06a80344c453e1b3bd96cd9dc0522ae9e9afb8"}, +] + +[package.dependencies] +django = ">=4.2" +django-crispy-forms = ">=2" + +[package.extras] +test = ["pytest", "pytest-django"] + [[package]] name = "cron-descriptor" version = "1.4.3" @@ -802,43 +823,38 @@ dev = ["polib"] [[package]] name = "cryptography" -version = "42.0.7" +version = "43.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = true python-versions = ">=3.7" files = [ - {file = "cryptography-42.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a987f840718078212fdf4504d0fd4c6effe34a7e4740378e59d47696e8dfb477"}, - {file = "cryptography-42.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bd13b5e9b543532453de08bcdc3cc7cebec6f9883e886fd20a92f26940fd3e7a"}, - {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a79165431551042cc9d1d90e6145d5d0d3ab0f2d66326c201d9b0e7f5bf43604"}, - {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a47787a5e3649008a1102d3df55424e86606c9bae6fb77ac59afe06d234605f8"}, - {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:02c0eee2d7133bdbbc5e24441258d5d2244beb31da5ed19fbb80315f4bbbff55"}, - {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5e44507bf8d14b36b8389b226665d597bc0f18ea035d75b4e53c7b1ea84583cc"}, - {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7f8b25fa616d8b846aef64b15c606bb0828dbc35faf90566eb139aa9cff67af2"}, - {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:93a3209f6bb2b33e725ed08ee0991b92976dfdcf4e8b38646540674fc7508e13"}, - {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6b8f1881dac458c34778d0a424ae5769de30544fc678eac51c1c8bb2183e9da"}, - {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3de9a45d3b2b7d8088c3fbf1ed4395dfeff79d07842217b38df14ef09ce1d8d7"}, - {file = "cryptography-42.0.7-cp37-abi3-win32.whl", hash = "sha256:789caea816c6704f63f6241a519bfa347f72fbd67ba28d04636b7c6b7da94b0b"}, - {file = "cryptography-42.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:8cb8ce7c3347fcf9446f201dc30e2d5a3c898d009126010cbd1f443f28b52678"}, - {file = "cryptography-42.0.7-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:a3a5ac8b56fe37f3125e5b72b61dcde43283e5370827f5233893d461b7360cd4"}, - {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:779245e13b9a6638df14641d029add5dc17edbef6ec915688f3acb9e720a5858"}, - {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d563795db98b4cd57742a78a288cdbdc9daedac29f2239793071fe114f13785"}, - {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:31adb7d06fe4383226c3e963471f6837742889b3c4caa55aac20ad951bc8ffda"}, - {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:efd0bf5205240182e0f13bcaea41be4fdf5c22c5129fc7ced4a0282ac86998c9"}, - {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a9bc127cdc4ecf87a5ea22a2556cab6c7eda2923f84e4f3cc588e8470ce4e42e"}, - {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:3577d029bc3f4827dd5bf8bf7710cac13527b470bbf1820a3f394adb38ed7d5f"}, - {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2e47577f9b18723fa294b0ea9a17d5e53a227867a0a4904a1a076d1646d45ca1"}, - {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1a58839984d9cb34c855197043eaae2c187d930ca6d644612843b4fe8513c886"}, - {file = "cryptography-42.0.7-cp39-abi3-win32.whl", hash = "sha256:e6b79d0adb01aae87e8a44c2b64bc3f3fe59515280e00fb6d57a7267a2583cda"}, - {file = "cryptography-42.0.7-cp39-abi3-win_amd64.whl", hash = "sha256:16268d46086bb8ad5bf0a2b5544d8a9ed87a0e33f5e77dd3c3301e63d941a83b"}, - {file = "cryptography-42.0.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2954fccea107026512b15afb4aa664a5640cd0af630e2ee3962f2602693f0c82"}, - {file = "cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:362e7197754c231797ec45ee081f3088a27a47c6c01eff2ac83f60f85a50fe60"}, - {file = "cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4f698edacf9c9e0371112792558d2f705b5645076cc0aaae02f816a0171770fd"}, - {file = "cryptography-42.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5482e789294854c28237bba77c4c83be698be740e31a3ae5e879ee5444166582"}, - {file = "cryptography-42.0.7-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e9b2a6309f14c0497f348d08a065d52f3020656f675819fc405fb63bbcd26562"}, - {file = "cryptography-42.0.7-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d8e3098721b84392ee45af2dd554c947c32cc52f862b6a3ae982dbb90f577f14"}, - {file = "cryptography-42.0.7-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c65f96dad14f8528a447414125e1fc8feb2ad5a272b8f68477abbcc1ea7d94b9"}, - {file = "cryptography-42.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:36017400817987670037fbb0324d71489b6ead6231c9604f8fc1f7d008087c68"}, - {file = "cryptography-42.0.7.tar.gz", hash = "sha256:ecbfbc00bf55888edda9868a4cf927205de8499e7fabe6c050322298382953f2"}, + {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"}, + {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"}, + {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"}, + {file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"}, + {file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"}, + {file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"}, + {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"}, + {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"}, + {file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"}, + {file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"}, + {file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"}, ] [package.dependencies] @@ -851,7 +867,7 @@ nox = ["nox"] pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] @@ -890,16 +906,17 @@ django = "*" [[package]] name = "django-axes" -version = "6.4.0" +version = "6.5.1" description = "Keep track of failed login attempts in Django-powered sites." optional = false python-versions = ">=3.7" files = [ - {file = "django-axes-6.4.0.tar.gz", hash = "sha256:aff0e853ae9cddbdd9e8dc703b46915a9960012dab602552f61b43c0777bc731"}, - {file = "django_axes-6.4.0-py3-none-any.whl", hash = "sha256:70508d108abc19bcfdd3e9ef7c9d526a014029a8e2ba51f03a5a8348d6eca1b9"}, + {file = "django_axes-6.5.1-py3-none-any.whl", hash = "sha256:7435068cc8523bfa3f34faa62bb3a772b76d00925c3ff54aef43e4316e74bf05"}, + {file = "django_axes-6.5.1.tar.gz", hash = "sha256:d57f0fc95d581a602c642b3fe5bc31488b9401bd7441f3bec1fef0e599028499"}, ] [package.dependencies] +asgiref = ">=3.6.0" django = ">=3.2" django-ipware = {version = ">=3", optional = true, markers = "extra == \"ipware\""} @@ -1025,6 +1042,21 @@ files = [ [package.dependencies] Django = ">=2.2" +[[package]] +name = "django-htmx" +version = "1.18.0" +description = "Extensions for using Django with htmx." +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_htmx-1.18.0-py3-none-any.whl", hash = "sha256:48f3b8a784467bfcc30562067b87ecbc4ad9b739cc269eec5f3789c16d2cb2ce"}, + {file = "django_htmx-1.18.0.tar.gz", hash = "sha256:db8a0cc15bcd0f7ae929bcb9108d9e6be228843092aca7956d977c31c4d95aae"}, +] + +[package.dependencies] +asgiref = ">=3.6" +django = ">=3.2" + [[package]] name = "django-ipware" version = "7.0.1" @@ -1159,13 +1191,13 @@ Django = ">=2.2" [[package]] name = "django-timezone-field" -version = "6.1.0" +version = "7.0" description = "A Django app providing DB, form, and REST framework fields for zoneinfo and pytz timezone objects." optional = false -python-versions = ">=3.8,<4.0" +python-versions = "<4.0,>=3.8" files = [ - {file = "django_timezone_field-6.1.0-py3-none-any.whl", hash = "sha256:0095f43da716552fcc606783cfb42cb025892514f1ec660ebfa96186eb83b74c"}, - {file = "django_timezone_field-6.1.0.tar.gz", hash = "sha256:d40f7059d7bae4075725d04a9dae601af9fe3c7f0119a69b0e2c6194a782f797"}, + {file = "django_timezone_field-7.0-py3-none-any.whl", hash = "sha256:3232e7ecde66ba4464abb6f9e6b8cc739b914efb9b29dc2cf2eee451f7cc2acb"}, + {file = "django_timezone_field-7.0.tar.gz", hash = "sha256:aa6f4965838484317b7f08d22c0d91a53d64e7bbbd34264468ae83d4023898a7"}, ] [package.dependencies] @@ -1187,22 +1219,23 @@ django = ">=3.0" [[package]] name = "docker" -version = "7.0.0" +version = "7.1.0" description = "A Python library for the Docker Engine API." optional = true python-versions = ">=3.8" files = [ - {file = "docker-7.0.0-py3-none-any.whl", hash = "sha256:12ba681f2777a0ad28ffbcc846a69c31b4dfd9752b47eb425a274ee269c5e14b"}, - {file = "docker-7.0.0.tar.gz", hash = "sha256:323736fb92cd9418fc5e7133bc953e11a9da04f4483f828b527db553f1e7e5a3"}, + {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, + {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, ] [package.dependencies] -packaging = ">=14.0" pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} requests = ">=2.26.0" urllib3 = ">=1.26.0" [package.extras] +dev = ["coverage (==7.2.7)", "pytest (==7.4.2)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.1.0)", "ruff (==0.1.8)"] +docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] ssh = ["paramiko (>=2.4.3)"] websockets = ["websocket-client (>=1.3.0)"] @@ -1241,13 +1274,13 @@ gmpy2 = ["gmpy2"] [[package]] name = "exceptiongroup" -version = "1.2.1" +version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = true python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, - {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [package.extras] @@ -1496,13 +1529,13 @@ files = [ [[package]] name = "humanize" -version = "4.9.0" +version = "4.10.0" description = "Python humanize utilities" optional = false python-versions = ">=3.8" files = [ - {file = "humanize-4.9.0-py3-none-any.whl", hash = "sha256:ce284a76d5b1377fd8836733b983bfb0b76f1aa1c090de2566fcf008d7f6ab16"}, - {file = "humanize-4.9.0.tar.gz", hash = "sha256:582a265c931c683a7e9b8ed9559089dea7edcf6cc95be39a3cbc2c5d5ac2bcfa"}, + {file = "humanize-4.10.0-py3-none-any.whl", hash = "sha256:39e7ccb96923e732b5c2e27aeaa3b10a8dfeeba3eb965ba7b74a3eb0e30040a6"}, + {file = "humanize-4.10.0.tar.gz", hash = "sha256:06b6eb0293e4b85e8d385397c5868926820db32b9b654b932f57fa41c23c9978"}, ] [package.extras] @@ -1619,31 +1652,21 @@ files = [ ] [[package]] -name = "jschema-to-python" -version = "1.2.3" -description = "Generate source code for Python classes from a JSON schema." +name = "jsondiff" +version = "2.2.0" +description = "Diff JSON and JSON-like structures in Python" optional = true -python-versions = ">= 2.7" +python-versions = ">=3.8" files = [ - {file = "jschema_to_python-1.2.3-py3-none-any.whl", hash = "sha256:8a703ca7604d42d74b2815eecf99a33359a8dccbb80806cce386d5e2dd992b05"}, - {file = "jschema_to_python-1.2.3.tar.gz", hash = "sha256:76ff14fe5d304708ccad1284e4b11f96a658949a31ee7faed9e0995279549b91"}, + {file = "jsondiff-2.2.0-py3-none-any.whl", hash = "sha256:afff7c0067d934e3f2730935dc3abd520ab7d09021c88d3a9f4272e7d2229a1e"}, + {file = "jsondiff-2.2.0.tar.gz", hash = "sha256:060e9a10fe136c643e9d2bf264ea1fbe966ed17d2fd37348dd65b1c650c2df4f"}, ] [package.dependencies] -attrs = "*" -jsonpickle = "*" -pbr = "*" +pyyaml = "*" -[[package]] -name = "jsondiff" -version = "2.0.0" -description = "Diff JSON and JSON-like structures in Python" -optional = true -python-versions = "*" -files = [ - {file = "jsondiff-2.0.0-py3-none-any.whl", hash = "sha256:689841d66273fc88fc79f7d33f4c074774f4f214b6466e3aff0e5adaf889d1e0"}, - {file = "jsondiff-2.0.0.tar.gz", hash = "sha256:2795844ef075ec8a2b8d385c4d59f5ea48b08e7180fce3cb2787be0db00b1fb4"}, -] +[package.extras] +dev = ["build", "hypothesis", "pytest", "setuptools-scm"] [[package]] name = "jsonpatch" @@ -1659,42 +1682,26 @@ files = [ [package.dependencies] jsonpointer = ">=1.9" -[[package]] -name = "jsonpickle" -version = "3.0.4" -description = "Serialize any Python object to JSON" -optional = true -python-versions = ">=3.7" -files = [ - {file = "jsonpickle-3.0.4-py3-none-any.whl", hash = "sha256:04ae7567a14269579e3af66b76bda284587458d7e8a204951ca8f71a3309952e"}, - {file = "jsonpickle-3.0.4.tar.gz", hash = "sha256:a1b14c8d6221cd8f394f2a97e735ea1d7edc927fbd135b26f2f8700657c8c62b"}, -] - -[package.extras] -docs = ["furo", "rst.linker (>=1.9)", "sphinx"] -packaging = ["build", "twine"] -testing = ["bson", "ecdsa", "feedparser", "gmpy2", "numpy", "pandas", "pymongo", "pytest (>=3.5,!=3.7.3)", "pytest-benchmark", "pytest-benchmark[histogram]", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-ruff (>=0.2.1)", "scikit-learn", "scipy", "scipy (>=1.9.3)", "simplejson", "sqlalchemy", "ujson"] - [[package]] name = "jsonpointer" -version = "2.4" +version = "3.0.0" description = "Identify specific nodes in a JSON document (RFC 6901)" optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" +python-versions = ">=3.7" files = [ - {file = "jsonpointer-2.4-py2.py3-none-any.whl", hash = "sha256:15d51bba20eea3165644553647711d150376234112651b4f1811022aecad7d7a"}, - {file = "jsonpointer-2.4.tar.gz", hash = "sha256:585cee82b70211fa9e6043b7bb89db6e1aa49524340dde8ad6b63206ea689d88"}, + {file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"}, + {file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"}, ] [[package]] name = "jsonschema" -version = "4.22.0" +version = "4.23.0" description = "An implementation of JSON Schema validation for Python" optional = true python-versions = ">=3.8" files = [ - {file = "jsonschema-4.22.0-py3-none-any.whl", hash = "sha256:ff4cfd6b1367a40e7bc6411caec72effadd3db0bbe5017de188f2d6108335802"}, - {file = "jsonschema-4.22.0.tar.gz", hash = "sha256:5b22d434a45935119af990552c862e5d6d564e8f6601206b305a61fdf661a2b7"}, + {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, + {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, ] [package.dependencies] @@ -1705,24 +1712,24 @@ rpds-py = ">=0.7.1" [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)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] [[package]] -name = "jsonschema-spec" -version = "0.1.3" +name = "jsonschema-path" +version = "0.3.3" description = "JSONSchema Spec with object-oriented paths" optional = true -python-versions = ">=3.7.0,<4.0.0" +python-versions = "<4.0.0,>=3.8.0" files = [ - {file = "jsonschema_spec-0.1.3-py3-none-any.whl", hash = "sha256:b3cde007ad65c2e631e2f8653cf187124a2c714d02d9fafbab68ad64bf5745d6"}, - {file = "jsonschema_spec-0.1.3.tar.gz", hash = "sha256:8d8db7c255e524fab1016a952a9143e5b6e3c074f4ed25d1878f8e97806caec0"}, + {file = "jsonschema_path-0.3.3-py3-none-any.whl", hash = "sha256:203aff257f8038cd3c67be614fe6b2001043408cb1b4e36576bc4921e09d83c4"}, + {file = "jsonschema_path-0.3.3.tar.gz", hash = "sha256:f02e5481a4288ec062f8e68c808569e427d905bedfecb7f2e4c69ef77957c382"}, ] [package.dependencies] -jsonschema = ">=4.0.0,<5.0.0" pathable = ">=0.4.1,<0.5.0" PyYAML = ">=5.1" -typing-extensions = ">=4.3.0,<5.0.0" +referencing = ">=0.28.0,<0.36.0" +requests = ">=2.31.0,<3.0.0" [[package]] name = "jsonschema-specifications" @@ -1738,20 +1745,6 @@ files = [ [package.dependencies] referencing = ">=0.31.0" -[[package]] -name = "junit-xml" -version = "1.9" -description = "Creates JUnit XML test result documents that can be read by tools such as Jenkins" -optional = true -python-versions = "*" -files = [ - {file = "junit-xml-1.9.tar.gz", hash = "sha256:de16a051990d4e25a3982b2dd9e89d671067548718866416faec14d9de56db9f"}, - {file = "junit_xml-1.9-py2.py3-none-any.whl", hash = "sha256:ec5ca1a55aefdd76d28fcc0b135251d156c7106fa979686a4b48d62b761b4732"}, -] - -[package.dependencies] -six = "*" - [[package]] name = "kombu" version = "5.3.7" @@ -2116,13 +2109,13 @@ files = [ [[package]] name = "multipart" -version = "0.2.4" +version = "0.2.5" description = "Parser for multipart/form-data." optional = true -python-versions = "*" +python-versions = ">=3.5" files = [ - {file = "multipart-0.2.4-py3-none-any.whl", hash = "sha256:5aec990820b8a9e94f9c164fbeb58cf118cfbde2854865b67a9a730edd1fb9d1"}, - {file = "multipart-0.2.4.tar.gz", hash = "sha256:06ba205360bc7096fefe618e4f1e9b2cdb890b4f2157053a81f386912a2522cb"}, + {file = "multipart-0.2.5-py3-none-any.whl", hash = "sha256:96352d67fa1f704e2bcbec9726d7fb316533bd010f0c66639f930fb59b734931"}, + {file = "multipart-0.2.5.tar.gz", hash = "sha256:fa98838d40c967bb19589626a0fb8a5c40c421dda2febe1b0351fcf626e24651"}, ] [[package]] @@ -2203,48 +2196,46 @@ test = ["pytest (>=7.2)", "pytest-cov (>=4.0)"] [[package]] name = "openapi-schema-validator" -version = "0.4.3" +version = "0.6.2" description = "OpenAPI schema validation for Python" optional = true -python-versions = ">=3.7.0,<4.0.0" +python-versions = ">=3.8.0,<4.0.0" files = [ - {file = "openapi_schema_validator-0.4.3-py3-none-any.whl", hash = "sha256:f1eff2a7936546a3ce62b88a17d09de93c9bd229cbc43cb696c988a61a382548"}, - {file = "openapi_schema_validator-0.4.3.tar.gz", hash = "sha256:6940dba9f4906c97078fea6fd9d5a3a3384207db368c4e32f6af6abd7c5c560b"}, + {file = "openapi_schema_validator-0.6.2-py3-none-any.whl", hash = "sha256:c4887c1347c669eb7cded9090f4438b710845cd0f90d1fb9e1b3303fb37339f8"}, + {file = "openapi_schema_validator-0.6.2.tar.gz", hash = "sha256:11a95c9c9017912964e3e5f2545a5b11c3814880681fcacfb73b1759bb4f2804"}, ] [package.dependencies] -jsonschema = ">=4.0.0,<5.0.0" +jsonschema = ">=4.19.1,<5.0.0" +jsonschema-specifications = ">=2023.5.2,<2024.0.0" rfc3339-validator = "*" [[package]] name = "openapi-spec-validator" -version = "0.5.5" +version = "0.7.1" description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3 spec validator" optional = true -python-versions = ">=3.7.0,<4.0.0" +python-versions = ">=3.8.0,<4.0.0" files = [ - {file = "openapi_spec_validator-0.5.5-py3-none-any.whl", hash = "sha256:93ba247f585e1447214b4207728a7cce3726d148238217be69e6b8725c118fbe"}, - {file = "openapi_spec_validator-0.5.5.tar.gz", hash = "sha256:3010df5237748e25d7fac2b2aaf13457c1afd02735b2bd6f008a10079c8f443a"}, + {file = "openapi_spec_validator-0.7.1-py3-none-any.whl", hash = "sha256:3c81825043f24ccbcd2f4b149b11e8231abce5ba84f37065e14ec947d8f4e959"}, + {file = "openapi_spec_validator-0.7.1.tar.gz", hash = "sha256:8577b85a8268685da6f8aa30990b83b7960d4d1117e901d451b5d572605e5ec7"}, ] [package.dependencies] -jsonschema = ">=4.0.0,<5.0.0" -jsonschema-spec = ">=0.1.1,<0.2.0" +jsonschema = ">=4.18.0,<5.0.0" +jsonschema-path = ">=0.3.1,<0.4.0" lazy-object-proxy = ">=1.7.1,<2.0.0" -openapi-schema-validator = ">=0.4.2,<0.5.0" - -[package.extras] -requests = ["requests"] +openapi-schema-validator = ">=0.6.0,<0.7.0" [[package]] name = "packaging" -version = "24.0" +version = "24.1" description = "Core utilities for Python packages" optional = true -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] @@ -2269,17 +2260,6 @@ files = [ {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] -[[package]] -name = "pbr" -version = "6.0.0" -description = "Python Build Reasonableness" -optional = true -python-versions = ">=2.6" -files = [ - {file = "pbr-6.0.0-py2.py3-none-any.whl", hash = "sha256:4a7317d5e3b17a3dccb6a8cfe67dab65b20551404c52c8ed41279fa4f0cb4cda"}, - {file = "pbr-6.0.0.tar.gz", hash = "sha256:d1377122a5a00e2f940ee482999518efe16d745d423a670c27773dfbc3c9a7d9"}, -] - [[package]] name = "pillow" version = "10.2.0" @@ -2367,13 +2347,13 @@ xmp = ["defusedxml"] [[package]] name = "platformdirs" -version = "4.2.1" +version = "4.2.2" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = true python-versions = ">=3.8" files = [ - {file = "platformdirs-4.2.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"}, - {file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"}, + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, ] [package.extras] @@ -2412,13 +2392,13 @@ twisted = ["twisted"] [[package]] name = "prompt-toolkit" -version = "3.0.43" +version = "3.0.47" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.7.0" files = [ - {file = "prompt_toolkit-3.0.43-py3-none-any.whl", hash = "sha256:a11a29cb3bf0a28a387fe5122cdb649816a957cd9261dcedf8c9f1fef33eacf6"}, - {file = "prompt_toolkit-3.0.43.tar.gz", hash = "sha256:3527b7af26106cbc65a040bcc84839a3566ec1b051bb0bfe953631e704b0ff7d"}, + {file = "prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10"}, + {file = "prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360"}, ] [package.dependencies] @@ -2554,109 +2534,122 @@ files = [ [[package]] name = "pydantic" -version = "2.7.1" +version = "2.8.2" description = "Data validation using Python type hints" optional = true python-versions = ">=3.8" files = [ - {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, - {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, ] [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.18.2" -typing-extensions = ">=4.6.1" +pydantic-core = "2.20.1" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] [package.extras] email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.18.2" +version = "2.20.1" description = "Core functionality for Pydantic validation and serialization" optional = true python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, - {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, - {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, - {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, - {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, - {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, - {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, - {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, - {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, - {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, - {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, - {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, - {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, - {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, - {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, - {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, - {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, - {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, - {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, - {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, - {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, - {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, - {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, - {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, - {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, - {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, - {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, - {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, - {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, - {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, - {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, - {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, - {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, - {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, - {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, - {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, - {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, - {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, - {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, - {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, + {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, + {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, + {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, + {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, + {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, + {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, + {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, + {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, ] [package.dependencies] @@ -2785,13 +2778,13 @@ testing = ["filelock"] [[package]] name = "python-crontab" -version = "3.0.0" +version = "3.2.0" description = "Python Crontab API" optional = false python-versions = "*" files = [ - {file = "python-crontab-3.0.0.tar.gz", hash = "sha256:79fb7465039ddfd4fb93d072d6ee0d45c1ac8bf1597f0686ea14fd4361dba379"}, - {file = "python_crontab-3.0.0-py3-none-any.whl", hash = "sha256:6d5ba3c190ec76e4d252989a1644fcb233dbf53fbc8fceeb9febe1657b9fb1d4"}, + {file = "python_crontab-3.2.0-py3-none-any.whl", hash = "sha256:82cb9b6a312d41ff66fd3caf3eed7115c28c195bfb50711bc2b4b9592feb9fe5"}, + {file = "python_crontab-3.2.0.tar.gz", hash = "sha256:40067d1dd39ade3460b2ad8557c7651514cd3851deffff61c5c60e1227c5c36b"}, ] [package.dependencies] @@ -3012,90 +3005,90 @@ rpds-py = ">=0.7.0" [[package]] name = "regex" -version = "2024.5.10" +version = "2024.7.24" description = "Alternative regular expression module, to replace re." optional = true python-versions = ">=3.8" files = [ - {file = "regex-2024.5.10-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eda3dd46df535da787ffb9036b5140f941ecb91701717df91c9daf64cabef953"}, - {file = "regex-2024.5.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1d5bd666466c8f00a06886ce1397ba8b12371c1f1c6d1bef11013e9e0a1464a8"}, - {file = "regex-2024.5.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32e5f3b8e32918bfbdd12eca62e49ab3031125c454b507127ad6ecbd86e62fca"}, - {file = "regex-2024.5.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:534efd2653ebc4f26fc0e47234e53bf0cb4715bb61f98c64d2774a278b58c846"}, - {file = "regex-2024.5.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:193b7c6834a06f722f0ce1ba685efe80881de7c3de31415513862f601097648c"}, - {file = "regex-2024.5.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:160ba087232c5c6e2a1e7ad08bd3a3f49b58c815be0504d8c8aacfb064491cd8"}, - {file = "regex-2024.5.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:951be1eae7b47660412dc4938777a975ebc41936d64e28081bf2e584b47ec246"}, - {file = "regex-2024.5.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8a0f0ab5453e409586b11ebe91c672040bc804ca98d03a656825f7890cbdf88"}, - {file = "regex-2024.5.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9e6d4d6ae1827b2f8c7200aaf7501c37cf3f3896c86a6aaf2566448397c823dd"}, - {file = "regex-2024.5.10-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:161a206c8f3511e2f5fafc9142a2cc25d7fe9a1ec5ad9b4ad2496a7c33e1c5d2"}, - {file = "regex-2024.5.10-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:44b3267cea873684af022822195298501568ed44d542f9a2d9bebc0212e99069"}, - {file = "regex-2024.5.10-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:560278c9975694e1f0bc50da187abf2cdc1e4890739ea33df2bc4a85eeef143e"}, - {file = "regex-2024.5.10-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:70364a097437dd0a90b31cd77f09f7387ad9ac60ef57590971f43b7fca3082a5"}, - {file = "regex-2024.5.10-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:42be5de7cc8c1edac55db92d82b68dc8e683b204d6f5414c5a51997a323d7081"}, - {file = "regex-2024.5.10-cp310-cp310-win32.whl", hash = "sha256:9a8625849387b9d558d528e263ecc9c0fbde86cfa5c2f0eef43fff480ae24d71"}, - {file = "regex-2024.5.10-cp310-cp310-win_amd64.whl", hash = "sha256:903350bf44d7e4116b4d5898b30b15755d61dcd3161e3413a49c7db76f0bee5a"}, - {file = "regex-2024.5.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bf9596cba92ce7b1fd32c7b07c6e3212c7eed0edc271757e48bfcd2b54646452"}, - {file = "regex-2024.5.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:45cc13d398b6359a7708986386f72bd156ae781c3e83a68a6d4cee5af04b1ce9"}, - {file = "regex-2024.5.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ad45f3bccfcb00868f2871dce02a755529838d2b86163ab8a246115e80cfb7d6"}, - {file = "regex-2024.5.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33d19f0cde6838c81acffff25c7708e4adc7dd02896c9ec25c3939b1500a1778"}, - {file = "regex-2024.5.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a9f89d7db5ef6bdf53e5cc8e6199a493d0f1374b3171796b464a74ebe8e508a"}, - {file = "regex-2024.5.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c6c71cf92b09e5faa72ea2c68aa1f61c9ce11cb66fdc5069d712f4392ddfd00"}, - {file = "regex-2024.5.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7467ad8b0eac0b28e52679e972b9b234b3de0ea5cee12eb50091d2b68145fe36"}, - {file = "regex-2024.5.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bc0db93ad039fc2fe32ccd3dd0e0e70c4f3d6e37ae83f0a487e1aba939bd2fbd"}, - {file = "regex-2024.5.10-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fa9335674d7c819674467c7b46154196c51efbaf5f5715187fd366814ba3fa39"}, - {file = "regex-2024.5.10-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7dda3091838206969c2b286f9832dff41e2da545b99d1cfaea9ebd8584d02708"}, - {file = "regex-2024.5.10-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:504b5116e2bd1821efd815941edff7535e93372a098e156bb9dffde30264e798"}, - {file = "regex-2024.5.10-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:91b53dea84415e8115506cc62e441a2b54537359c63d856d73cb1abe05af4c9a"}, - {file = "regex-2024.5.10-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1a3903128f9e17a500618e80c68165c78c741ebb17dd1a0b44575f92c3c68b02"}, - {file = "regex-2024.5.10-cp311-cp311-win32.whl", hash = "sha256:236cace6c1903effd647ed46ce6dd5d76d54985fc36dafc5256032886736c85d"}, - {file = "regex-2024.5.10-cp311-cp311-win_amd64.whl", hash = "sha256:12446827f43c7881decf2c126762e11425de5eb93b3b0d8b581344c16db7047a"}, - {file = "regex-2024.5.10-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:14905ed75c7a6edf423eb46c213ed3f4507c38115f1ed3c00f4ec9eafba50e58"}, - {file = "regex-2024.5.10-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4fad420b14ae1970a1f322e8ae84a1d9d89375eb71e1b504060ab2d1bfe68f3c"}, - {file = "regex-2024.5.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c46a76a599fcbf95f98755275c5527304cc4f1bb69919434c1e15544d7052910"}, - {file = "regex-2024.5.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0faecb6d5779753a6066a3c7a0471a8d29fe25d9981ca9e552d6d1b8f8b6a594"}, - {file = "regex-2024.5.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aab65121229c2ecdf4a31b793d99a6a0501225bd39b616e653c87b219ed34a49"}, - {file = "regex-2024.5.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50e7e96a527488334379e05755b210b7da4a60fc5d6481938c1fa053e0c92184"}, - {file = "regex-2024.5.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba034c8db4b264ef1601eb33cd23d87c5013b8fb48b8161debe2e5d3bd9156b0"}, - {file = "regex-2024.5.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:031219782d97550c2098d9a68ce9e9eaefe67d2d81d8ff84c8354f9c009e720c"}, - {file = "regex-2024.5.10-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:62b5f7910b639f3c1d122d408421317c351e213ca39c964ad4121f27916631c6"}, - {file = "regex-2024.5.10-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cd832bd9b6120d6074f39bdfbb3c80e416848b07ac72910f1c7f03131a6debc3"}, - {file = "regex-2024.5.10-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:e91b1976358e17197157b405cab408a5f4e33310cda211c49fc6da7cffd0b2f0"}, - {file = "regex-2024.5.10-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:571452362d552de508c37191b6abbbb660028b8b418e2d68c20779e0bc8eaaa8"}, - {file = "regex-2024.5.10-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5253dcb0bfda7214523de58b002eb0090cb530d7c55993ce5f6d17faf953ece7"}, - {file = "regex-2024.5.10-cp312-cp312-win32.whl", hash = "sha256:2f30a5ab8902f93930dc6f627c4dd5da2703333287081c85cace0fc6e21c25af"}, - {file = "regex-2024.5.10-cp312-cp312-win_amd64.whl", hash = "sha256:3799e36d60a35162bb35b2246d8bb012192b7437dff807ef79c14e7352706306"}, - {file = "regex-2024.5.10-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:bbdc5db2c98ac2bf1971ffa1410c87ca7a15800415f788971e8ba8520fc0fda9"}, - {file = "regex-2024.5.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6ccdeef4584450b6f0bddd5135354908dacad95425fcb629fe36d13e48b60f32"}, - {file = "regex-2024.5.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:29d839829209f3c53f004e1de8c3113efce6d98029f044fa5cfee666253ee7e6"}, - {file = "regex-2024.5.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0709ba544cf50bd5cb843df4b8bb6701bae2b70a8e88da9add8386cbca5c1385"}, - {file = "regex-2024.5.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:972b49f2fe1047b9249c958ec4fa1bdd2cf8ce305dc19d27546d5a38e57732d8"}, - {file = "regex-2024.5.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cdbb1998da94607d5eec02566b9586f0e70d6438abf1b690261aac0edda7ab6"}, - {file = "regex-2024.5.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf7c8ee4861d9ef5b1120abb75846828c811f932d63311596ad25fa168053e00"}, - {file = "regex-2024.5.10-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d35d4cc9270944e95f9c88af757b0c9fc43f396917e143a5756608462c5223b"}, - {file = "regex-2024.5.10-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8722f72068b3e1156a4b2e1afde6810f1fc67155a9fa30a4b9d5b4bc46f18fb0"}, - {file = "regex-2024.5.10-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:696639a73ca78a380acfaa0a1f6dd8220616a99074c05bba9ba8bb916914b224"}, - {file = "regex-2024.5.10-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea057306ab469130167014b662643cfaed84651c792948891d003cf0039223a5"}, - {file = "regex-2024.5.10-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:b43b78f9386d3d932a6ce5af4b45f393d2e93693ee18dc4800d30a8909df700e"}, - {file = "regex-2024.5.10-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:c43395a3b7cc9862801a65c6994678484f186ce13c929abab44fb8a9e473a55a"}, - {file = "regex-2024.5.10-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0bc94873ba11e34837bffd7e5006703abeffc4514e2f482022f46ce05bd25e67"}, - {file = "regex-2024.5.10-cp38-cp38-win32.whl", hash = "sha256:1118ba9def608250250f4b3e3f48c62f4562ba16ca58ede491b6e7554bfa09ff"}, - {file = "regex-2024.5.10-cp38-cp38-win_amd64.whl", hash = "sha256:458d68d34fb74b906709735c927c029e62f7d06437a98af1b5b6258025223210"}, - {file = "regex-2024.5.10-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:15e593386ec6331e0ab4ac0795b7593f02ab2f4b30a698beb89fbdc34f92386a"}, - {file = "regex-2024.5.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ca23b41355ba95929e9505ee04e55495726aa2282003ed9b012d86f857d3e49b"}, - {file = "regex-2024.5.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2c8982ee19ccecabbaeac1ba687bfef085a6352a8c64f821ce2f43e6d76a9298"}, - {file = "regex-2024.5.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7117cb7d6ac7f2e985f3d18aa8a1728864097da1a677ffa69e970ca215baebf1"}, - {file = "regex-2024.5.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b66421f8878a0c82fc0c272a43e2121c8d4c67cb37429b764f0d5ad70b82993b"}, - {file = "regex-2024.5.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:224a9269f133564109ce668213ef3cb32bc72ccf040b0b51c72a50e569e9dc9e"}, - {file = "regex-2024.5.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab98016541543692a37905871a5ffca59b16e08aacc3d7d10a27297b443f572d"}, - {file = "regex-2024.5.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51d27844763c273a122e08a3e86e7aefa54ee09fb672d96a645ece0454d8425e"}, - {file = "regex-2024.5.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:853cc36e756ff673bf984e9044ccc8fad60b95a748915dddeab9488aea974c73"}, - {file = "regex-2024.5.10-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4e7eaf9df15423d07b6050fb91f86c66307171b95ea53e2d87a7993b6d02c7f7"}, - {file = "regex-2024.5.10-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:169fd0acd7a259f58f417e492e93d0e15fc87592cd1e971c8c533ad5703b5830"}, - {file = "regex-2024.5.10-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:334b79ce9c08f26b4659a53f42892793948a613c46f1b583e985fd5a6bf1c149"}, - {file = "regex-2024.5.10-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:f03b1dbd4d9596dd84955bb40f7d885204d6aac0d56a919bb1e0ff2fb7e1735a"}, - {file = "regex-2024.5.10-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfa6d61a76c77610ba9274c1a90a453062bdf6887858afbe214d18ad41cf6bde"}, - {file = "regex-2024.5.10-cp39-cp39-win32.whl", hash = "sha256:249fbcee0a277c32a3ce36d8e36d50c27c968fdf969e0fbe342658d4e010fbc8"}, - {file = "regex-2024.5.10-cp39-cp39-win_amd64.whl", hash = "sha256:0ce56a923f4c01d7568811bfdffe156268c0a7aae8a94c902b92fe34c4bde785"}, - {file = "regex-2024.5.10.tar.gz", hash = "sha256:304e7e2418146ae4d0ef0e9ffa28f881f7874b45b4994cc2279b21b6e7ae50c8"}, + {file = "regex-2024.7.24-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b0d3f567fafa0633aee87f08b9276c7062da9616931382993c03808bb68ce"}, + {file = "regex-2024.7.24-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3426de3b91d1bc73249042742f45c2148803c111d1175b283270177fdf669024"}, + {file = "regex-2024.7.24-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f273674b445bcb6e4409bf8d1be67bc4b58e8b46fd0d560055d515b8830063cd"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23acc72f0f4e1a9e6e9843d6328177ae3074b4182167e34119ec7233dfeccf53"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65fd3d2e228cae024c411c5ccdffae4c315271eee4a8b839291f84f796b34eca"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c414cbda77dbf13c3bc88b073a1a9f375c7b0cb5e115e15d4b73ec3a2fbc6f59"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf7a89eef64b5455835f5ed30254ec19bf41f7541cd94f266ab7cbd463f00c41"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19c65b00d42804e3fbea9708f0937d157e53429a39b7c61253ff15670ff62cb5"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7a5486ca56c8869070a966321d5ab416ff0f83f30e0e2da1ab48815c8d165d46"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f51f9556785e5a203713f5efd9c085b4a45aecd2a42573e2b5041881b588d1f"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a4997716674d36a82eab3e86f8fa77080a5d8d96a389a61ea1d0e3a94a582cf7"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c0abb5e4e8ce71a61d9446040c1e86d4e6d23f9097275c5bd49ed978755ff0fe"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:18300a1d78cf1290fa583cd8b7cde26ecb73e9f5916690cf9d42de569c89b1ce"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:416c0e4f56308f34cdb18c3f59849479dde5b19febdcd6e6fa4d04b6c31c9faa"}, + {file = "regex-2024.7.24-cp310-cp310-win32.whl", hash = "sha256:fb168b5924bef397b5ba13aabd8cf5df7d3d93f10218d7b925e360d436863f66"}, + {file = "regex-2024.7.24-cp310-cp310-win_amd64.whl", hash = "sha256:6b9fc7e9cc983e75e2518496ba1afc524227c163e43d706688a6bb9eca41617e"}, + {file = "regex-2024.7.24-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:382281306e3adaaa7b8b9ebbb3ffb43358a7bbf585fa93821300a418bb975281"}, + {file = "regex-2024.7.24-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4fdd1384619f406ad9037fe6b6eaa3de2749e2e12084abc80169e8e075377d3b"}, + {file = "regex-2024.7.24-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3d974d24edb231446f708c455fd08f94c41c1ff4f04bcf06e5f36df5ef50b95a"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2ec4419a3fe6cf8a4795752596dfe0adb4aea40d3683a132bae9c30b81e8d73"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb563dd3aea54c797adf513eeec819c4213d7dbfc311874eb4fd28d10f2ff0f2"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:45104baae8b9f67569f0f1dca5e1f1ed77a54ae1cd8b0b07aba89272710db61e"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:994448ee01864501912abf2bad9203bffc34158e80fe8bfb5b031f4f8e16da51"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fac296f99283ac232d8125be932c5cd7644084a30748fda013028c815ba3364"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e37e809b9303ec3a179085415cb5f418ecf65ec98cdfe34f6a078b46ef823ee"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:01b689e887f612610c869421241e075c02f2e3d1ae93a037cb14f88ab6a8934c"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f6442f0f0ff81775eaa5b05af8a0ffa1dda36e9cf6ec1e0d3d245e8564b684ce"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:871e3ab2838fbcb4e0865a6e01233975df3a15e6fce93b6f99d75cacbd9862d1"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c918b7a1e26b4ab40409820ddccc5d49871a82329640f5005f73572d5eaa9b5e"}, + {file = "regex-2024.7.24-cp311-cp311-win32.whl", hash = "sha256:2dfbb8baf8ba2c2b9aa2807f44ed272f0913eeeba002478c4577b8d29cde215c"}, + {file = "regex-2024.7.24-cp311-cp311-win_amd64.whl", hash = "sha256:538d30cd96ed7d1416d3956f94d54e426a8daf7c14527f6e0d6d425fcb4cca52"}, + {file = "regex-2024.7.24-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:fe4ebef608553aff8deb845c7f4f1d0740ff76fa672c011cc0bacb2a00fbde86"}, + {file = "regex-2024.7.24-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:74007a5b25b7a678459f06559504f1eec2f0f17bca218c9d56f6a0a12bfffdad"}, + {file = "regex-2024.7.24-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7df9ea48641da022c2a3c9c641650cd09f0cd15e8908bf931ad538f5ca7919c9"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a1141a1dcc32904c47f6846b040275c6e5de0bf73f17d7a409035d55b76f289"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80c811cfcb5c331237d9bad3bea2c391114588cf4131707e84d9493064d267f9"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7214477bf9bd195894cf24005b1e7b496f46833337b5dedb7b2a6e33f66d962c"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d55588cba7553f0b6ec33130bc3e114b355570b45785cebdc9daed8c637dd440"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:558a57cfc32adcf19d3f791f62b5ff564922942e389e3cfdb538a23d65a6b610"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a512eed9dfd4117110b1881ba9a59b31433caed0c4101b361f768e7bcbaf93c5"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:86b17ba823ea76256b1885652e3a141a99a5c4422f4a869189db328321b73799"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5eefee9bfe23f6df09ffb6dfb23809f4d74a78acef004aa904dc7c88b9944b05"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:731fcd76bbdbf225e2eb85b7c38da9633ad3073822f5ab32379381e8c3c12e94"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eaef80eac3b4cfbdd6de53c6e108b4c534c21ae055d1dbea2de6b3b8ff3def38"}, + {file = "regex-2024.7.24-cp312-cp312-win32.whl", hash = "sha256:185e029368d6f89f36e526764cf12bf8d6f0e3a2a7737da625a76f594bdfcbfc"}, + {file = "regex-2024.7.24-cp312-cp312-win_amd64.whl", hash = "sha256:2f1baff13cc2521bea83ab2528e7a80cbe0ebb2c6f0bfad15be7da3aed443908"}, + {file = "regex-2024.7.24-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:66b4c0731a5c81921e938dcf1a88e978264e26e6ac4ec96a4d21ae0354581ae0"}, + {file = "regex-2024.7.24-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:88ecc3afd7e776967fa16c80f974cb79399ee8dc6c96423321d6f7d4b881c92b"}, + {file = "regex-2024.7.24-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:64bd50cf16bcc54b274e20235bf8edbb64184a30e1e53873ff8d444e7ac656b2"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb462f0e346fcf41a901a126b50f8781e9a474d3927930f3490f38a6e73b6950"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a82465ebbc9b1c5c50738536fdfa7cab639a261a99b469c9d4c7dcbb2b3f1e57"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68a8f8c046c6466ac61a36b65bb2395c74451df2ffb8458492ef49900efed293"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac8e84fff5d27420f3c1e879ce9929108e873667ec87e0c8eeb413a5311adfe"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba2537ef2163db9e6ccdbeb6f6424282ae4dea43177402152c67ef869cf3978b"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:43affe33137fcd679bdae93fb25924979517e011f9dea99163f80b82eadc7e53"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:c9bb87fdf2ab2370f21e4d5636e5317775e5d51ff32ebff2cf389f71b9b13750"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:945352286a541406f99b2655c973852da7911b3f4264e010218bbc1cc73168f2"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:8bc593dcce679206b60a538c302d03c29b18e3d862609317cb560e18b66d10cf"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3f3b6ca8eae6d6c75a6cff525c8530c60e909a71a15e1b731723233331de4169"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c51edc3541e11fbe83f0c4d9412ef6c79f664a3745fab261457e84465ec9d5a8"}, + {file = "regex-2024.7.24-cp38-cp38-win32.whl", hash = "sha256:d0a07763776188b4db4c9c7fb1b8c494049f84659bb387b71c73bbc07f189e96"}, + {file = "regex-2024.7.24-cp38-cp38-win_amd64.whl", hash = "sha256:8fd5afd101dcf86a270d254364e0e8dddedebe6bd1ab9d5f732f274fa00499a5"}, + {file = "regex-2024.7.24-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0ffe3f9d430cd37d8fa5632ff6fb36d5b24818c5c986893063b4e5bdb84cdf24"}, + {file = "regex-2024.7.24-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25419b70ba00a16abc90ee5fce061228206173231f004437730b67ac77323f0d"}, + {file = "regex-2024.7.24-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:33e2614a7ce627f0cdf2ad104797d1f68342d967de3695678c0cb84f530709f8"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d33a0021893ede5969876052796165bab6006559ab845fd7b515a30abdd990dc"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04ce29e2c5fedf296b1a1b0acc1724ba93a36fb14031f3abfb7abda2806c1535"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b16582783f44fbca6fcf46f61347340c787d7530d88b4d590a397a47583f31dd"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:836d3cc225b3e8a943d0b02633fb2f28a66e281290302a79df0e1eaa984ff7c1"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:438d9f0f4bc64e8dea78274caa5af971ceff0f8771e1a2333620969936ba10be"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:973335b1624859cb0e52f96062a28aa18f3a5fc77a96e4a3d6d76e29811a0e6e"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c5e69fd3eb0b409432b537fe3c6f44ac089c458ab6b78dcec14478422879ec5f"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fbf8c2f00904eaf63ff37718eb13acf8e178cb940520e47b2f05027f5bb34ce3"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ae2757ace61bc4061b69af19e4689fa4416e1a04840f33b441034202b5cd02d4"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:44fc61b99035fd9b3b9453f1713234e5a7c92a04f3577252b45feefe1b327759"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:84c312cdf839e8b579f504afcd7b65f35d60b6285d892b19adea16355e8343c9"}, + {file = "regex-2024.7.24-cp39-cp39-win32.whl", hash = "sha256:ca5b2028c2f7af4e13fb9fc29b28d0ce767c38c7facdf64f6c2cd040413055f1"}, + {file = "regex-2024.7.24-cp39-cp39-win_amd64.whl", hash = "sha256:7c479f5ae937ec9985ecaf42e2e10631551d909f203e31308c12d703922742f9"}, + {file = "regex-2024.7.24.tar.gz", hash = "sha256:9cfd009eed1a46b27c14039ad5bbc5e71b6367c5b2e6d5f5da0ea91600817506"}, ] [[package]] @@ -3121,13 +3114,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "responses" -version = "0.25.0" +version = "0.25.3" description = "A utility library for mocking out the `requests` Python library." optional = true python-versions = ">=3.8" files = [ - {file = "responses-0.25.0-py3-none-any.whl", hash = "sha256:2f0b9c2b6437db4b528619a77e5d565e4ec2a9532162ac1a131a83529db7be1a"}, - {file = "responses-0.25.0.tar.gz", hash = "sha256:01ae6a02b4f34e39bffceb0fc6786b67a25eae919c6368d05eabc8d9576c2a66"}, + {file = "responses-0.25.3-py3-none-any.whl", hash = "sha256:521efcbc82081ab8daa588e08f7e8a64ce79b91c39f6e62199b19159bea7dbcb"}, + {file = "responses-0.25.3.tar.gz", hash = "sha256:617b9247abd9ae28313d57a75880422d55ec63c29d33d629697590a034358dba"}, ] [package.dependencies] @@ -3186,110 +3179,114 @@ files = [ [[package]] name = "rpds-py" -version = "0.18.1" +version = "0.19.1" description = "Python bindings to Rust's persistent data structures (rpds)" optional = true python-versions = ">=3.8" files = [ - {file = "rpds_py-0.18.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:d31dea506d718693b6b2cffc0648a8929bdc51c70a311b2770f09611caa10d53"}, - {file = "rpds_py-0.18.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:732672fbc449bab754e0b15356c077cc31566df874964d4801ab14f71951ea80"}, - {file = "rpds_py-0.18.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a98a1f0552b5f227a3d6422dbd61bc6f30db170939bd87ed14f3c339aa6c7c9"}, - {file = "rpds_py-0.18.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f1944ce16401aad1e3f7d312247b3d5de7981f634dc9dfe90da72b87d37887d"}, - {file = "rpds_py-0.18.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38e14fb4e370885c4ecd734f093a2225ee52dc384b86fa55fe3f74638b2cfb09"}, - {file = "rpds_py-0.18.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08d74b184f9ab6289b87b19fe6a6d1a97fbfea84b8a3e745e87a5de3029bf944"}, - {file = "rpds_py-0.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d70129cef4a8d979caa37e7fe957202e7eee8ea02c5e16455bc9808a59c6b2f0"}, - {file = "rpds_py-0.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce0bb20e3a11bd04461324a6a798af34d503f8d6f1aa3d2aa8901ceaf039176d"}, - {file = "rpds_py-0.18.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81c5196a790032e0fc2464c0b4ab95f8610f96f1f2fa3d4deacce6a79852da60"}, - {file = "rpds_py-0.18.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f3027be483868c99b4985fda802a57a67fdf30c5d9a50338d9db646d590198da"}, - {file = "rpds_py-0.18.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d44607f98caa2961bab4fa3c4309724b185b464cdc3ba6f3d7340bac3ec97cc1"}, - {file = "rpds_py-0.18.1-cp310-none-win32.whl", hash = "sha256:c273e795e7a0f1fddd46e1e3cb8be15634c29ae8ff31c196debb620e1edb9333"}, - {file = "rpds_py-0.18.1-cp310-none-win_amd64.whl", hash = "sha256:8352f48d511de5f973e4f2f9412736d7dea76c69faa6d36bcf885b50c758ab9a"}, - {file = "rpds_py-0.18.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6b5ff7e1d63a8281654b5e2896d7f08799378e594f09cf3674e832ecaf396ce8"}, - {file = "rpds_py-0.18.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8927638a4d4137a289e41d0fd631551e89fa346d6dbcfc31ad627557d03ceb6d"}, - {file = "rpds_py-0.18.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:154bf5c93d79558b44e5b50cc354aa0459e518e83677791e6adb0b039b7aa6a7"}, - {file = "rpds_py-0.18.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07f2139741e5deb2c5154a7b9629bc5aa48c766b643c1a6750d16f865a82c5fc"}, - {file = "rpds_py-0.18.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c7672e9fba7425f79019db9945b16e308ed8bc89348c23d955c8c0540da0a07"}, - {file = "rpds_py-0.18.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:489bdfe1abd0406eba6b3bb4fdc87c7fa40f1031de073d0cfb744634cc8fa261"}, - {file = "rpds_py-0.18.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c20f05e8e3d4fc76875fc9cb8cf24b90a63f5a1b4c5b9273f0e8225e169b100"}, - {file = "rpds_py-0.18.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:967342e045564cef76dfcf1edb700b1e20838d83b1aa02ab313e6a497cf923b8"}, - {file = "rpds_py-0.18.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2cc7c1a47f3a63282ab0f422d90ddac4aa3034e39fc66a559ab93041e6505da7"}, - {file = "rpds_py-0.18.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f7afbfee1157e0f9376c00bb232e80a60e59ed716e3211a80cb8506550671e6e"}, - {file = "rpds_py-0.18.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e6934d70dc50f9f8ea47081ceafdec09245fd9f6032669c3b45705dea096b88"}, - {file = "rpds_py-0.18.1-cp311-none-win32.whl", hash = "sha256:c69882964516dc143083d3795cb508e806b09fc3800fd0d4cddc1df6c36e76bb"}, - {file = "rpds_py-0.18.1-cp311-none-win_amd64.whl", hash = "sha256:70a838f7754483bcdc830444952fd89645569e7452e3226de4a613a4c1793fb2"}, - {file = "rpds_py-0.18.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3dd3cd86e1db5aadd334e011eba4e29d37a104b403e8ca24dcd6703c68ca55b3"}, - {file = "rpds_py-0.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05f3d615099bd9b13ecf2fc9cf2d839ad3f20239c678f461c753e93755d629ee"}, - {file = "rpds_py-0.18.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35b2b771b13eee8729a5049c976197ff58a27a3829c018a04341bcf1ae409b2b"}, - {file = "rpds_py-0.18.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee17cd26b97d537af8f33635ef38be873073d516fd425e80559f4585a7b90c43"}, - {file = "rpds_py-0.18.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b646bf655b135ccf4522ed43d6902af37d3f5dbcf0da66c769a2b3938b9d8184"}, - {file = "rpds_py-0.18.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19ba472b9606c36716062c023afa2484d1e4220548751bda14f725a7de17b4f6"}, - {file = "rpds_py-0.18.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e30ac5e329098903262dc5bdd7e2086e0256aa762cc8b744f9e7bf2a427d3f8"}, - {file = "rpds_py-0.18.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d58ad6317d188c43750cb76e9deacf6051d0f884d87dc6518e0280438648a9ac"}, - {file = "rpds_py-0.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e1735502458621921cee039c47318cb90b51d532c2766593be6207eec53e5c4c"}, - {file = "rpds_py-0.18.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f5bab211605d91db0e2995a17b5c6ee5edec1270e46223e513eaa20da20076ac"}, - {file = "rpds_py-0.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2fc24a329a717f9e2448f8cd1f960f9dac4e45b6224d60734edeb67499bab03a"}, - {file = "rpds_py-0.18.1-cp312-none-win32.whl", hash = "sha256:1805d5901779662d599d0e2e4159d8a82c0b05faa86ef9222bf974572286b2b6"}, - {file = "rpds_py-0.18.1-cp312-none-win_amd64.whl", hash = "sha256:720edcb916df872d80f80a1cc5ea9058300b97721efda8651efcd938a9c70a72"}, - {file = "rpds_py-0.18.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:c827576e2fa017a081346dce87d532a5310241648eb3700af9a571a6e9fc7e74"}, - {file = "rpds_py-0.18.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:aa3679e751408d75a0b4d8d26d6647b6d9326f5e35c00a7ccd82b78ef64f65f8"}, - {file = "rpds_py-0.18.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0abeee75434e2ee2d142d650d1e54ac1f8b01e6e6abdde8ffd6eeac6e9c38e20"}, - {file = "rpds_py-0.18.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed402d6153c5d519a0faf1bb69898e97fb31613b49da27a84a13935ea9164dfc"}, - {file = "rpds_py-0.18.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:338dee44b0cef8b70fd2ef54b4e09bb1b97fc6c3a58fea5db6cc083fd9fc2724"}, - {file = "rpds_py-0.18.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7750569d9526199c5b97e5a9f8d96a13300950d910cf04a861d96f4273d5b104"}, - {file = "rpds_py-0.18.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:607345bd5912aacc0c5a63d45a1f73fef29e697884f7e861094e443187c02be5"}, - {file = "rpds_py-0.18.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:207c82978115baa1fd8d706d720b4a4d2b0913df1c78c85ba73fe6c5804505f0"}, - {file = "rpds_py-0.18.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6d1e42d2735d437e7e80bab4d78eb2e459af48c0a46e686ea35f690b93db792d"}, - {file = "rpds_py-0.18.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5463c47c08630007dc0fe99fb480ea4f34a89712410592380425a9b4e1611d8e"}, - {file = "rpds_py-0.18.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:06d218939e1bf2ca50e6b0ec700ffe755e5216a8230ab3e87c059ebb4ea06afc"}, - {file = "rpds_py-0.18.1-cp38-none-win32.whl", hash = "sha256:312fe69b4fe1ffbe76520a7676b1e5ac06ddf7826d764cc10265c3b53f96dbe9"}, - {file = "rpds_py-0.18.1-cp38-none-win_amd64.whl", hash = "sha256:9437ca26784120a279f3137ee080b0e717012c42921eb07861b412340f85bae2"}, - {file = "rpds_py-0.18.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:19e515b78c3fc1039dd7da0a33c28c3154458f947f4dc198d3c72db2b6b5dc93"}, - {file = "rpds_py-0.18.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7b28c5b066bca9a4eb4e2f2663012debe680f097979d880657f00e1c30875a0"}, - {file = "rpds_py-0.18.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:673fdbbf668dd958eff750e500495ef3f611e2ecc209464f661bc82e9838991e"}, - {file = "rpds_py-0.18.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d960de62227635d2e61068f42a6cb6aae91a7fe00fca0e3aeed17667c8a34611"}, - {file = "rpds_py-0.18.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:352a88dc7892f1da66b6027af06a2e7e5d53fe05924cc2cfc56495b586a10b72"}, - {file = "rpds_py-0.18.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e0ee01ad8260184db21468a6e1c37afa0529acc12c3a697ee498d3c2c4dcaf3"}, - {file = "rpds_py-0.18.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4c39ad2f512b4041343ea3c7894339e4ca7839ac38ca83d68a832fc8b3748ab"}, - {file = "rpds_py-0.18.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aaa71ee43a703c321906813bb252f69524f02aa05bf4eec85f0c41d5d62d0f4c"}, - {file = "rpds_py-0.18.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6cd8098517c64a85e790657e7b1e509b9fe07487fd358e19431cb120f7d96338"}, - {file = "rpds_py-0.18.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4adec039b8e2928983f885c53b7cc4cda8965b62b6596501a0308d2703f8af1b"}, - {file = "rpds_py-0.18.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:32b7daaa3e9389db3695964ce8e566e3413b0c43e3394c05e4b243a4cd7bef26"}, - {file = "rpds_py-0.18.1-cp39-none-win32.whl", hash = "sha256:2625f03b105328729f9450c8badda34d5243231eef6535f80064d57035738360"}, - {file = "rpds_py-0.18.1-cp39-none-win_amd64.whl", hash = "sha256:bf18932d0003c8c4d51a39f244231986ab23ee057d235a12b2684ea26a353590"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cbfbea39ba64f5e53ae2915de36f130588bba71245b418060ec3330ebf85678e"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a3d456ff2a6a4d2adcdf3c1c960a36f4fd2fec6e3b4902a42a384d17cf4e7a65"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7700936ef9d006b7ef605dc53aa364da2de5a3aa65516a1f3ce73bf82ecfc7ae"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:51584acc5916212e1bf45edd17f3a6b05fe0cbb40482d25e619f824dccb679de"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:942695a206a58d2575033ff1e42b12b2aece98d6003c6bc739fbf33d1773b12f"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b906b5f58892813e5ba5c6056d6a5ad08f358ba49f046d910ad992196ea61397"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6f8e3fecca256fefc91bb6765a693d96692459d7d4c644660a9fff32e517843"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7732770412bab81c5a9f6d20aeb60ae943a9b36dcd990d876a773526468e7163"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:bd1105b50ede37461c1d51b9698c4f4be6e13e69a908ab7751e3807985fc0346"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:618916f5535784960f3ecf8111581f4ad31d347c3de66d02e728de460a46303c"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:17c6d2155e2423f7e79e3bb18151c686d40db42d8645e7977442170c360194d4"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6c4c4c3f878df21faf5fac86eda32671c27889e13570645a9eea0a1abdd50922"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:fab6ce90574645a0d6c58890e9bcaac8d94dff54fb51c69e5522a7358b80ab64"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:531796fb842b53f2695e94dc338929e9f9dbf473b64710c28af5a160b2a8927d"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:740884bc62a5e2bbb31e584f5d23b32320fd75d79f916f15a788d527a5e83644"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:998125738de0158f088aef3cb264a34251908dd2e5d9966774fdab7402edfab7"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2be6e9dd4111d5b31ba3b74d17da54a8319d8168890fbaea4b9e5c3de630ae5"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0cee71bc618cd93716f3c1bf56653740d2d13ddbd47673efa8bf41435a60daa"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c3caec4ec5cd1d18e5dd6ae5194d24ed12785212a90b37f5f7f06b8bedd7139"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:27bba383e8c5231cd559affe169ca0b96ec78d39909ffd817f28b166d7ddd4d8"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-musllinux_1_2_i686.whl", hash = "sha256:a888e8bdb45916234b99da2d859566f1e8a1d2275a801bb8e4a9644e3c7e7909"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6031b25fb1b06327b43d841f33842b383beba399884f8228a6bb3df3088485ff"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48c2faaa8adfacefcbfdb5f2e2e7bdad081e5ace8d182e5f4ade971f128e6bb3"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:d85164315bd68c0806768dc6bb0429c6f95c354f87485ee3593c4f6b14def2bd"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6afd80f6c79893cfc0574956f78a0add8c76e3696f2d6a15bca2c66c415cf2d4"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa242ac1ff583e4ec7771141606aafc92b361cd90a05c30d93e343a0c2d82a89"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21be4770ff4e08698e1e8e0bce06edb6ea0626e7c8f560bc08222880aca6a6f"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c45a639e93a0c5d4b788b2613bd637468edd62f8f95ebc6fcc303d58ab3f0a8"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:910e71711d1055b2768181efa0a17537b2622afeb0424116619817007f8a2b10"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b9bb1f182a97880f6078283b3505a707057c42bf55d8fca604f70dedfdc0772a"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:1d54f74f40b1f7aaa595a02ff42ef38ca654b1469bef7d52867da474243cc633"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:8d2e182c9ee01135e11e9676e9a62dfad791a7a467738f06726872374a83db49"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:636a15acc588f70fda1661234761f9ed9ad79ebed3f2125d44be0862708b666e"}, - {file = "rpds_py-0.18.1.tar.gz", hash = "sha256:dc48b479d540770c811fbd1eb9ba2bb66951863e448efec2e2c102625328e92f"}, + {file = "rpds_py-0.19.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:aaf71f95b21f9dc708123335df22e5a2fef6307e3e6f9ed773b2e0938cc4d491"}, + {file = "rpds_py-0.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca0dda0c5715efe2ab35bb83f813f681ebcd2840d8b1b92bfc6fe3ab382fae4a"}, + {file = "rpds_py-0.19.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81db2e7282cc0487f500d4db203edc57da81acde9e35f061d69ed983228ffe3b"}, + {file = "rpds_py-0.19.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8dfa125b60ec00c7c9baef945bb04abf8ac772d8ebefd79dae2a5f316d7850"}, + {file = "rpds_py-0.19.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:271accf41b02687cef26367c775ab220372ee0f4925591c6796e7c148c50cab5"}, + {file = "rpds_py-0.19.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9bc4161bd3b970cd6a6fcda70583ad4afd10f2750609fb1f3ca9505050d4ef3"}, + {file = "rpds_py-0.19.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0cf2a0dbb5987da4bd92a7ca727eadb225581dd9681365beba9accbe5308f7d"}, + {file = "rpds_py-0.19.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b5e28e56143750808c1c79c70a16519e9bc0a68b623197b96292b21b62d6055c"}, + {file = "rpds_py-0.19.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c7af6f7b80f687b33a4cdb0a785a5d4de1fb027a44c9a049d8eb67d5bfe8a687"}, + {file = "rpds_py-0.19.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e429fc517a1c5e2a70d576077231538a98d59a45dfc552d1ac45a132844e6dfb"}, + {file = "rpds_py-0.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d2dbd8f4990d4788cb122f63bf000357533f34860d269c1a8e90ae362090ff3a"}, + {file = "rpds_py-0.19.1-cp310-none-win32.whl", hash = "sha256:e0f9d268b19e8f61bf42a1da48276bcd05f7ab5560311f541d22557f8227b866"}, + {file = "rpds_py-0.19.1-cp310-none-win_amd64.whl", hash = "sha256:df7c841813f6265e636fe548a49664c77af31ddfa0085515326342a751a6ba51"}, + {file = "rpds_py-0.19.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:902cf4739458852fe917104365ec0efbea7d29a15e4276c96a8d33e6ed8ec137"}, + {file = "rpds_py-0.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f3d73022990ab0c8b172cce57c69fd9a89c24fd473a5e79cbce92df87e3d9c48"}, + {file = "rpds_py-0.19.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3837c63dd6918a24de6c526277910e3766d8c2b1627c500b155f3eecad8fad65"}, + {file = "rpds_py-0.19.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cdb7eb3cf3deb3dd9e7b8749323b5d970052711f9e1e9f36364163627f96da58"}, + {file = "rpds_py-0.19.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26ab43b6d65d25b1a333c8d1b1c2f8399385ff683a35ab5e274ba7b8bb7dc61c"}, + {file = "rpds_py-0.19.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75130df05aae7a7ac171b3b5b24714cffeabd054ad2ebc18870b3aa4526eba23"}, + {file = "rpds_py-0.19.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c34f751bf67cab69638564eee34023909380ba3e0d8ee7f6fe473079bf93f09b"}, + {file = "rpds_py-0.19.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f2671cb47e50a97f419a02cd1e0c339b31de017b033186358db92f4d8e2e17d8"}, + {file = "rpds_py-0.19.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c73254c256081704dba0a333457e2fb815364018788f9b501efe7c5e0ada401"}, + {file = "rpds_py-0.19.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4383beb4a29935b8fa28aca8fa84c956bf545cb0c46307b091b8d312a9150e6a"}, + {file = "rpds_py-0.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dbceedcf4a9329cc665452db1aaf0845b85c666e4885b92ee0cddb1dbf7e052a"}, + {file = "rpds_py-0.19.1-cp311-none-win32.whl", hash = "sha256:f0a6d4a93d2a05daec7cb885157c97bbb0be4da739d6f9dfb02e101eb40921cd"}, + {file = "rpds_py-0.19.1-cp311-none-win_amd64.whl", hash = "sha256:c149a652aeac4902ecff2dd93c3b2681c608bd5208c793c4a99404b3e1afc87c"}, + {file = "rpds_py-0.19.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:56313be667a837ff1ea3508cebb1ef6681d418fa2913a0635386cf29cff35165"}, + {file = "rpds_py-0.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d1d7539043b2b31307f2c6c72957a97c839a88b2629a348ebabe5aa8b626d6b"}, + {file = "rpds_py-0.19.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e1dc59a5e7bc7f44bd0c048681f5e05356e479c50be4f2c1a7089103f1621d5"}, + {file = "rpds_py-0.19.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8f78398e67a7227aefa95f876481485403eb974b29e9dc38b307bb6eb2315ea"}, + {file = "rpds_py-0.19.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef07a0a1d254eeb16455d839cef6e8c2ed127f47f014bbda64a58b5482b6c836"}, + {file = "rpds_py-0.19.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8124101e92c56827bebef084ff106e8ea11c743256149a95b9fd860d3a4f331f"}, + {file = "rpds_py-0.19.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08ce9c95a0b093b7aec75676b356a27879901488abc27e9d029273d280438505"}, + {file = "rpds_py-0.19.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b02dd77a2de6e49078c8937aadabe933ceac04b41c5dde5eca13a69f3cf144e"}, + {file = "rpds_py-0.19.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4dd02e29c8cbed21a1875330b07246b71121a1c08e29f0ee3db5b4cfe16980c4"}, + {file = "rpds_py-0.19.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9c7042488165f7251dc7894cd533a875d2875af6d3b0e09eda9c4b334627ad1c"}, + {file = "rpds_py-0.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f809a17cc78bd331e137caa25262b507225854073fd319e987bd216bed911b7c"}, + {file = "rpds_py-0.19.1-cp312-none-win32.whl", hash = "sha256:3ddab996807c6b4227967fe1587febade4e48ac47bb0e2d3e7858bc621b1cace"}, + {file = "rpds_py-0.19.1-cp312-none-win_amd64.whl", hash = "sha256:32e0db3d6e4f45601b58e4ac75c6f24afbf99818c647cc2066f3e4b192dabb1f"}, + {file = "rpds_py-0.19.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:747251e428406b05fc86fee3904ee19550c4d2d19258cef274e2151f31ae9d38"}, + {file = "rpds_py-0.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dc733d35f861f8d78abfaf54035461e10423422999b360966bf1c443cbc42705"}, + {file = "rpds_py-0.19.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbda75f245caecff8faa7e32ee94dfaa8312a3367397975527f29654cd17a6ed"}, + {file = "rpds_py-0.19.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd04d8cab16cab5b0a9ffc7d10f0779cf1120ab16c3925404428f74a0a43205a"}, + {file = "rpds_py-0.19.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2d66eb41ffca6cc3c91d8387509d27ba73ad28371ef90255c50cb51f8953301"}, + {file = "rpds_py-0.19.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdf4890cda3b59170009d012fca3294c00140e7f2abe1910e6a730809d0f3f9b"}, + {file = "rpds_py-0.19.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1fa67ef839bad3815124f5f57e48cd50ff392f4911a9f3cf449d66fa3df62a5"}, + {file = "rpds_py-0.19.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b82c9514c6d74b89a370c4060bdb80d2299bc6857e462e4a215b4ef7aa7b090e"}, + {file = "rpds_py-0.19.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c7b07959866a6afb019abb9564d8a55046feb7a84506c74a6f197cbcdf8a208e"}, + {file = "rpds_py-0.19.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4f580ae79d0b861dfd912494ab9d477bea535bfb4756a2269130b6607a21802e"}, + {file = "rpds_py-0.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c6d20c8896c00775e6f62d8373aba32956aa0b850d02b5ec493f486c88e12859"}, + {file = "rpds_py-0.19.1-cp313-none-win32.whl", hash = "sha256:afedc35fe4b9e30ab240b208bb9dc8938cb4afe9187589e8d8d085e1aacb8309"}, + {file = "rpds_py-0.19.1-cp313-none-win_amd64.whl", hash = "sha256:1d4af2eb520d759f48f1073ad3caef997d1bfd910dc34e41261a595d3f038a94"}, + {file = "rpds_py-0.19.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:34bca66e2e3eabc8a19e9afe0d3e77789733c702c7c43cd008e953d5d1463fde"}, + {file = "rpds_py-0.19.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:24f8ae92c7fae7c28d0fae9b52829235df83f34847aa8160a47eb229d9666c7b"}, + {file = "rpds_py-0.19.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71157f9db7f6bc6599a852852f3389343bea34315b4e6f109e5cbc97c1fb2963"}, + {file = "rpds_py-0.19.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d494887d40dc4dd0d5a71e9d07324e5c09c4383d93942d391727e7a40ff810b"}, + {file = "rpds_py-0.19.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b3661e6d4ba63a094138032c1356d557de5b3ea6fd3cca62a195f623e381c76"}, + {file = "rpds_py-0.19.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97fbb77eaeb97591efdc654b8b5f3ccc066406ccfb3175b41382f221ecc216e8"}, + {file = "rpds_py-0.19.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cc4bc73e53af8e7a42c8fd7923bbe35babacfa7394ae9240b3430b5dcf16b2a"}, + {file = "rpds_py-0.19.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:35af5e4d5448fa179fd7fff0bba0fba51f876cd55212f96c8bbcecc5c684ae5c"}, + {file = "rpds_py-0.19.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3511f6baf8438326e351097cecd137eb45c5f019944fe0fd0ae2fea2fd26be39"}, + {file = "rpds_py-0.19.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:57863d16187995c10fe9cf911b897ed443ac68189179541734502353af33e693"}, + {file = "rpds_py-0.19.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:9e318e6786b1e750a62f90c6f7fa8b542102bdcf97c7c4de2a48b50b61bd36ec"}, + {file = "rpds_py-0.19.1-cp38-none-win32.whl", hash = "sha256:53dbc35808c6faa2ce3e48571f8f74ef70802218554884787b86a30947842a14"}, + {file = "rpds_py-0.19.1-cp38-none-win_amd64.whl", hash = "sha256:8df1c283e57c9cb4d271fdc1875f4a58a143a2d1698eb0d6b7c0d7d5f49c53a1"}, + {file = "rpds_py-0.19.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e76c902d229a3aa9d5ceb813e1cbcc69bf5bda44c80d574ff1ac1fa3136dea71"}, + {file = "rpds_py-0.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de1f7cd5b6b351e1afd7568bdab94934d656abe273d66cda0ceea43bbc02a0c2"}, + {file = "rpds_py-0.19.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24fc5a84777cb61692d17988989690d6f34f7f95968ac81398d67c0d0994a897"}, + {file = "rpds_py-0.19.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74129d5ffc4cde992d89d345f7f7d6758320e5d44a369d74d83493429dad2de5"}, + {file = "rpds_py-0.19.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e360188b72f8080fefa3adfdcf3618604cc8173651c9754f189fece068d2a45"}, + {file = "rpds_py-0.19.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13e6d4840897d4e4e6b2aa1443e3a8eca92b0402182aafc5f4ca1f5e24f9270a"}, + {file = "rpds_py-0.19.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f09529d2332264a902688031a83c19de8fda5eb5881e44233286b9c9ec91856d"}, + {file = "rpds_py-0.19.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0d4b52811dcbc1aba08fd88d475f75b4f6db0984ba12275d9bed1a04b2cae9b5"}, + {file = "rpds_py-0.19.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd635c2c4043222d80d80ca1ac4530a633102a9f2ad12252183bcf338c1b9474"}, + {file = "rpds_py-0.19.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f35b34a5184d5e0cc360b61664c1c06e866aab077b5a7c538a3e20c8fcdbf90b"}, + {file = "rpds_py-0.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d4ec0046facab83012d821b33cead742a35b54575c4edfb7ed7445f63441835f"}, + {file = "rpds_py-0.19.1-cp39-none-win32.whl", hash = "sha256:f5b8353ea1a4d7dfb59a7f45c04df66ecfd363bb5b35f33b11ea579111d4655f"}, + {file = "rpds_py-0.19.1-cp39-none-win_amd64.whl", hash = "sha256:1fb93d3486f793d54a094e2bfd9cd97031f63fcb5bc18faeb3dd4b49a1c06523"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7d5c7e32f3ee42f77d8ff1a10384b5cdcc2d37035e2e3320ded909aa192d32c3"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:89cc8921a4a5028d6dd388c399fcd2eef232e7040345af3d5b16c04b91cf3c7e"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca34e913d27401bda2a6f390d0614049f5a95b3b11cd8eff80fe4ec340a1208"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5953391af1405f968eb5701ebbb577ebc5ced8d0041406f9052638bafe52209d"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:840e18c38098221ea6201f091fc5d4de6128961d2930fbbc96806fb43f69aec1"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d8b735c4d162dc7d86a9cf3d717f14b6c73637a1f9cd57fe7e61002d9cb1972"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce757c7c90d35719b38fa3d4ca55654a76a40716ee299b0865f2de21c146801c"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a9421b23c85f361a133aa7c5e8ec757668f70343f4ed8fdb5a4a14abd5437244"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:3b823be829407393d84ee56dc849dbe3b31b6a326f388e171555b262e8456cc1"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:5e58b61dcbb483a442c6239c3836696b79f2cd8e7eec11e12155d3f6f2d886d1"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:39d67896f7235b2c886fb1ee77b1491b77049dcef6fbf0f401e7b4cbed86bbd4"}, + {file = "rpds_py-0.19.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8b32cd4ab6db50c875001ba4f5a6b30c0f42151aa1fbf9c2e7e3674893fb1dc4"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1c32e41de995f39b6b315d66c27dea3ef7f7c937c06caab4c6a79a5e09e2c415"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1a129c02b42d46758c87faeea21a9f574e1c858b9f358b6dd0bbd71d17713175"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:346557f5b1d8fd9966059b7a748fd79ac59f5752cd0e9498d6a40e3ac1c1875f"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31e450840f2f27699d014cfc8865cc747184286b26d945bcea6042bb6aa4d26e"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01227f8b3e6c8961490d869aa65c99653df80d2f0a7fde8c64ebddab2b9b02fd"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69084fd29bfeff14816666c93a466e85414fe6b7d236cfc108a9c11afa6f7301"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d2b88efe65544a7d5121b0c3b003ebba92bfede2ea3577ce548b69c5235185"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ea961a674172ed2235d990d7edf85d15d8dfa23ab8575e48306371c070cda67"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:5beffdbe766cfe4fb04f30644d822a1080b5359df7db3a63d30fa928375b2720"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:720f3108fb1bfa32e51db58b832898372eb5891e8472a8093008010911e324c5"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:c2087dbb76a87ec2c619253e021e4fb20d1a72580feeaa6892b0b3d955175a71"}, + {file = "rpds_py-0.19.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ddd50f18ebc05ec29a0d9271e9dbe93997536da3546677f8ca00b76d477680c"}, + {file = "rpds_py-0.19.1.tar.gz", hash = "sha256:31dd5794837f00b46f4096aa8ccaa5972f73a938982e32ed817bb520c465e520"}, ] [[package]] @@ -3343,36 +3340,21 @@ botocore = ">=1.12.36,<2.0a.0" [package.extras] crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] -[[package]] -name = "sarif-om" -version = "1.0.4" -description = "Classes implementing the SARIF 2.1.0 object model." -optional = true -python-versions = ">= 2.7" -files = [ - {file = "sarif_om-1.0.4-py3-none-any.whl", hash = "sha256:539ef47a662329b1c8502388ad92457425e95dc0aaaf995fe46f4984c4771911"}, - {file = "sarif_om-1.0.4.tar.gz", hash = "sha256:cd5f416b3083e00d402a92e449a7ff67af46f11241073eea0461802a3b5aef98"}, -] - -[package.dependencies] -attrs = "*" -pbr = "*" - [[package]] name = "setuptools" -version = "69.5.1" +version = "72.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = true python-versions = ">=3.8" files = [ - {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, - {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, + {file = "setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1"}, + {file = "setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "six" @@ -3409,13 +3391,13 @@ files = [ [[package]] name = "sqlparse" -version = "0.5.0" +version = "0.5.1" description = "A non-validating SQL parser." optional = false python-versions = ">=3.8" files = [ - {file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"}, - {file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"}, + {file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"}, + {file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"}, ] [package.extras] @@ -3442,34 +3424,37 @@ dev = ["twine", "wheel", "yapf"] [[package]] name = "structlog" -version = "24.1.0" +version = "24.4.0" description = "Structured Logging for Python" optional = false python-versions = ">=3.8" files = [ - {file = "structlog-24.1.0-py3-none-any.whl", hash = "sha256:3f6efe7d25fab6e86f277713c218044669906537bb717c1807a09d46bca0714d"}, - {file = "structlog-24.1.0.tar.gz", hash = "sha256:41a09886e4d55df25bdcb9b5c9674bccfab723ff43e0a86a1b7b236be8e57b16"}, + {file = "structlog-24.4.0-py3-none-any.whl", hash = "sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610"}, + {file = "structlog-24.4.0.tar.gz", hash = "sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4"}, ] [package.extras] -dev = ["structlog[tests,typing]"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-mermaid", "sphinxext-opengraph", "twisted"] +dev = ["freezegun (>=0.2.8)", "mypy (>=1.4)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "rich", "simplejson", "twisted"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-mermaid", "sphinxext-opengraph", "twisted"] tests = ["freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"] typing = ["mypy (>=1.4)", "rich", "twisted"] [[package]] name = "sympy" -version = "1.12" +version = "1.13.1" description = "Computer algebra system (CAS) in Python" optional = true python-versions = ">=3.8" files = [ - {file = "sympy-1.12-py3-none-any.whl", hash = "sha256:c3588cd4295d0c0f603d0f2ae780587e64e2efeedb3521e46b9bb1d08d184fa5"}, - {file = "sympy-1.12.tar.gz", hash = "sha256:ebf595c8dac3e0fdc4152c51878b498396ec7f30e7a914d6071e674d49420fb8"}, + {file = "sympy-1.13.1-py3-none-any.whl", hash = "sha256:db36cdc64bf61b9b24578b6f7bab1ecdd2452cf008f34faa33776680c26d66f8"}, + {file = "sympy-1.13.1.tar.gz", hash = "sha256:9cebf7e04ff162015ce31c9c6c9144daa34a93bd082f54fd8f12deca4f47515f"}, ] [package.dependencies] -mpmath = ">=0.19" +mpmath = ">=1.1.0,<1.4" + +[package.extras] +dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] [[package]] name = "tinycss2" @@ -3502,33 +3487,33 @@ files = [ [[package]] name = "tornado" -version = "6.4" +version = "6.4.1" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." optional = false -python-versions = ">= 3.8" +python-versions = ">=3.8" files = [ - {file = "tornado-6.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:02ccefc7d8211e5a7f9e8bc3f9e5b0ad6262ba2fbb683a6443ecc804e5224ce0"}, - {file = "tornado-6.4-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:27787de946a9cffd63ce5814c33f734c627a87072ec7eed71f7fc4417bb16263"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7894c581ecdcf91666a0912f18ce5e757213999e183ebfc2c3fdbf4d5bd764e"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e43bc2e5370a6a8e413e1e1cd0c91bedc5bd62a74a532371042a18ef19e10579"}, - {file = "tornado-6.4-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0251554cdd50b4b44362f73ad5ba7126fc5b2c2895cc62b14a1c2d7ea32f212"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fd03192e287fbd0899dd8f81c6fb9cbbc69194d2074b38f384cb6fa72b80e9c2"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:88b84956273fbd73420e6d4b8d5ccbe913c65d31351b4c004ae362eba06e1f78"}, - {file = "tornado-6.4-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:71ddfc23a0e03ef2df1c1397d859868d158c8276a0603b96cf86892bff58149f"}, - {file = "tornado-6.4-cp38-abi3-win32.whl", hash = "sha256:6f8a6c77900f5ae93d8b4ae1196472d0ccc2775cc1dfdc9e7727889145c45052"}, - {file = "tornado-6.4-cp38-abi3-win_amd64.whl", hash = "sha256:10aeaa8006333433da48dec9fe417877f8bcc21f48dda8d661ae79da357b2a63"}, - {file = "tornado-6.4.tar.gz", hash = "sha256:72291fa6e6bc84e626589f1c29d90a5a6d593ef5ae68052ee2ef000dfd273dee"}, + {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8"}, + {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6d5ce3437e18a2b66fbadb183c1d3364fb03f2be71299e7d10dbeeb69f4b2a14"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e20b9113cd7293f164dc46fffb13535266e713cdb87bd2d15ddb336e96cfc4"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ae50a504a740365267b2a8d1a90c9fbc86b780a39170feca9bcc1787ff80842"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613bf4ddf5c7a95509218b149b555621497a6cc0d46ac341b30bd9ec19eac7f3"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25486eb223babe3eed4b8aecbac33b37e3dd6d776bc730ca14e1bf93888b979f"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:454db8a7ecfcf2ff6042dde58404164d969b6f5d58b926da15e6b23817950fc4"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a02a08cc7a9314b006f653ce40483b9b3c12cda222d6a46d4ac63bb6c9057698"}, + {file = "tornado-6.4.1-cp38-abi3-win32.whl", hash = "sha256:d9a566c40b89757c9aa8e6f032bcdb8ca8795d7c1a9762910c722b1635c9de4d"}, + {file = "tornado-6.4.1-cp38-abi3-win_amd64.whl", hash = "sha256:b24b8982ed444378d7f21d563f4180a2de31ced9d8d84443907a0a64da2072e7"}, + {file = "tornado-6.4.1.tar.gz", hash = "sha256:92d3ab53183d8c50f8204a51e6f91d18a15d5ef261e84d452800d4ff6fc504e9"}, ] [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] @@ -3544,13 +3529,13 @@ files = [ [[package]] name = "urllib3" -version = "1.26.18" +version = "1.26.19" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" files = [ - {file = "urllib3-1.26.18-py2.py3-none-any.whl", hash = "sha256:34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07"}, - {file = "urllib3-1.26.18.tar.gz", hash = "sha256:f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0"}, + {file = "urllib3-1.26.19-py2.py3-none-any.whl", hash = "sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3"}, + {file = "urllib3-1.26.19.tar.gz", hash = "sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429"}, ] [package.extras] @@ -3890,4 +3875,4 @@ tests = ["hypothesis", "moto", "pytest", "pytest-cov", "pytest-django", "pytest- [metadata] lock-version = "2.0" python-versions = "^3.10.0" -content-hash = "302cd2db53228e09bfa677d5e4149fdfc4afb20ca005ffae17a4683a32f826ab" +content-hash = "cca73bd296e27c132739ea7cd69d18f8a4ab19c6bc8e7deada8e0c47962bd0b5" diff --git a/portal/.github/dependabot.yml b/portal/.github/dependabot.yml deleted file mode 100644 index 9e5479c22..000000000 --- a/portal/.github/dependabot.yml +++ /dev/null @@ -1,22 +0,0 @@ -version: 2 -updates: - - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - target-branch: "develop" - labels: - - "github-actions dependencies" - - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - # Raise pull requests for version updates - # to pip against the `develop` branch - target-branch: "develop" - # Labels on pull requests for version updates only - labels: - - "pip dependencies" diff --git a/portal/.github/workflows/branch-name-check.yaml b/portal/.github/workflows/branch-name-check.yaml deleted file mode 100644 index 03c9b39bf..000000000 --- a/portal/.github/workflows/branch-name-check.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: "branch name check" - -on: - push: - branches-ignore: - - develop - - main - -env: - BRANCH_REGEX: '^((feature|hotfix|bug|docs|dependabot|fix)\/.+)|(release\/v((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?))$' - -jobs: - branch-name-check: - runs-on: ubuntu-20.04 - steps: - - name: checkout - uses: actions/checkout@v2 - - - name: branch name check - run: | - git rev-parse --abbrev-ref HEAD | grep -P "$BRANCH_REGEX" diff --git a/portal/.github/workflows/code-checks.yaml b/portal/.github/workflows/code-checks.yaml deleted file mode 100644 index 68676755a..000000000 --- a/portal/.github/workflows/code-checks.yaml +++ /dev/null @@ -1,52 +0,0 @@ -name: Code checks - -on: - push: - branches: - - main - - develop - pull_request: - branches: - - main - - develop - release: - types: [published] - -jobs: - check-code: - - runs-on: ubuntu-20.04 - services: - postgres: - image: postgres - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - - - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - #Python version should be the same as the base image in Dockerfile - python-version: "3.8.10" - - name: Install dependencies - - run: | - python -m pip install --upgrade pip - pip install black==22.10.0 isort flake8 - if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi - - name: Check Python imports - run: | - isort . --check --diff --skip migrations --skip .venv --profile black -p studio -p projects -p models -p apps -p portal --line-length 79 - - name: Check Python formatting - run: | - black . --check --diff --line-length 79 --exclude migrations - - name: Check Python linting - run: | - flake8 . --exclude migrations diff --git a/portal/.gitignore b/portal/.gitignore deleted file mode 100644 index b6e47617d..000000000 --- a/portal/.gitignore +++ /dev/null @@ -1,129 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ diff --git a/portal/LICENSE b/portal/LICENSE deleted file mode 100644 index 261eeb9e9..000000000 --- a/portal/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/portal/MANIFEST.in b/portal/MANIFEST.in deleted file mode 100644 index c00fc5198..000000000 --- a/portal/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.md -recursive-include portal/templates * diff --git a/portal/README.md b/portal/README.md deleted file mode 100644 index 311cb3ccf..000000000 --- a/portal/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# studio-portal - -Portal Django module for [Studio/STACKn](https://github.com/scaleoutsystems/stackn). To include source package in a Django project: - -``` -$ python3 -m venv .venv -$ source .venv/bin/activate -$ pip install . -``` -And add to installed apps in settings.py: - -``` -INSTALLED_APPS=[ - "portal" -] -``` - -For a complete project follow the link above and navigate to settings.py diff --git a/portal/admin.py b/portal/admin.py index 8b776fb01..4a3d217a5 100644 --- a/portal/admin.py +++ b/portal/admin.py @@ -1,6 +1,18 @@ from django.contrib import admin -from .models import PublicModelObject, PublishedModel +from .models import Collection, NewsObject, PublicModelObject, PublishedModel + +class CollectionAdmin(admin.ModelAdmin): + readonly_fields = ["connected_apps"] + + def connected_apps(self, obj): + apps = obj.app_instances.all() + app_list = ", ".join([app.name for app in apps]) + return app_list or "No apps connected" + + +admin.site.register(Collection, CollectionAdmin) +admin.site.register(NewsObject) admin.site.register(PublishedModel) admin.site.register(PublicModelObject) diff --git a/portal/migrations/0001_initial.py b/portal/migrations/0001_initial.py new file mode 100644 index 000000000..9aed1a321 --- /dev/null +++ b/portal/migrations/0001_initial.py @@ -0,0 +1,74 @@ +# Generated by Django 5.0.2 on 2024-05-27 07:28 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("models", "0001_initial"), + ("projects", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="NewsObject", + fields=[ + ("created_on", models.DateTimeField(auto_now_add=True)), + ("title", models.CharField(default="", max_length=60, primary_key=True, serialize=False)), + ("body", models.TextField(blank=True, default="", max_length=2024, null=True)), + ], + ), + migrations.CreateModel( + name="Collection", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("name", models.CharField(default="", max_length=200, unique=True)), + ("description", models.TextField(blank=True, default="", max_length=1000)), + ("website", models.URLField(blank=True)), + ("logo", models.ImageField(blank=True, null=True, upload_to="collections/logos/")), + ("slug", models.SlugField(blank=True, unique=True)), + ("zenodo_community_id", models.CharField(blank=True, max_length=200, null=True)), + ( + "maintainer", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="collection_maintainer", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="PublicModelObject", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("obj", models.FileField(upload_to="models/objects/")), + ("updated_on", models.DateTimeField(auto_now=True)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("model", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to="models.model")), + ], + ), + migrations.CreateModel( + name="PublishedModel", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=512)), + ("pattern", models.CharField(default="", max_length=255)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ( + "collections", + models.ManyToManyField(blank=True, related_name="published_models", to="portal.collection"), + ), + ("model_obj", models.ManyToManyField(to="portal.publicmodelobject")), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="projects.project")), + ], + ), + ] diff --git a/portal/models.py b/portal/models.py index 29177c604..f8f2697fd 100644 --- a/portal/models.py +++ b/portal/models.py @@ -1,9 +1,11 @@ import random from django.conf import settings +from django.contrib.auth import get_user_model from django.db import models from django.db.models.signals import pre_save from django.dispatch import receiver +from django.utils.text import slugify class PublicModelObject(models.Model): @@ -20,7 +22,7 @@ class PublishedModel(models.Model): pattern = models.CharField(max_length=255, default="") updated_on = models.DateTimeField(auto_now=True) created_on = models.DateTimeField(auto_now_add=True) - collections = models.ManyToManyField("collections_module.Collection", related_name="published_models", blank=True) + collections = models.ManyToManyField("portal.Collection", related_name="published_models", blank=True) @property def model_description(self): @@ -57,3 +59,42 @@ def on_project_save(sender, instance, **kwargs): pattern = f"pattern-{randint}" instance.pattern = pattern + + +class NewsObject(models.Model): + created_on = models.DateTimeField(auto_now_add=True) + title = models.CharField(max_length=60, default="", primary_key=True) + body = models.TextField(blank=True, null=True, default="", max_length=2024) + + @property + def news_body(self): + return self.body + + @property + def news_title(self): + return self.title + + +class Collection(models.Model): + created_on = models.DateTimeField(auto_now_add=True) + maintainer = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + related_name="collection_maintainer", + null=True, + ) + name = models.CharField(max_length=200, unique=True, default="") + description = models.TextField(blank=True, default="", max_length=1000) + website = models.URLField(max_length=200, blank=True) + logo = models.ImageField(upload_to="collections/logos/", null=True, blank=True) + slug = models.SlugField(unique=True, blank=True) + zenodo_community_id = models.CharField(max_length=200, null=True, blank=True) + # repositories source would be another field + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + super(Collection, self).save(*args, **kwargs) diff --git a/portal/setup.py b/portal/setup.py deleted file mode 100644 index d22a48990..000000000 --- a/portal/setup.py +++ /dev/null @@ -1,28 +0,0 @@ -from setuptools import setup - -setup( - name="studio-portal", - version="0.0.1", - description="""Django app for handling portal in Studio""", - url="https://www.scaleoutsystems.com", - include_package_data=True, - package=["portal"], - package_dir={"portal": "."}, - python_requires=">=3.6,<4", - install_requires=[ - "django==4.2.1", - "requests==2.31.0", - "Pillow==9.4.0", - ], - license="Copyright Scaleout Systems AB. See license for details", - zip_safe=False, - keywords="", - classifiers=[ - "Development Status :: 2 - Pre-Alpha", - "Intended Audience :: Developers", - "Natural Language :: English", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - ], -) diff --git a/portal/urls.py b/portal/urls.py index 7c61e587f..1381effda 100644 --- a/portal/urls.py +++ b/portal/urls.py @@ -12,5 +12,8 @@ path("teaching/", views.teaching, name="teaching"), path("privacy/", views.privacy, name="privacy"), path("apps/", views.public_apps, name="apps"), + path("news/", views.news, name="news"), + path("collections/", views.index, name="collections_index"), + path("collections//", views.collection, name="collection"), path("", views.HomeViewDynamic.as_view(), name="home-dynamic"), ] diff --git a/portal/views.py b/portal/views.py index ada167a15..f5f47a416 100644 --- a/portal/views.py +++ b/portal/views.py @@ -2,24 +2,26 @@ from django.apps import apps from django.conf import settings from django.db.models import Q -from django.shortcuts import redirect, render +from django.shortcuts import get_object_or_404, redirect, render from django.views.generic import View +from apps.models import BaseAppInstance, SocialMixin from studio.utils import get_logger +from .models import NewsObject + logger = get_logger(__name__) -AppInstance = apps.get_model(app_label=settings.APPINSTANCE_MODEL) + Project = apps.get_model(app_label=settings.PROJECTS_MODEL) PublishedModel = apps.get_model(app_label=settings.PUBLISHEDMODEL_MODEL) -NewsObject = apps.get_model(app_label="news.NewsObject") -Collection = apps.get_model(app_label="collections_module.Collection") +Collection = apps.get_model(app_label="portal.Collection") # TODO minor refactor # 1. Change id to app_id as it's anti-pattern to override language reserved function names # 2. add type annotations -def get_public_apps(request, id=0, get_all=True, collection=None): +def get_public_apps(request, app_id=0, get_all=True, collection=None): try: projects = Project.objects.filter( Q(owner=request.user) | Q(authorized=request.user), status="active" @@ -42,28 +44,45 @@ def get_public_apps(request, id=0, get_all=True, collection=None): # add app id to app_tags object if "app_id_add" in request.GET: num_tags = int(request.GET["tag_count"]) - id = int(request.GET["app_id_add"]) - request.session["app_tags"][str(id)] = num_tags + app_id = int(request.GET["app_id_add"]) + request.session["app_tags"][str(app_id)] = num_tags # remove app id from app_tags object if "app_id_remove" in request.GET: num_tags = int(request.GET["tag_count"]) - id = int(request.GET["app_id_remove"]) - if str(id) in request.session["app_tags"]: - request.session["app_tags"].pop(str(id)) + app_id = int(request.GET["app_id_remove"]) + if str(app_id) in request.session["app_tags"]: + request.session["app_tags"].pop(str(app_id)) # reset app_tags if Apps Tab on Sidebar pressed - if id == 0: + if app_id == 0: if "tf_add" not in request.GET and "tf_remove" not in request.GET: request.session["app_tags"] = {} + published_apps = [] + if collection: - published_apps = AppInstance.objects.filter( - ~Q(state="Deleted"), access="public", collections__slug=collection - ).order_by("-updated_on") + # TODO: TIDY THIS UP! + + for subclass in SocialMixin.__subclasses__(): + print(subclass, flush=True) + published_apps_qs = subclass.objects.filter( + ~Q(app_status__status="Deleted"), access="public", collections__slug=collection + ) + print(published_apps_qs, flush=True) + published_apps.extend([app for app in published_apps_qs]) + else: - published_apps = AppInstance.objects.filter(~Q(state="Deleted"), access="public").order_by("-updated_on") + for subclass in SocialMixin.__subclasses__(): + published_apps_qs = subclass.objects.filter(~Q(app_status__status="Deleted"), access="public") + published_apps.extend([app for app in published_apps_qs]) - if published_apps.count() >= 3 and not get_all: + # sorting the apps by date updated + published_apps.sort( + key=lambda app: (app.updated_on is None, app.updated_on if app.updated_on is not None else ""), + reverse=True, # Sort in descending order + ) + + if len(published_apps) >= 3 and not get_all: published_apps = published_apps[:3] else: published_apps = published_apps @@ -71,19 +90,18 @@ def get_public_apps(request, id=0, get_all=True, collection=None): # Similar to GetStatusView() in apps.views for app in published_apps: try: - app.latest_status = app.status.latest().status_type - - app.status_group = "success" if app.latest_status in settings.APPS_STATUS_SUCCESS else "warning" + app.status_group = "success" if app.app_status.status in settings.APPS_STATUS_SUCCESS else "warning" except: # noqa E722 TODO refactor: Add exception app.latest_status = "unknown" app.status_group = "unknown" # Extract app config for use in Django templates for app in published_apps: - app.image = app.parameters.get("appconfig", {}).get("image", "Not available") - app.port = app.parameters.get("appconfig", {}).get("port", "Not available") - app.userid = app.parameters.get("appconfig", {}).get("userid", "Not available") - app.pvc = app.parameters.get("apps", {}).get("volumeK8s") or None + if getattr(app, "k8s_values", False): + app.image = app.k8s_values.get("appconfig", {}).get("image", "Not available") + app.port = app.k8s_values.get("appconfig", {}).get("port", "Not available") + app.userid = app.k8s_values.get("appconfig", {}).get("userid", "Not available") + app.pvc = app.k8s_values.get("apps", {}).get("volumeK8s") or None # create session object to store ids for tag seacrh if it does not exist if "app_tag_filters" not in request.session: @@ -114,8 +132,8 @@ def get_public_apps(request, id=0, get_all=True, collection=None): return published_apps, request -def public_apps(request, id=0): - published_apps, request = get_public_apps(request, id=id) +def public_apps(request, app_id=0): + published_apps, request = get_public_apps(request, app_id=app_id) template = "portal/apps.html" return render(request, template, locals()) @@ -123,8 +141,8 @@ def public_apps(request, id=0): class HomeView(View): template = "portal/home.html" - def get(self, request, id=0): - published_apps, request = get_public_apps(request, id=id, get_all=False) + def get(self, request, app_id=0): + published_apps, request = get_public_apps(request, app_id=app_id, get_all=False) published_models = PublishedModel.objects.all() news_objects = NewsObject.objects.all().order_by("-created_on") for news in news_objects: @@ -169,7 +187,7 @@ def get(self, request): if request.user.is_authenticated: return redirect("projects/") else: - return HomeView.as_view()(request, id=0) + return HomeView.as_view()(request, app_id=0) def about(request): @@ -185,3 +203,36 @@ def teaching(request): def privacy(request): template = "portal/privacy.html" return render(request, template, locals()) + + +def news(request): + news_objects = NewsObject.objects.all().order_by("-created_on") + for news in news_objects: + news.body_html = markdown.markdown(news.body) + return render(request, "news/news.html", {"news_objects": news_objects}) + + +def index(request): + template = "collections/index.html" + + collection_objects = Collection.objects.all().order_by("-created_on") + + context = {"collection_objects": collection_objects} + + return render(request, template, context=context) + + +def collection(request, slug, app_id=0): + template = "collections/collection.html" + + collection = get_object_or_404(Collection, slug=slug) + collection_published_apps, request = get_public_apps(request, app_id=app_id, collection=slug) + collection_published_models = PublishedModel.objects.all().filter(collections__slug=slug) + + context = { + "collection": collection, + "collection_published_apps": collection_published_apps, + "collection_published_models": collection_published_models, + } + + return render(request, template, context=context) diff --git a/projects/.github/dependabot.yml b/projects/.github/dependabot.yml deleted file mode 100644 index 9e5479c22..000000000 --- a/projects/.github/dependabot.yml +++ /dev/null @@ -1,22 +0,0 @@ -version: 2 -updates: - - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - target-branch: "develop" - labels: - - "github-actions dependencies" - - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - # Raise pull requests for version updates - # to pip against the `develop` branch - target-branch: "develop" - # Labels on pull requests for version updates only - labels: - - "pip dependencies" diff --git a/projects/.github/workflows/branch-name-check.yaml b/projects/.github/workflows/branch-name-check.yaml deleted file mode 100644 index 03c9b39bf..000000000 --- a/projects/.github/workflows/branch-name-check.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: "branch name check" - -on: - push: - branches-ignore: - - develop - - main - -env: - BRANCH_REGEX: '^((feature|hotfix|bug|docs|dependabot|fix)\/.+)|(release\/v((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?))$' - -jobs: - branch-name-check: - runs-on: ubuntu-20.04 - steps: - - name: checkout - uses: actions/checkout@v2 - - - name: branch name check - run: | - git rev-parse --abbrev-ref HEAD | grep -P "$BRANCH_REGEX" diff --git a/projects/.github/workflows/code-checks.yaml b/projects/.github/workflows/code-checks.yaml deleted file mode 100644 index 68676755a..000000000 --- a/projects/.github/workflows/code-checks.yaml +++ /dev/null @@ -1,52 +0,0 @@ -name: Code checks - -on: - push: - branches: - - main - - develop - pull_request: - branches: - - main - - develop - release: - types: [published] - -jobs: - check-code: - - runs-on: ubuntu-20.04 - services: - postgres: - image: postgres - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - - - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - #Python version should be the same as the base image in Dockerfile - python-version: "3.8.10" - - name: Install dependencies - - run: | - python -m pip install --upgrade pip - pip install black==22.10.0 isort flake8 - if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi - - name: Check Python imports - run: | - isort . --check --diff --skip migrations --skip .venv --profile black -p studio -p projects -p models -p apps -p portal --line-length 79 - - name: Check Python formatting - run: | - black . --check --diff --line-length 79 --exclude migrations - - name: Check Python linting - run: | - flake8 . --exclude migrations diff --git a/projects/.gitignore b/projects/.gitignore deleted file mode 100644 index b6e47617d..000000000 --- a/projects/.gitignore +++ /dev/null @@ -1,129 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ diff --git a/projects/LICENSE b/projects/LICENSE deleted file mode 100644 index 261eeb9e9..000000000 --- a/projects/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/projects/MANIFEST.in b/projects/MANIFEST.in deleted file mode 100644 index a876fcedd..000000000 --- a/projects/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.md -recursive-include projects/templates * diff --git a/projects/README.md b/projects/README.md deleted file mode 100644 index 97d76674f..000000000 --- a/projects/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# studio-projects - -Projects Django module for [Studio/STACKn](https://github.com/scaleoutsystems/stackn). To include source package in a Django project: - -``` -$ python3 -m venv .venv -$ source .venv/bin/activate -$ pip install . -``` -And add to installed apps in settings.py: - -``` -INSTALLED_APPS=[ - "projects" -] -``` - -For a complete project follow the link above and navigate to settings.py diff --git a/projects/admin.py b/projects/admin.py index 0485ccb20..5d0d8301d 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -1,27 +1,12 @@ from django.conf import settings from django.contrib import admin -from .models import ( - S3, - BasicAuth, - Environment, - Flavor, - MLFlow, - Project, - ProjectLog, - ProjectTemplate, - ReleaseName, -) +from .models import BasicAuth, Environment, Flavor, Project, ProjectLog, ProjectTemplate admin.site.register(ProjectTemplate) -# admin.site.register(Project) admin.site.register(Environment) -# admin.site.register(Flavor) admin.site.register(ProjectLog) -admin.site.register(S3) -admin.site.register(MLFlow) admin.site.register(BasicAuth) -admin.site.register(ReleaseName) @admin.register(Project) diff --git a/projects/apps.py b/projects/apps.py index f1a230364..6a9b6f278 100644 --- a/projects/apps.py +++ b/projects/apps.py @@ -4,4 +4,4 @@ class ProjectConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "projects" - verbose_name = "Scaleout Projects" + verbose_name = "Serve Projects" diff --git a/projects/helpers.py b/projects/helpers.py deleted file mode 100644 index 17796dcef..000000000 --- a/projects/helpers.py +++ /dev/null @@ -1,25 +0,0 @@ -import base64 -import re - - -def urlify(s): - # Remove all non-word characters (everything except numbers and letters) - s = re.sub(r"[^\w\s]", "", s) - - # Replace all runs of whitespace with a single dash - s = re.sub(r"\s+", "-", s) - - return s - - -def get_minio_keys(project): - return { - "project_key": decrypt_key(project.project_key), - "project_secret": decrypt_key(project.project_secret), - } - - -def decrypt_key(key): - base64_bytes = key.encode("ascii") - result = base64.b64decode(base64_bytes) - return result.decode("ascii") diff --git a/studio/migrations/projects/0001_initial.py b/projects/migrations/0001_initial.py similarity index 56% rename from studio/migrations/projects/0001_initial.py rename to projects/migrations/0001_initial.py index 9de409fe9..fe0ff20f2 100644 --- a/studio/migrations/projects/0001_initial.py +++ b/projects/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.5 on 2023-11-16 08:07 +# Generated by Django 5.0.2 on 2024-05-27 07:28 import django.db.models.deletion from django.conf import settings @@ -16,50 +16,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.CreateModel( - name="BasicAuth", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=512)), - ("password", models.CharField(blank=True, default="", max_length=100)), - ("username", models.CharField(blank=True, default="", max_length=100)), - ( - "owner", - models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL), - ), - ], - ), - migrations.CreateModel( - name="MLFlow", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("mlflow_url", models.CharField(max_length=512)), - ("host", models.CharField(blank=True, default="", max_length=512)), - ("name", models.CharField(max_length=512)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "app", - models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="mlflowobj", - to="apps.appinstance", - ), - ), - ( - "basic_auth", - models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to="projects.basicauth" - ), - ), - ( - "owner", - models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL), - ), - ], - ), migrations.CreateModel( name="Project", fields=[ @@ -80,16 +36,6 @@ class Migration(migrations.Migration): ("project_key", models.CharField(max_length=512)), ("project_secret", models.CharField(max_length=512)), ("authorized", models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), - ( - "mlflow", - models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="project_mlflow", - to="projects.mlflow", - ), - ), ( "owner", models.ForeignKey( @@ -104,72 +50,67 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name="S3", + name="Flavor", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("access_key", models.CharField(max_length=512)), ("created_at", models.DateTimeField(auto_now_add=True)), - ("host", models.CharField(max_length=512)), + ("cpu_lim", models.TextField(blank=True, default="1000m", null=True)), + ("gpu_lim", models.TextField(blank=True, default="0", null=True)), + ("ephmem_lim", models.TextField(blank=True, default="200Mi", null=True)), + ("mem_lim", models.TextField(blank=True, default="3Gi", null=True)), + ("cpu_req", models.TextField(blank=True, default="200m", null=True)), + ("gpu_req", models.TextField(blank=True, default="0", null=True)), + ("ephmem_req", models.TextField(blank=True, default="200Mi", null=True)), + ("mem_req", models.TextField(blank=True, default="0.5Gi", null=True)), ("name", models.CharField(max_length=512)), - ("region", models.CharField(blank=True, default="", max_length=512)), - ("secret_key", models.CharField(max_length=512)), ("updated_at", models.DateTimeField(auto_now=True)), - ( - "app", - models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="s3obj", - to="apps.appinstance", - ), - ), - ( - "owner", - models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL), - ), ( "project", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="s3_project", to="projects.project" - ), + models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="projects.project"), ), ], ), migrations.CreateModel( - name="ReleaseName", + name="Environment", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("created_at", models.DateTimeField(auto_now_add=True)), - ("name", models.CharField(max_length=512)), - ("status", models.CharField(max_length=10)), + ("image", models.CharField(max_length=100)), + ("name", models.CharField(max_length=100)), + ("repository", models.CharField(blank=True, max_length=100, null=True)), + ("slug", models.CharField(blank=True, max_length=100, null=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("public", models.BooleanField(default=False)), + ("app", models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="apps.apps")), ( - "app", + "project", models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="apps.appinstance" + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="projects.project" ), ), - ( - "project", - models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="projects.project"), - ), ], ), migrations.CreateModel( - name="ProjectTemplate", + name="BasicAuth", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("description", models.TextField(blank=True, null=True)), - ("image", models.ImageField(blank=True, null=True, upload_to="projecttemplates/images/")), ("name", models.CharField(max_length=512)), - ("revision", models.IntegerField(default=1)), - ("slug", models.CharField(default="", max_length=512)), - ("template", models.TextField(blank=True, null=True)), - ("enabled", models.BooleanField(default=True)), + ("password", models.CharField(blank=True, default="", max_length=100)), + ("username", models.CharField(blank=True, default="", max_length=100)), + ( + "owner", + models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="ba_project", + to="projects.project", + ), + ), ], - options={ - "unique_together": {("slug", "revision")}, - }, ), migrations.CreateModel( name="ProjectLog", @@ -196,97 +137,28 @@ class Migration(migrations.Migration): ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="projects.project")), ], ), - migrations.AddField( - model_name="project", - name="s3storage", - field=models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="project_s3", - to="projects.s3", - ), - ), - migrations.AddField( - model_name="mlflow", - name="project", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="mlflow_project", to="projects.project" - ), - ), - migrations.AddField( - model_name="mlflow", - name="s3", - field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to="projects.s3" - ), - ), migrations.CreateModel( - name="Flavor", + name="ProjectTemplate", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("cpu_lim", models.TextField(blank=True, default="1000m", null=True)), - ("gpu_lim", models.TextField(blank=True, default="0", null=True)), - ("ephmem_lim", models.TextField(blank=True, default="200Mi", null=True)), - ("mem_lim", models.TextField(blank=True, default="3Gi", null=True)), - ("cpu_req", models.TextField(blank=True, default="200m", null=True)), - ("gpu_req", models.TextField(blank=True, default="0", null=True)), - ("ephmem_req", models.TextField(blank=True, default="200Mi", null=True)), - ("mem_req", models.TextField(blank=True, default="0.5Gi", null=True)), + ("description", models.TextField(blank=True, null=True)), + ("image", models.ImageField(blank=True, null=True, upload_to="projecttemplates/images/")), ("name", models.CharField(max_length=512)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "project", - models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="projects.project"), - ), - ], - ), - migrations.CreateModel( - name="Environment", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("image", models.CharField(max_length=100)), - ("name", models.CharField(max_length=100)), - ("repository", models.CharField(blank=True, max_length=100, null=True)), - ("slug", models.CharField(blank=True, max_length=100, null=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("public", models.BooleanField(default=False)), - ("app", models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="apps.apps")), - ( - "appenv", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="envobj", - to="apps.appinstance", - ), - ), - ( - "project", - models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="projects.project" - ), - ), - ( - "registry", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="environments", - to="apps.appinstance", - ), - ), + ("revision", models.IntegerField(default=1)), + ("slug", models.CharField(default="", max_length=512)), + ("template", models.TextField(blank=True, null=True)), + ("enabled", models.BooleanField(default=True)), + ("available_apps", models.ManyToManyField(blank=True, related_name="available_apps", to="apps.apps")), ], + options={ + "unique_together": {("slug", "revision")}, + }, ), migrations.AddField( - model_name="basicauth", - name="project", + model_name="project", + name="project_template", field=models.ForeignKey( - null=True, on_delete=django.db.models.deletion.CASCADE, related_name="ba_project", to="projects.project" + null=True, on_delete=django.db.models.deletion.DO_NOTHING, to="projects.projecttemplate" ), ), ] diff --git a/projects/models.py b/projects/models.py index 1ccd0ca65..eab020f98 100644 --- a/projects/models.py +++ b/projects/models.py @@ -35,13 +35,7 @@ class BasicAuth(models.Model): class Environment(models.Model): app = models.ForeignKey(settings.APPS_MODEL, on_delete=models.CASCADE, null=True) - appenv = models.ForeignKey( - settings.APPINSTANCE_MODEL, - related_name="envobj", - null=True, - blank=True, - on_delete=models.CASCADE, - ) + created_at = models.DateTimeField(auto_now_add=True) image = models.CharField(max_length=100) name = models.CharField(max_length=100) @@ -51,13 +45,7 @@ class Environment(models.Model): null=True, blank=True, ) - registry = models.ForeignKey( - settings.APPINSTANCE_MODEL, - related_name="environments", - null=True, - blank=True, - on_delete=models.CASCADE, - ) + repository = models.CharField(max_length=100, blank=True, null=True) slug = models.CharField(max_length=100, null=True, blank=True) updated_at = models.DateTimeField(auto_now=True) @@ -85,68 +73,26 @@ class Flavor(models.Model): def __str__(self): return str(self.name) + def to_dict(self): + flavor_dict = dict( + flavor=dict( + requests={ + "cpu": self.cpu_req, + "memory": self.mem_req, + "ephemeral-storage": self.ephmem_req, + }, + limits={ + "cpu": self.cpu_lim, + "memory": self.mem_lim, + "ephemeral-storage": self.ephmem_lim, + }, + ) + ) + if self.gpu_req and int(self.gpu_req) > 0: + flavor_dict["flavor"]["requests"]["nvidia.com/gpu"] = (self.gpu_req,) + flavor_dict["flavor"]["limits"]["nvidia.com/gpu"] = self.gpu_lim -class S3(models.Model): - access_key = models.CharField(max_length=512) - app = models.OneToOneField( - settings.APPINSTANCE_MODEL, - on_delete=models.CASCADE, - null=True, - blank=True, - related_name="s3obj", - ) - created_at = models.DateTimeField(auto_now_add=True) - host = models.CharField(max_length=512) - name = models.CharField(max_length=512) - owner = models.ForeignKey(get_user_model(), on_delete=models.DO_NOTHING) - project = models.ForeignKey( - settings.PROJECTS_MODEL, - on_delete=models.CASCADE, - related_name="s3_project", - ) - region = models.CharField(max_length=512, blank=True, default="") - secret_key = models.CharField(max_length=512) - updated_at = models.DateTimeField(auto_now=True) - - def __str__(self): - return "{} ({})".format(self.name, self.project.slug) - - -class MLFlow(models.Model): - app = models.OneToOneField( - settings.APPINSTANCE_MODEL, - on_delete=models.CASCADE, - null=True, - blank=True, - related_name="mlflowobj", - ) - basic_auth = models.ForeignKey(BasicAuth, on_delete=models.DO_NOTHING, null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - mlflow_url = models.CharField(max_length=512) - host = models.CharField(max_length=512, blank=True, default="") - name = models.CharField(max_length=512) - owner = models.ForeignKey(get_user_model(), on_delete=models.DO_NOTHING) - project = models.ForeignKey( - settings.PROJECTS_MODEL, - on_delete=models.CASCADE, - related_name="mlflow_project", - ) - s3 = models.ForeignKey(S3, on_delete=models.DO_NOTHING, blank=True, null=True) - updated_at = models.DateTimeField(auto_now=True) - - def __str__(self): - return "{} ({})".format(self.name, self.project.slug) - - -"""Post save signal when creating an mlflow object""" - - -@receiver(post_save, sender=MLFlow) -def create_mlflow(sender, instance, created, **kwargs): - if created: - if instance.project and not instance.project.mlflow: - instance.project.mlflow = instance - instance.project.save() + return flavor_dict # it will become the default objects attribute for a Project model @@ -240,6 +186,7 @@ class ProjectTemplate(models.Model): revision = models.IntegerField(default=1) slug = models.CharField(max_length=512, default="") template = models.TextField(null=True, blank=True) + available_apps = models.ManyToManyField("apps.Apps", blank=True, related_name="available_apps") enabled = models.BooleanField(default=True) @@ -258,13 +205,7 @@ class Project(models.Model): created_at = models.DateTimeField(auto_now_add=True) clone_url = models.CharField(max_length=512, null=True, blank=True) description = models.TextField(null=True, blank=True) - mlflow = models.OneToOneField( - MLFlow, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="project_mlflow", - ) + name = models.CharField(max_length=512) objects = ProjectManager() owner = models.ForeignKey(get_user_model(), on_delete=models.DO_NOTHING, related_name="owner") @@ -273,13 +214,6 @@ class Project(models.Model): pattern = models.CharField(max_length=255, default="") - s3storage = models.OneToOneField( - S3, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="project_s3", - ) slug = models.CharField(max_length=512, unique=True) status = models.CharField(max_length=20, null=True, blank=True, default="active") updated_at = models.DateTimeField(auto_now=True) @@ -364,19 +298,3 @@ class ProjectLog(models.Model): headline = models.CharField(max_length=256) module = models.CharField(max_length=2, choices=MODULE_CHOICES, default="UN") project = models.ForeignKey(settings.PROJECTS_MODEL, on_delete=models.CASCADE) - - -class ReleaseName(models.Model): - app = models.ForeignKey( - settings.APPINSTANCE_MODEL, - on_delete=models.CASCADE, - null=True, - blank=True, - ) - created_at = models.DateTimeField(auto_now_add=True) - name = models.CharField(max_length=512) - status = models.CharField(max_length=10) - project = models.ForeignKey(settings.PROJECTS_MODEL, on_delete=models.CASCADE, null=True) - - def __str__(self): - return "{}-{}-{}".format(self.name, self.project, self.app) diff --git a/projects/setup.py b/projects/setup.py deleted file mode 100644 index b46dded86..000000000 --- a/projects/setup.py +++ /dev/null @@ -1,30 +0,0 @@ -from setuptools import setup - -setup( - name="studio-projects", - version="0.0.1", - description="""Django app for handling portal in Studio""", - url="https://www.scaleoutsystems.com", - include_package_data=True, - package=["projects"], - package_dir={"projects": "."}, - python_requires=">=3.6,<4", - install_requires=[ - "django==4.2.1", - "requests==2.31.0", - "django-guardian==2.4.0", - "celery==5.2.7", - "Pillow==9.4.0", - ], - license="Copyright Scaleout Systems AB. See license for details", - zip_safe=False, - keywords="", - classifiers=[ - "Development Status :: 2 - Pre-Alpha", - "Intended Audience :: Developers", - "Natural Language :: English", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - ], -) diff --git a/projects/static/img/logo.png b/projects/static/img/logo.png deleted file mode 100644 index c7dca1cd1..000000000 Binary files a/projects/static/img/logo.png and /dev/null differ diff --git a/projects/tasks.py b/projects/tasks.py index 44fb8eb66..35ec099b4 100644 --- a/projects/tasks.py +++ b/projects/tasks.py @@ -1,25 +1,23 @@ import collections import json -import secrets -import string from celery import shared_task from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model -import apps.tasks as apptasks -from apps.controller import delete -from apps.helpers import create_app_instance +from apps.app_registry import APP_REGISTRY +from apps.helpers import create_instance_from_form +from apps.models import BaseAppInstance, VolumeInstance +from apps.tasks import delete_resource from studio.utils import get_logger from .exceptions import ProjectCreationException -from .models import S3, Environment, Flavor, MLFlow, Project +from .models import Environment, Flavor, Project logger = get_logger(__name__) Apps = apps.get_model(app_label=settings.APPS_MODEL) -AppInstance = apps.get_model(app_label=settings.APPINSTANCE_MODEL) User = get_user_model() @@ -27,165 +25,156 @@ @shared_task def create_resources_from_template(user, project_slug, template): logger.info("Create Resources From Project Template...") + + project = Project.objects.get(slug=project_slug) + decoder = json.JSONDecoder(object_pairs_hook=collections.OrderedDict) parsed_template = template.replace("'", '"') template = decoder.decode(parsed_template) - alphabet = string.ascii_letters + string.digits - project = Project.objects.get(slug=project_slug) - logger.info("Parsing template...") - for key, item in template.items(): - logger.info("Key %s", key) - if "flavors" == key: - flavors = item - logger.info("Flavors: %s", flavors) - # TODO: potential bug. This for statement overrides variables in the outer loop. - for key, item in flavors.items(): - flavor = Flavor( - name=key, - cpu_req=item["cpu"]["requirement"], - cpu_lim=item["cpu"]["limit"], - mem_req=item["mem"]["requirement"], - mem_lim=item["mem"]["limit"], - gpu_req=item["gpu"]["requirement"], - gpu_lim=item["gpu"]["limit"], - ephmem_req=item["ephmem"]["requirement"], - ephmem_lim=item["ephmem"]["limit"], - project=project, - ) - flavor.save() - elif "environments" == key: - environments = item - logger.info("Environments: %s", environments) - for key, item in environments.items(): - try: - app = Apps.objects.filter(slug=item["app"]).order_by("-revision")[0] - except Exception as err: - logger.error( - ("App for environment not found. item.app=%s project_slug=%s " "user=%s err=%s"), - item["app"], - project_slug, - user, - err, - exc_info=True, - ) - raise - try: - environment = Environment( - name=key, - project=project, - repository=item["repository"], - image=item["image"], - app=app, - ) - environment.save() - except Exception as err: - logger.error( - ( - "Failed to create new environment: " - "key=%s " - "project=%s " - "item.repository=%s " - "image=%s " - "app=%s " - "user%s " - "err=%s" - ), - key, - project, - item["repository"], - item["image"], - app, - user, - err, - exc_info=True, - ) - elif "apps" == key: - apps = item - logger.info("Apps: %s", apps) - for key, item in apps.items(): - app_name = key - data = {"app_name": app_name, "app_action": "Create"} - if "credentials.access_key" in item: - item["credentials.access_key"] = "".join(secrets.choice(alphabet) for i in range(8)) - if "credentials.secret_key" in item: - item["credentials.secret_key"] = "".join(secrets.choice(alphabet) for i in range(14)) - if "credentials.username" in item: - item["credentials.username"] = "admin" - if "credentials.password" in item: - item["credentials.password"] = "".join(secrets.choice(alphabet) for i in range(14)) - - data = {**data, **item} - logger.info("DATA TEMPLATE") - logger.info(data) - - user_obj = User.objects.get(username=user) - - app = Apps.objects.filter(slug=item["slug"]).order_by("-revision")[0] - ( - successful, - _, - _, - ) = create_app_instance( - user=user_obj, - project=project, - app=app, - app_settings=app.settings, - data=data, - wait=True, - ) - - if not successful: - logger.error("create_app_instance failed") - raise ProjectCreationException - - elif "settings" == key: - logger.info("PARSING SETTINGS") - logger.info("Settings: %s", settings) - if "project-S3" in item: - logger.info("SETTING DEFAULT S3") - s3storage = item["project-S3"] - # Add logics: here it is referring to minio basically. - # It is assumed that minio exist, but if it doesn't - # then it blows up of course - s3obj = S3.objects.get(name=s3storage, project=project) - project.s3storage = s3obj - project.save() - if "project-MLflow" in item: - logger.info("SETTING DEFAULT MLflow") - mlflow = item["project-MLflow"] - mlflowobj = MLFlow.objects.get(name=mlflow, project=project) - project.mlflow = mlflowobj - project.save() + logger.info("Creating Project Flavors...") + flavor_dict = template.get("flavors", {}) + for flavor_name, resources in flavor_dict.items(): + logger.info("Creating flavor ") + logger.info(f"Creating flavor: {flavor_name}") + flavor = Flavor( + name=flavor_name, + cpu_req=resources["cpu"]["requirement"], + cpu_lim=resources["cpu"]["limit"], + mem_req=resources["mem"]["requirement"], + mem_lim=resources["mem"]["limit"], + gpu_req=resources["gpu"]["requirement"], + gpu_lim=resources["gpu"]["limit"], + ephmem_req=resources["ephmem"]["requirement"], + ephmem_lim=resources["ephmem"]["limit"], + project=project, + ) + flavor.save() + + logger.info("Creating Project Volumes...") + volume_dict = template.get("volumes", {}) + for volume_name, data in volume_dict.items(): + data["name"] = volume_name + logger.info(f"Creating volume using {data}") + form_class = APP_REGISTRY.get_form_class("volumeK8s") + form = form_class(data, project_pk=project.pk) + if form.is_valid(): + create_instance_from_form(form, project, "volumeK8s") + else: + logger.error(f"Form is invalid: {form.errors.as_data()}") + raise ProjectCreationException(f"Form is invalid: {form.errors.as_data()}") + + apps_dict = template.get("apps", {}) + logger.info("Initiate Creation of Project Apps...") + form_dict = {} + for app_slug, data in apps_dict.items(): + logger.info(f"Creating {app_slug} using raw data {data}") + + # Handle mounting of volumes + if "volume" in data: + try: + volumes = VolumeInstance.objects.filter(project=project, name=data["volume"]) + data["volume"] = volumes + logger.info(f"Modified data with specified volume: {data}") + except VolumeInstance.DoesNotExist: + raise ProjectCreationException(f"Volume {data['volume']} not found") + + # Handle flavor selection + if "flavor" in data: + try: + # Get the only flavor that matches the project and the name + flavor = Flavor.objects.filter(project__pk=project.pk, name=data["flavor"]).first() + data["flavor"] = flavor + logger.info(f"Modified data with specified flavor: {data}") + except Flavor.DoesNotExist: + raise ProjectCreationException(f"Flavor {data['flavor']} not found") + + model_form_tuple = APP_REGISTRY.get(app_slug) + + # Check if the model form tuple exists + if not model_form_tuple: + logger.error(f"App {app_slug} not found") + raise ProjectCreationException(f"App {app_slug} not found") + + # Create form + form = model_form_tuple.Form(data, project_pk=project.pk) + + # Handle validation + if form.is_valid(): + logger.info("Form is valid - Appending form to list") + form_dict[app_slug] = form else: - logger.error("Template has either not valid or unknown keys") - raise ProjectCreationException + logger.error(f"Form is invalid: {form.errors.as_data()}") + raise ProjectCreationException(f"Form is invalid: {form.errors.as_data()}") + + # All forms are valid, lets create apps. + logger.info("All forms valid, creating apps...") + for app_slug, form in form_dict.items(): + create_instance_from_form(form, project, app_slug) + + env_dict = template.get("environments", {}) + logger.info("Creating Project Environments...") + for name, env_settings in env_dict.items(): + try: + app = Apps.objects.filter(slug=env_settings["app"]).order_by("-revision")[0] + except Exception as err: + logger.error( + ("App for environment not found. item.app=%s project_slug=%s " "user=%s err=%s"), + env_settings["app"], + project_slug, + user, + err, + exc_info=True, + ) + raise + try: + environment = Environment( + name=name, + project=project, + repository=env_settings["repository"], + image=env_settings["image"], + app=app, + ) + environment.save() + except Exception as err: + logger.error( + ( + "Failed to create new environment: " + "key=%s " + "project=%s " + "item.repository=%s " + "image=%s " + "app=%s " + "user%s " + "err=%s" + ), + name, + project, + settings["repository"], + settings["image"], + app, + user, + err, + exc_info=True, + ) project.status = "active" project.save() -@shared_task -def delete_project_apps(project_slug): - project = Project.objects.get(slug=project_slug) - apps = AppInstance.objects.filter(project=project) - for app in apps: - apptasks.delete_resource.delay(app.pk) - - @shared_task def delete_project(project_pk): logger.info("SCHEDULING DELETION OF ALL INSTALLED APPS") project = Project.objects.get(pk=project_pk) - delete_project_apps_permanently(project) + delete_project_apps(project) project.delete() @shared_task -def delete_project_apps_permanently(project): - apps = AppInstance.objects.filter(project=project) - - for app in apps: - helm_output = delete(app.parameters) - logger.info(helm_output.stderr.decode("utf-8")) +def delete_project_apps(project): + for orm_model in APP_REGISTRY.iter_orm_models(): + queryset = orm_model.objects.filter(project=project) + for instance in queryset: + serialized_instance = instance.serialize() + delete_resource(serialized_instance) diff --git a/projects/tests/test_create_mlflow.py b/projects/tests/test_create_mlflow.py deleted file mode 100644 index e8707ee76..000000000 --- a/projects/tests/test_create_mlflow.py +++ /dev/null @@ -1,52 +0,0 @@ -from django.contrib.auth import get_user_model -from django.test import TestCase - -from ..models import MLFlow, Project - -User = get_user_model() - - -class CreateMLFlowTestCase(TestCase): - project_name = "test-perm-mlflow" - - def setUp(self) -> None: - self.user = User.objects.create_user("foo1", "foo@test.com", "bar") - self.project = Project.objects.create_project(name=self.project_name, owner=self.user, description="") - - def test_no_default_for_project(self): - obj = MLFlow( - name="mlflow1", - project=self.project, - owner=self.user, - ) - - obj.save() - - project = Project.objects.get(name=self.project_name) - - self.assertEqual(project.mlflow.name, obj.name) - - def test_default_existis_for_project(self): - obj = MLFlow( - name="mlflow1", - project=self.project, - owner=self.user, - ) - - obj.save() - - project = Project.objects.get(name=self.project_name) - - self.assertEqual(project.mlflow.name, obj.name) - - obj2 = MLFlow( - name="mlflow2", - project=self.project, - owner=self.user, - ) - - obj2.save() - - project = Project.objects.get(name=self.project_name) - - self.assertEqual(project.mlflow.name, obj.name) diff --git a/projects/tests/test_project.py b/projects/tests/test_project.py index 640d6b86d..59bfa5a0c 100644 --- a/projects/tests/test_project.py +++ b/projects/tests/test_project.py @@ -1,9 +1,10 @@ +import base64 + from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.test import TestCase, override_settings -from ..helpers import decrypt_key from ..models import Project User = get_user_model() @@ -29,6 +30,11 @@ def setUp(self): def test_decrypt_key(self): project = Project.objects.filter(name="test-secret").first() + def decrypt_key(key): + base64_bytes = key.encode("ascii") + result = base64.b64decode(base64_bytes) + return result.decode("ascii") + self.assertEqual(decrypt_key(project.project_key), "key") self.assertEqual(decrypt_key(project.project_secret), "secret") diff --git a/projects/tests/test_project_view.py b/projects/tests/test_project_view.py index e1ddef23b..41992731b 100644 --- a/projects/tests/test_project_view.py +++ b/projects/tests/test_project_view.py @@ -82,33 +82,3 @@ def test_forbidden_project_delete(self): ) self.assertTemplateUsed(response, "403.html") self.assertEqual(response.status_code, 403) - - def test_forbidden_project_setS3storage(self): - """ - Test non-project member not allowed to access project setS3storage - """ - self.client.login(username=test_member["email"], password=test_member["password"]) - project = Project.objects.get(name="test-perm") - response = self.client.get( - reverse( - "projects:set_s3storage", - kwargs={"project_slug": project.slug}, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) - - def test_forbidden_project_setmlflow(self): - """ - Test non-project member not allowed to access project setmlflow - """ - self.client.login(username=test_member["email"], password=test_member["password"]) - project = Project.objects.get(name="test-perm") - response = self.client.get( - reverse( - "projects:set_mlflow", - kwargs={"project_slug": project.slug}, - ) - ) - self.assertTemplateUsed(response, "403.html") - self.assertEqual(response.status_code, 403) diff --git a/projects/urls.py b/projects/urls.py index 21496c44f..aabd02984 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -33,12 +33,6 @@ ), path("/settings/", views.settings, name="settings"), path("/delete/", views.delete, name="delete"), - path( - "/setS3storage/", - views.set_s3storage, - name="set_s3storage", - ), - path("/setmlflow/", views.set_mlflow, name="set_mlflow"), path( "/details/change/", views.change_description, diff --git a/projects/views.py b/projects/views.py index 52379ae50..df79c8b23 100644 --- a/projects/views.py +++ b/projects/views.py @@ -22,22 +22,16 @@ from guardian.decorators import permission_required_or_403 from guardian.shortcuts import assign_perm, remove_perm +from apps.app_registry import APP_REGISTRY +from apps.models import BaseAppInstance + from .exceptions import ProjectCreationException from .forms import PublishProjectToGitHub -from .models import ( - S3, - Environment, - Flavor, - MLFlow, - Project, - ProjectLog, - ProjectTemplate, -) +from .models import Environment, Flavor, Project, ProjectLog, ProjectTemplate from .tasks import create_resources_from_template, delete_project logger = logging.getLogger(__name__) Apps = apps.get_model(app_label=django_settings.APPS_MODEL) -AppInstance = apps.get_model(app_label=django_settings.APPINSTANCE_MODEL) AppCategories = apps.get_model(app_label=django_settings.APPCATEGORIES_MODEL) Model = apps.get_model(app_label=django_settings.MODELS_MODEL) User = get_user_model() @@ -51,7 +45,7 @@ def get(self, request): projects = Project.objects.filter(status="active").distinct("pk") else: projects = Project.objects.filter( - Q(owner=request.user) | Q(authorized=request.user), + Q(owner=request.user.id) | Q(authorized=request.user.id), status="active", ).distinct("pk") except TypeError as err: @@ -110,9 +104,7 @@ def settings(request, project_slug): environments = Environment.objects.filter(project=project) apps = Apps.objects.all().order_by("slug", "-revision").distinct("slug") - s3instances = S3.objects.filter(Q(project=project), Q(app__state="Running")) flavors = Flavor.objects.filter(project=project) - mlflows = MLFlow.objects.filter(Q(project=project), Q(app__state="Running")) return render(request, template, locals()) @@ -275,71 +267,6 @@ def delete_flavor(request, project_slug): ) -@login_required -@permission_required_or_403("can_view_project", (Project, "slug", "project_slug")) -def set_s3storage(request, project_slug, s3storage=[]): - # TODO: Ensure that the user has the correct permissions to set - # this specific - # s3 object to storage in this project (need to check that - # the user has access to the - # project as well.) - if request.method == "POST" or s3storage: - project = Project.objects.get(slug=project_slug) - - if s3storage: - s3obj = S3.objects.get(name=s3storage, project=project) - else: - pk = request.POST.get("s3storage") - if pk == "blank": - s3obj = None - else: - s3obj = S3.objects.get(pk=pk) - - project.s3storage = s3obj - project.save() - - if s3storage: - return JsonResponse({"status": "ok"}) - - return HttpResponseRedirect( - reverse( - "projects:settings", - kwargs={"project_slug": project.slug}, - ) - ) - - -@login_required -@permission_required_or_403("can_view_project", (Project, "slug", "project_slug")) -def set_mlflow(request, project_slug, mlflow=[]): - # TODO: Ensure that the user has the correct permissions - # to set this specific - # MLFlow object to MLFlow Server in this project (need to check - # that the user has access to the - # project as well.) - if request.method == "POST" or mlflow: - project = Project.objects.get(slug=project_slug) - - if mlflow: - mlflowobj = MLFlow.objects.get(name=mlflow, project=project) - else: - pk = request.POST.get("mlflow") - mlflowobj = MLFlow.objects.get(pk=pk) - - project.mlflow = mlflowobj - project.save() - - if mlflow: - return JsonResponse({"status": "ok"}) - - return HttpResponseRedirect( - reverse( - "projects:settings", - kwargs={"project_slug": project.slug}, - ) - ) - - @method_decorator( permission_required_or_403("can_view_project", (Project, "slug", "project_slug")), name="dispatch", @@ -532,72 +459,55 @@ class DetailsView(View): template_name = "projects/overview.html" def get(self, request, project_slug): - resources = list() - models = Model.objects.none() + project = Project.objects.get(slug=project_slug) + resources = [] app_ids = [] - project = None - filemanager_instance = None - - if request.user.is_authenticated: - project = Project.objects.get(slug=project_slug) - if request.user.is_superuser: - categories = AppCategories.objects.all().order_by("-priority") - else: - categories = AppCategories.objects.all().exclude(slug__in=["admin-apps"]).order_by("-priority") - # models = Model.objects.filter(project=project).order_by("-uploaded_at")[:10] - models = Model.objects.filter(project=project).order_by("-uploaded_at") - - def filter_func(slug): - return Q(app__category__slug=slug) - - for category in categories: - app_instances_of_category = AppInstance.objects.get_app_instances_of_project( - user=request.user, - project=project, - filter_func=filter_func(slug=category.slug), - # limit=5, - ) + if request.user.is_superuser: + categories = AppCategories.objects.all().order_by("-priority") + else: + categories = AppCategories.objects.all().exclude(slug__in=["admin-apps"]).order_by("-priority") - app_ids += [obj.id for obj in app_instances_of_category] + for category in categories: + # Get all subclasses of Base - apps_of_category = ( - Apps.objects.filter(category=category, user_can_create=True) - .order_by("slug", "-revision") - .distinct("slug") - ) + instances_per_category_list = [] + for orm_model in APP_REGISTRY.iter_orm_models(): + # Filter instances of each subclass by project, user and status. + # See the get_app_instances_of_project_filter method in base.py - resources.append( - { - "title": category.name, - "objs": app_instances_of_category, - "apps": apps_of_category, - } + queryset_per_category = orm_model.objects.get_app_instances_of_project( + user=request.user, project=project, filter_func=Q(app__category__slug=category.slug) ) - def filter_app_slug(slug): - return Q(app__slug=slug) - - filemanager_instance = AppInstance.objects.get_app_instances_of_project( - user=request.user, project=project, filter_func=filter_app_slug(slug="filemanager") - ).first() + if queryset_per_category: + # SS-1071 Added check for app_ids and instances_per_category + # to avoid duplicates (e.g in case of shinyapps) + app_ids += [obj.id for obj in queryset_per_category if obj.id not in app_ids] + instances_per_category_list.extend( + [instance for instance in queryset_per_category if instance not in instances_per_category_list] + ) + + # Filter the available apps specified in the project template + available_apps = [app.pk for app in project.project_template.available_apps.all()] + + apps_per_category = ( + Apps.objects.filter(category=category, user_can_create=True, pk__in=available_apps) + .order_by("slug", "-revision") + .distinct("slug") + ) - if filemanager_instance: - creation_date = filemanager_instance.created_on - now = datetime.datetime.now(datetime.timezone.utc) - age = now - creation_date - timedelta = datetime.timedelta(hours=24) - hours = timedelta - age - hours = round(hours.total_seconds() / 3600) - else: - hours = 0 + resources.append( + { + "title": category.name, + "instances": instances_per_category_list, + "apps": apps_per_category, + } + ) context = { "resources": resources, - "models": models, "project": project, "app_ids": app_ids, - "filemanager_instance": filemanager_instance, - "hours": hours, } return render( diff --git a/pyproject.toml b/pyproject.toml index 8609df636..7721e6a72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ django-tagulous = "==1.3.3" django-guardian = "==2.4.0" djangorestframework = "== 3.15.1" django-crispy-forms = "==2.1" +crispy-bootstrap5 = "==2024.2" # django-wiki wiki = "==0.11.1" @@ -70,7 +71,7 @@ pytest-cov = { version = "==4.1.0", optional = true } pytest-xdist = { version = "^3.5.0", optional = true } django-axes = {extras = ["ipware"], version = "^6.3.0"} django-password-validators = "^1.7.3" - +django-htmx = "^1.18.0" [tool.poetry.extras] linters = ["black", "isort", "flake8", "mypy"] @@ -109,7 +110,7 @@ python_version = "3.12" disallow_subclassing_any = false ignore_missing_imports = false warn_return_any = true -untyped_calls_exclude = ["apps.helpers","projects.models"] +untyped_calls_exclude = ["projects.models"] # ["apps.helpers","projects.models"] exclude = ["venv", ".venv", "examples"] #plugins = ["mypy_django_plugin.main"] @@ -137,7 +138,7 @@ module = [ "*.tests.*", "*.tests", "conftest", - "cypress.*" + #"cypress.*" ] check_untyped_defs = false disallow_incomplete_defs = false @@ -150,6 +151,16 @@ disallow_untyped_defs = false module = "*.migrations.*" ignore_errors = true +# A temporary section to ease the transition of mypy into the codebase +[[tool.mypy.overrides]] +module = [ + "apps.*", + "cypress.*", + "cypress/e2e/setup-scripts/seed_collections_user" +] +strict = false +ignore_errors = true + # A temporary section to ease the transition of mypy into the codebase [[tool.mypy.overrides]] module = [ @@ -178,6 +189,7 @@ no_implicit_reexport = false module = [ "boto3.*", "colorlog.*", + "crispy_forms.*", "django.*", "django_structlog.*", "rest_framework.*", diff --git a/scripts/app_instance_permissions.py b/scripts/app_instance_permissions.py index 9f0b64510..bf8a4bbca 100644 --- a/scripts/app_instance_permissions.py +++ b/scripts/app_instance_permissions.py @@ -1,20 +1,21 @@ from guardian.shortcuts import assign_perm, remove_perm -from apps.models import AppInstance +from apps.app_registry import APP_REGISTRY +from apps.models import BaseAppInstance def run(*args): """Reads all AppInstance objects and sets correct permission based on owner (user) and the instance access property""" - app_instances_all = AppInstance.objects.all() + for orm_model in APP_REGISTRY.iter_orm_models(): + app_instances_all = orm_model.objects.all() + for app_instance in app_instances_all: + owner = app_instance.owner - for app_instance in app_instances_all: - owner = app_instance.owner - - if app_instance.access == "private": - if not owner.has_perm("can_access_app", app_instance): - assign_perm("can_access_app", owner, app_instance) - else: - if owner.has_perm("can_access_app", app_instance): - remove_perm("can_access_app", owner, app_instance) + if getattr(app_instance, "access", False) == "private": + if not owner.has_perm("can_access_app", app_instance): + assign_perm("can_access_app", owner, app_instance) + else: + if owner.has_perm("can_access_app", app_instance): + remove_perm("can_access_app", owner, app_instance) diff --git a/static/css/serve-elements.css b/static/css/serve-elements.css index 10ab1ead0..da4aad97f 100644 --- a/static/css/serve-elements.css +++ b/static/css/serve-elements.css @@ -84,6 +84,27 @@ border-color: var(--scaleout-green); } +.form-control-with-spinner { + width: -webkit-calc(100% - 50px); + width: -moz-calc(100% - 50px); + width: calc(100% - 50px); +} + +.client-validation-feedback { + display: block; + width: 100%; + margin-top: 0.25rem; + font-size: 80%; +} + +.client-validation-valid { + color: rgb(25, 135, 84); +} + +.client-validation-invalid { + color: var(--bs-danger); +} + /* links */ a { diff --git a/static/js/htmx.min.js b/static/js/htmx.min.js new file mode 100644 index 000000000..66d713f47 --- /dev/null +++ b/static/js/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.1"};Q.onLoad=$;Q.process=kt;Q.on=be;Q.off=we;Q.trigger=he;Q.ajax=Hn;Q.find=r;Q.findAll=p;Q.closest=g;Q.remove=K;Q.addClass=Y;Q.removeClass=o;Q.toggleClass=W;Q.takeClass=ge;Q.swap=ze;Q.defineExtension=Un;Q.removeExtension=Bn;Q.logAll=z;Q.logNone=J;Q.parseInterval=d;Q._=_;const n={addTriggerHandler:Et,bodyContains:le,canAccessLocalStorage:j,findThisElement:Ee,filterValues:dn,swap:ze,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:T,getExpressionVars:Cn,getHeaders:hn,getInputValues:cn,getInternalData:ie,getSwapSpecification:pn,getTriggerSpecs:lt,getTarget:Ce,makeFragment:k,mergeObjects:ue,makeSettleInfo:xn,oobSwap:Te,querySelectorExt:fe,settleImmediately:Gt,shouldCancel:dt,triggerEvent:he,triggerErrorEvent:ae,withExtensions:Ut};const v=["get","post","put","delete","patch"];const R=v.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");const O=e("head");function e(e,t=false){return new RegExp(`<${e}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${e}>`,t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function H(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function T(e,t){while(e&&!t(e)){e=u(e)}return e||null}function q(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;T(t,function(e){return!!(r=q(t,ce(e),n))});if(r!=="unset"){return r}}function a(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function L(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function N(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function A(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function I(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function P(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function D(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(P(e)){const t=I(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){w(e)}finally{e.remove()}}})}function k(e){const t=e.replace(O,"");const n=L(t);let r;if(n==="html"){r=new DocumentFragment;const i=N(e);A(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=N(t);A(r,i.body);r.title=i.title}else{const i=N('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){D(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function M(e){return typeof e==="function"}function X(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function le(e){const t=e.getRootNode&&e.getRootNode();if(t&&t instanceof window.ShadowRoot){return ne().body.contains(t.host)}else{return ne().body.contains(e)}}function B(e){return e.trim().split(/\s+/)}function ue(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){w(e);return null}}function j(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function V(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function _(e){return vn(ne().body,function(){return eval(e)})}function $(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function z(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function J(){Q.logger=null}function r(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return r(ne(),e)}}function p(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return p(ne(),e)}}function E(){return window}function K(e,t){e=y(e);if(t){E().setTimeout(function(){K(e);e=null},t)}else{u(e).removeChild(e)}}function ce(e){return e instanceof Element?e:null}function G(e){return e instanceof HTMLElement?e:null}function Z(e){return typeof e==="string"?e:null}function h(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function Y(e,t,n){e=ce(y(e));if(!e){return}if(n){E().setTimeout(function(){Y(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function o(e,t,n){let r=ce(y(e));if(!r){return}if(n){E().setTimeout(function(){o(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function ge(e,t){e=y(e);se(e.parentElement.children,function(e){o(e,t)});Y(ce(e),t)}function g(e,t){e=ce(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||a(e,t)){return e}}while(e=e&&ce(u(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function pe(e,t){return e.substring(e.length-t.length)===t}function i(e){const t=e.trim();if(l(t,"<")&&pe(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(e,t,n){e=y(e);if(t.indexOf("closest ")===0){return[g(ce(e),i(t.substr(8)))]}else if(t.indexOf("find ")===0){return[r(h(e),i(t.substr(5)))]}else if(t==="next"){return[ce(e).nextElementSibling]}else if(t.indexOf("next ")===0){return[me(e,i(t.substr(5)),!!n)]}else if(t==="previous"){return[ce(e).previousElementSibling]}else if(t.indexOf("previous ")===0){return[ye(e,i(t.substr(9)),!!n)]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else if(t==="root"){return[H(e,!!n)]}else if(t.indexOf("global ")===0){return m(e,t.slice(7),true)}else{return F(h(H(e,!!n)).querySelectorAll(i(t)))}}var me=function(t,e,n){const r=h(H(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function fe(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return r(h(t)||document,e)}else{return e}}function xe(e,t,n){if(M(t)){return{target:ne().body,event:Z(e),listener:t}}else{return{target:y(e),event:Z(t),listener:n}}}function be(t,n,r){_n(function(){const e=xe(t,n,r);e.target.addEventListener(e.event,e.listener)});const e=M(n);return e?n:r}function we(t,n,r){_n(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return M(n)?n:r}const ve=ne().createElement("output");function Se(e,t){const n=re(e,t);if(n){if(n==="this"){return[Ee(e,t)]}else{const r=m(e,n);if(r.length===0){w('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Ee(e,t){return ce(T(e,function(e){return te(ce(e),t)!=null}))}function Ce(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Ee(e,"hx-target")}else{return fe(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Re(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{s=e}const n=ne().querySelectorAll(t);if(n){se(n,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!He(s,e)){t=h(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){_e(s,e,e,t,i)}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);ae(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function qe(e){se(p(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){e.parentNode.replaceChild(n,e)}})}function Le(l,e,u){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=h(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);u.tasks.push(function(){Oe(t,s)})}}})}function Ne(e){return function(){o(e,Q.config.addedClass);kt(ce(e));Ae(h(e));he(e,"htmx:load")}}function Ae(e){const t="[autofocus]";const n=G(a(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;Y(ce(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ne(o))}}}function Ie(e,t){let n=0;while(n0){E().setTimeout(l,r.settleDelay)}else{l()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(!X(e)){e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){ae(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(nt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function b(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function ot(e){let t;if(e.length>0&&Qe.test(e[0])){e.shift();t=b(e,et).trim();e.shift()}else{t=b(e,x)}return t}const it="input, textarea, select";function st(e,t,n){const r=[];const o=tt(t);do{b(o,We);const l=o.length;const u=b(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};b(o,We);c.pollInterval=d(b(o,/[,\[\s]/));b(o,We);var i=rt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const f={trigger:u};var i=rt(e,o,"event");if(i){f.eventFilter=i}while(o.length>0&&o[0]!==","){b(o,We);const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(b(o,x))}else if(a==="from"&&o[0]===":"){o.shift();if(Qe.test(o[0])){var s=ot(o)}else{var s=b(o,x);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=ot(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=ot(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(b(o,x))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=b(o,x)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=ot(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=b(o,x)}else{ae(e,"htmx:syntax:error",{token:o.shift()})}}r.push(f)}}if(o.length===l){ae(e,"htmx:syntax:error",{token:o.shift()})}b(o,We)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function lt(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||st(e,t,r)}if(n.length>0){return n}else if(a(e,"form")){return[{trigger:"submit"}]}else if(a(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(a(e,it)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function ut(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ft(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ht(t,n,e){if(t instanceof HTMLAnchorElement&&ft(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";if(r==="get"){}o=ee(t,"action")}e.forEach(function(e){mt(t,function(e,t){const n=ce(e);if(at(n)){f(n);return}de(r,o,n,t)},n,e,true)})}}function dt(e,t){const n=ce(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(a(n,'input[type="submit"], button')&&g(n,"form")!==null){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function gt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;ae(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function mt(s,l,e,u,c){const f=ie(s);let t;if(u.from){t=m(s,u.from)}else{t=[s]}if(u.changed){t.forEach(function(e){const t=ie(e);t.lastValue=e.value})}se(t,function(o){const i=function(e){if(!le(s)){o.removeEventListener(u.trigger,i);return}if(gt(s,e)){return}if(c||dt(e,s)){e.preventDefault()}if(pt(u,s,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(s)<0){t.handledFor.push(s);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!a(ce(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=ie(o);const r=o.value;if(n.lastValue===r){return}n.lastValue=r}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){l(s,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){l(s,e)},u.delay)}else{he(s,"htmx:trigger");l(s,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:i,on:o});o.addEventListener(u.trigger,i)})}let yt=false;let xt=null;function bt(){if(!xt){xt=function(){yt=true};window.addEventListener("scroll",xt);setInterval(function(){if(yt){yt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){wt(e)})}},200)}}function wt(e){if(!s(e,"data-hx-revealed")&&U(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function St(t,n,e){let i=false;se(v,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){Et(t,e,n,function(e,t){const n=ce(e);if(g(n,Q.config.disableSelector)){f(n);return}de(r,o,n,t)})})}});return i}function Et(r,e,t,n){if(e.trigger==="revealed"){bt();mt(r,n,t,e);wt(ce(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=fe(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ce(r),n,e)}else{mt(r,n,t,e)}}function Ct(e){const t=ce(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function qt(e){const t=g(ce(e.target),"button, input[type='submit']");const n=Nt(e);if(n){n.lastButtonClicked=t}}function Lt(e){const t=Nt(e);if(t){t.lastButtonClicked=null}}function Nt(e){const t=g(ce(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",qt);e.addEventListener("focusin",qt);e.addEventListener("focusout",Lt)}function It(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){De(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){ae(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function _t(t){if(!j()){return null}t=V(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=k(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=jt();const r=xn(n);kn(e.title);Ve(n,t,r);Gt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{ae(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Yt(e){zt();e=e||location.pathname+location.search;const t=_t(e);if(t){const n=k(t.content);const r=jt();const o=xn(r);kn(n.title);Ve(r,n,o);Gt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Zt(e)}}}function Wt(e){let t=Se(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Qt(e){let t=Se(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function en(e,t){se(e,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function tn(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function sn(t,n,r,o,i){if(o==null||tn(t,o)){return}else{t.push(o)}if(nn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=F(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=F(o.files)}rn(s,e,n);if(i){ln(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){on(e.name,e.value,n)}else{t.push(e)}if(i){ln(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}rn(t,e,n)})}}function ln(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function un(t,e){for(const n of e.keys()){t.delete(n);e.getAll(n).forEach(function(e){t.append(n,e)})}return t}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){sn(n,o,i,g(e,"form"),l)}sn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const f=ee(c,"name");rn(f,c.value,o)}const u=Se(e,"hx-include");se(u,function(e){sn(n,r,i,ce(e),l);if(!a(e,"form")){se(h(e).querySelectorAll(it),function(e){sn(n,r,i,e,l)})}});un(r,o);return{errors:i,formData:r,values:An(r)}}function fn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=Ln(e);let n="";e.forEach(function(e,t){n=fn(n,t,e)});return n}function hn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};wn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function dn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.substr(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function gn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function pn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!gn(e)){r.show="top"}if(n){const s=B(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.substr(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.substr("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{w("Unknown modifier in hx-swap: "+l)}}}}return r}function mn(e){return re(e,"hx-encoding")==="multipart/form-data"||a(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function yn(t,n,r){let o=null;Ut(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(mn(n)){return un(new FormData,Ln(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function bn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ce(fe(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ce(fe(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function wn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.substr(11);t=true}else if(e.indexOf("js:")===0){e=e.substr(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return wn(ce(u(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{ae(e,"htmx:evalDisallowedError");return n}}function Sn(e,t){return wn(e,"hx-vars",true,t)}function En(e,t){return wn(e,"hx-vals",false,t)}function Cn(e){return ue(Sn(e),En(e))}function Rn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){ae(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function C(e,t){return t.test(e.getAllResponseHeaders())}function Hn(e,t,n){e=e.toLowerCase();if(n){if(n instanceof Element||typeof n==="string"){return de(e,t,null,null,{targetOverride:y(n),returnPromise:true})}else{return de(e,t,y(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:y(n.target),swapOverride:n.swap,select:n.select,returnPromise:true})}}else{return de(e,t,null,null,{returnPromise:true})}}function Tn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function qn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ue({url:o,sameHost:r},n))}function Ln(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Nn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(r){return new Proxy(r,{get:function(e,t){if(typeof t==="symbol"){return Reflect.get(e,t)}if(t==="toJSON"){return()=>Object.fromEntries(r)}if(t in e){if(typeof e[t]==="function"){return function(){return r[t].apply(r,arguments)}}else{return e[t]}}const n=r.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Nn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,k){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Mn;const X=i.select||null;if(!le(r)){oe(s);return e}const u=i.targetOverride||ce(Ce(r));if(u==null||u==ve){ae(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let c=ie(r);const f=c.lastButtonClicked;if(f){const L=ee(f,"formaction");if(L!=null){n=L}const N=ee(f,"formmethod");if(N!=null){if(N.toLowerCase()!=="dialog"){t=N}}}const a=re(r,"hx-confirm");if(k===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const A=d.split(":");const I=A[0].trim();if(I==="this"){h=Ee(r,"hx-sync")}else{h=ce(fe(r,I))}d=(A[1]||"drop").trim();c=ie(h);if(d==="drop"&&c.xhr&&c.abortable!==true){oe(s);return e}else if(d==="abort"){if(c.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const Z=d.split(" ");g=(Z[1]||"last").trim()}}if(c.xhr){if(c.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(g==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){c.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;c.xhr=p;c.abortable=F;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const U=re(r,"hx-prompt");if(U){var y=prompt(U);if(y===null||!he(r,"htmx:prompt",{prompt:y,target:u})){oe(s);m();return e}}if(a&&!k){if(!confirm(a)){oe(s);m();return e}}let x=hn(r,u,y);if(t!=="get"&&!mn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=ue(x,i.headers)}const B=cn(r,t);let b=B.errors;const j=B.formData;if(i.values){un(j,Ln(i.values))}const V=Ln(Cn(r));const w=un(j,V);let v=dn(w,r);if(Q.config.getCacheBusterParam&&t==="get"){v.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=wn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:v,parameters:An(v),unfilteredFormData:w,unfilteredParameters:An(w),headers:x,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;x=C.headers;v=Ln(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const $=n.split("#");const z=$[0];const R=$[1];let O=n;if(E){O=z;const Y=!v.keys().next().done;if(Y){if(O.indexOf("?")<0){O+="?"}else{O+="&"}O+=an(v);if(R){O+="#"+R}}}if(!qn(r,O,C)){ae(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),O,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const D in x){if(x.hasOwnProperty(D)){const W=x[D];Rn(p,D,W)}}}const H={xhr:p,target:u,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:O,responsePath:null,anchor:R}};p.onload=function(){try{const t=Tn(r);H.pathInfo.responsePath=On(p);M(r,H);en(T,q);he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){ae(r,"htmx:onLoadError",ue({error:e},H));throw e}};p.onerror=function(){en(T,q);ae(r,"htmx:afterRequest",H);ae(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){en(T,q);ae(r,"htmx:afterRequest",H);ae(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){en(T,q);ae(r,"htmx:afterRequest",H);ae(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Wt(r);var q=Qt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:yn(p,r,v);p.send(J);return e}function In(e,t){const n=t.xhr;let r=null;let o=null;if(C(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(C(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(C(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const u=re(e,"hx-replace-url");const c=ie(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(u){f="replace";a=u}else if(c){f="push";a=s||i}if(a){if(a==="false"){return{}}if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Pn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Dn(e){for(var t=0;t0){E().setTimeout(e,y.swapDelay)}else{e()}}if(a){ae(o,"htmx:responseError",ue({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Xn={};function Fn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Un(e,t){if(t.init){t.init(n)}Xn[e]=ue(Fn(),t)}function Bn(e){delete Xn[e]}function jn(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Xn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return jn(ce(u(e)),n,r)}var Vn=false;ne().addEventListener("DOMContentLoaded",function(){Vn=true});function _n(e){if(Vn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function $n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function Jn(){const e=zn();if(e){Q.config=ue(Q.config,e)}}_n(function(){Jn();$n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Yt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); diff --git a/static/tagulous/adaptor/select2-3.js b/static/tagulous/adaptor/select2-3.js new file mode 100644 index 000000000..913b91fce --- /dev/null +++ b/static/tagulous/adaptor/select2-3.js @@ -0,0 +1,268 @@ +/** Tagulous adaptor for Select2 v3 */ +(function ($) { + if (!window.Select2 || !window.Tagulous) { + return; + } + + /** Select2 monkeypatching + Adds support for new option, quotedTags + + This is used by tagulous to add quote support to the tag parser, + without affecting other use of select2 on the page. + */ + var MultiSelect2 = Select2['class'].multi, + oldGetVal = MultiSelect2.prototype.getVal, + oldSetVal = MultiSelect2.prototype.setVal + ; + + MultiSelect2.prototype.getVal = function () { + /** Parse tag string into tags */ + if (this.select || !this.opts.quotedTags) { + return oldGetVal.call(this); + } + return Tagulous.parseTags( + this.opts.element.val(), this.opts.spaceDelimiter + ); + }; + MultiSelect2.prototype.setVal = function (val) { + /** Join tags into a string */ + if (this.select || !this.opts.quotedTags) { + return oldSetVal.call(this, val); + } + + var str = Tagulous.renderTags(val); + this.opts.element.val(str); + }; + + /** Select2 option functions + + These replace default options with quote-aware ones + */ + function initSelectionSingle(element, callback) { + var val = element.val(); + callback({id: val, text: val}); + } + function initSelectionMulti_factory(opts) { + /** initSelection has no way to get options, so need to use closure */ + return function (element, callback) { + /** Initialises selection for fields with multiple tags */ + var tags = Tagulous.parseTags(element.val(), opts.spaceDelimiter), + data = [], + i + ; + for (i=0; i 0 && // there are quotes + numEndQuotes > 0 && + numStartQuotes + numEndQuotes < term.length && // something other than quotes + numStartQuotes % 2 == 1 && // uneven quotes, so one is unescaped + numEndQuotes % 2 == 1 + ) { + params.term = term.substr(1, term.length - 2); + } + + return decorated.call(this, params); + } + + // Rather than re-implement Default.prototype.apply logic to determine the correct + // data adapter base classes so we can pass it in as options.dataAdapter, we'll just + // monkeypatch Default.apply so we can add our data adapter extension at the end. + // There might be a nicer way to do this, PRs welcome! + var oldApply = defaults.apply + defaults.apply = function (options) { + options = oldApply.call(this, options); + options.dataAdapter = Utils.Decorate(options.dataAdapter, TagulousDataAdapter); + return options + }; + + + /** Apply select2 to a specified element + + Arguments: + el The DOM or jQuery object to use as the tag element + canDefer If true and tag-options.defer is set, this field + will not be initialised. + */ + function apply_select2(el, canDefer) { + // Convert element to jQuery object (if it isn't already) + var $el = $(el), + thisTagField = this, + + // Get info from element + isSingle = $el.data('tag-type') === "single", + options = $el.data('tag-options') || {}, + settings = options.autocomplete_settings || {}, + list = $el.data('tag-list'), + url = $el.data('tag-url'), + + // Other values + $blank, args, field_args + ; + // See if this is a deferred tag + if (canDefer && settings.defer) { + return $el; + } + delete settings.defer; + + // Clear out first option if it's Django's blank value + $blank = $el + .find('option:first[value=""]:contains("---------")') + .text('') + ; + + // Default constructor args, which can be overridden + args = { + width: 'resolve' + }; + + // Merge in any overrides + field_args = settings; + if (field_args) { + $.extend(args, field_args); + } + // Merge in common compulsory settings + $.extend(args, { + // Select2 options + tokenizer: tokenizer, + tags: true, + + // Things defined by field/tag options, which can't be overridden + multiple: !isSingle, + quotedTags: true, + allowClear: !options.required, + maximumSelectionLength: isSingle ? null : options.max_count || null, + spaceDelimiter: options.space_delimiter !== false + }); + + // Add in any specific to the field type + if (url) { + args['ajax'] = { + url: url, + dataType: 'json', + data: function (params) { + return {q:params.term, p:params.page}; + }, + processResults: function (data) { + data['results'] = listToData(data['results']); + return data; + } + }; + + // Merge in override ajax values + if (field_args && field_args.ajax) { + $.extend(args['ajax'], field_args.ajax); + } + + } else if (isSingle) { + // Make SingleTagField look like a select, set data not tags + args['data'] = listToData(list); + + // Add a placeholder to ignore the empty option we're about to add + args['placeholder'] = $el.prop('placeholder') || ""; + + } else { + // Multiple tags, normal tags mode appropriate + $.extend(args, { + data: list || [], + }); + } + + // Collect values + var currentVal = $el.val(); + var selectedTags = []; + if (isSingle) { + if (currentVal) { + selectedTags = [currentVal]; + } + } else { + selectedTags = Tagulous.parseTags(currentVal, args.spaceDelimiter); + } + + /** + * Convert to ').width($el.width()); + + if ($el.width() == 0) { + $selectEl.css('width', '20em'); + } else { + $selectEl.width($el.width()); + } + + if (options.required) { + $selectEl.prop('required', true); + } + + if ($el.data('theme')) { + $selectEl.attr('data-theme', $el.data('theme')); + } + + + if (isSingle) { + // Add an empty option so the placeholder takes effect + // This prevents us accidentally selecting the first value + var option = new Option('', '', true, true); + $selectEl.append(option).trigger('change'); + } + + // Swap in + $selectEl.insertAfter($inputEl.hide()); + var $selectCtl = $selectEl.select2(args); + + if (selectedTags.length > 0) { + var selectedData = []; + for (var i=0; i -1 || str.indexOf(SPACE) > -1) { + safe.push(QUOTE + str + QUOTE); + } else { + safe.push(str); + } + } + safe.sort(); + return safe.join(', '); + } + + return { + parseTags: parseTags, + renderTags: renderTags + }; +})(); diff --git a/studio/migrations/__init__.py b/studio/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/studio/migrations/apps/0001_initial.py b/studio/migrations/apps/0001_initial.py deleted file mode 100644 index 3d8fee336..000000000 --- a/studio/migrations/apps/0001_initial.py +++ /dev/null @@ -1,128 +0,0 @@ -# Generated by Django 4.2.5 on 2023-11-16 08:07 - -import django.db.models.deletion -import tagulous.models.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [] - - operations = [ - migrations.CreateModel( - name="AppCategories", - fields=[ - ("name", models.CharField(max_length=512)), - ("priority", models.IntegerField(default=100)), - ("slug", models.CharField(default="", max_length=512, primary_key=True, serialize=False)), - ], - ), - migrations.CreateModel( - name="AppInstance", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("access", models.CharField(blank=True, default="private", max_length=20, null=True)), - ("created_on", models.DateTimeField(auto_now_add=True)), - ("deleted_on", models.DateTimeField(blank=True, null=True)), - ("description", models.TextField(blank=True, default="", null=True)), - ("info", models.JSONField(blank=True, null=True)), - ("name", models.CharField(default="app_name", max_length=512)), - ("parameters", models.JSONField(blank=True, null=True)), - ("state", models.CharField(blank=True, max_length=50, null=True)), - ("table_field", models.JSONField(blank=True, null=True)), - ("updated_on", models.DateTimeField(auto_now=True)), - ], - options={ - "permissions": [("can_access_app", "Can access app service")], - }, - ), - migrations.CreateModel( - name="Tagulous_AppInstance_tags", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=255, unique=True)), - ("slug", models.SlugField()), - ( - "count", - models.IntegerField(default=0, help_text="Internal counter of how many times this tag is in use"), - ), - ( - "protected", - models.BooleanField(default=False, help_text="Will not be deleted when the count reaches 0"), - ), - ], - options={ - "ordering": ("name",), - "abstract": False, - "unique_together": {("slug",)}, - }, - bases=(tagulous.models.models.BaseTagModel, models.Model), - ), - migrations.CreateModel( - name="ResourceData", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("cpu", models.IntegerField()), - ("gpu", models.IntegerField()), - ("mem", models.IntegerField()), - ("time", models.IntegerField()), - ( - "appinstance", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="resourcedata", to="apps.appinstance" - ), - ), - ], - ), - migrations.CreateModel( - name="AppStatus", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("info", models.JSONField(blank=True, null=True)), - ("status_type", models.CharField(default="app_name", max_length=15)), - ("time", models.DateTimeField(auto_now_add=True)), - ( - "appinstance", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="status", to="apps.appinstance" - ), - ), - ], - options={ - "get_latest_by": "time", - }, - ), - migrations.CreateModel( - name="Apps", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("user_can_create", models.BooleanField(default=True)), - ("user_can_edit", models.BooleanField(default=True)), - ("user_can_delete", models.BooleanField(default=True)), - ("access", models.CharField(blank=True, default="public", max_length=20, null=True)), - ("chart", models.CharField(max_length=512)), - ("chart_archive", models.FileField(blank=True, null=True, upload_to="apps/")), - ("created_on", models.DateTimeField(auto_now_add=True)), - ("description", models.TextField(blank=True, default="", null=True)), - ("logo", models.CharField(blank=True, max_length=512, null=True)), - ("name", models.CharField(max_length=512)), - ("priority", models.IntegerField(default=100)), - ("revision", models.IntegerField(default=1)), - ("settings", models.JSONField(blank=True, null=True)), - ("slug", models.CharField(blank=True, max_length=512, null=True)), - ("table_field", models.JSONField(blank=True, null=True)), - ("updated_on", models.DateTimeField(auto_now=True)), - ( - "category", - models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="apps", - to="apps.appcategories", - ), - ), - ], - ), - ] diff --git a/studio/migrations/apps/0002_initial.py b/studio/migrations/apps/0002_initial.py deleted file mode 100644 index ea26d718d..000000000 --- a/studio/migrations/apps/0002_initial.py +++ /dev/null @@ -1,83 +0,0 @@ -# Generated by Django 4.2.5 on 2023-11-16 08:07 - -import django.db.models.deletion -import tagulous.models.fields -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("models", "0001_initial"), - ("apps", "0001_initial"), - ("projects", "0001_initial"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name="apps", - name="projects", - field=models.ManyToManyField(blank=True, to="projects.project"), - ), - migrations.AddField( - model_name="appinstance", - name="app", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="appinstance", to="apps.apps" - ), - ), - migrations.AddField( - model_name="appinstance", - name="app_dependencies", - field=models.ManyToManyField(blank=True, to="apps.appinstance"), - ), - migrations.AddField( - model_name="appinstance", - name="flavor", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.RESTRICT, - related_name="appinstance", - to="projects.flavor", - ), - ), - migrations.AddField( - model_name="appinstance", - name="model_dependencies", - field=models.ManyToManyField(blank=True, to="models.model"), - ), - migrations.AddField( - model_name="appinstance", - name="owner", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="app_owner", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AddField( - model_name="appinstance", - name="project", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="appinstance", to="projects.project" - ), - ), - migrations.AddField( - model_name="appinstance", - name="tags", - field=tagulous.models.fields.TagField( - _set_tag_meta=True, - blank=True, - help_text="Enter a comma-separated tag string", - to="apps.tagulous_appinstance_tags", - ), - ), - migrations.AlterUniqueTogether( - name="apps", - unique_together={("slug", "revision")}, - ), - ] diff --git a/studio/migrations/apps/0003_appinstance_note_on_linkonly_privacy.py b/studio/migrations/apps/0003_appinstance_note_on_linkonly_privacy.py deleted file mode 100644 index f59f709b6..000000000 --- a/studio/migrations/apps/0003_appinstance_note_on_linkonly_privacy.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.7 on 2024-02-13 16:49 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("apps", "0002_initial"), - ] - - operations = [ - migrations.AddField( - model_name="appinstance", - name="note_on_linkonly_privacy", - field=models.TextField(blank=True, default="", null=True), - ), - ] diff --git a/studio/migrations/apps/0004_remove_appinstance_note_on_linkonly_privacy.py b/studio/migrations/apps/0004_remove_appinstance_note_on_linkonly_privacy.py deleted file mode 100644 index 5cfd0c7bc..000000000 --- a/studio/migrations/apps/0004_remove_appinstance_note_on_linkonly_privacy.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 4.2.7 on 2024-02-15 17:06 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("apps", "0003_appinstance_note_on_linkonly_privacy"), - ] - - operations = [ - migrations.RemoveField( - model_name="appinstance", - name="note_on_linkonly_privacy", - ), - ] diff --git a/studio/migrations/apps/0005_appinstance_note_on_linkonly_privacy.py b/studio/migrations/apps/0005_appinstance_note_on_linkonly_privacy.py deleted file mode 100644 index 2486663bc..000000000 --- a/studio/migrations/apps/0005_appinstance_note_on_linkonly_privacy.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.7 on 2024-02-21 12:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("apps", "0004_remove_appinstance_note_on_linkonly_privacy"), - ] - - operations = [ - migrations.AddField( - model_name="appinstance", - name="note_on_linkonly_privacy", - field=models.TextField(blank=True, default="", null=True), - ), - ] diff --git a/studio/migrations/apps/0006_appinstance_collections.py b/studio/migrations/apps/0006_appinstance_collections.py deleted file mode 100644 index 78d21bbc1..000000000 --- a/studio/migrations/apps/0006_appinstance_collections.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.7 on 2024-03-20 15:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("collections_module", "0001_initial"), - ("apps", "0005_appinstance_note_on_linkonly_privacy"), - ] - - operations = [ - migrations.AddField( - model_name="appinstance", - name="collections", - field=models.ManyToManyField(blank=True, related_name="app_instances", to="collections_module.collection"), - ), - ] diff --git a/studio/migrations/apps/0007_appinstance_source_code_url.py b/studio/migrations/apps/0007_appinstance_source_code_url.py deleted file mode 100644 index 31a42c5b4..000000000 --- a/studio/migrations/apps/0007_appinstance_source_code_url.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.7 on 2024-03-28 15:25 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("apps", "0006_appinstance_collections"), - ] - - operations = [ - migrations.AddField( - model_name="appinstance", - name="source_code_url", - field=models.URLField(blank=True, null=True), - ), - ] diff --git a/studio/migrations/apps/__init__.py b/studio/migrations/apps/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/studio/migrations/models/__init__.py b/studio/migrations/models/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/studio/migrations/monitor/__init__.py b/studio/migrations/monitor/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/studio/migrations/portal/0001_initial.py b/studio/migrations/portal/0001_initial.py deleted file mode 100644 index c5a7bf597..000000000 --- a/studio/migrations/portal/0001_initial.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 4.2.5 on 2023-11-16 08:07 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("models", "0001_initial"), - ("projects", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="PublicModelObject", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("obj", models.FileField(upload_to="models/objects/")), - ("updated_on", models.DateTimeField(auto_now=True)), - ("created_on", models.DateTimeField(auto_now_add=True)), - ("model", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to="models.model")), - ], - ), - migrations.CreateModel( - name="PublishedModel", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=512)), - ("pattern", models.CharField(default="", max_length=255)), - ("updated_on", models.DateTimeField(auto_now=True)), - ("created_on", models.DateTimeField(auto_now_add=True)), - ("model_obj", models.ManyToManyField(to="portal.publicmodelobject")), - ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="projects.project")), - ], - ), - ] diff --git a/studio/migrations/portal/0002_publishedmodel_collections.py b/studio/migrations/portal/0002_publishedmodel_collections.py deleted file mode 100644 index 790d8dc28..000000000 --- a/studio/migrations/portal/0002_publishedmodel_collections.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.2.7 on 2024-03-22 07:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("collections_module", "0001_initial"), - ("portal", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="publishedmodel", - name="collections", - field=models.ManyToManyField( - blank=True, related_name="published_models", to="collections_module.collection" - ), - ), - ] diff --git a/studio/migrations/portal/__init__.py b/studio/migrations/portal/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/studio/migrations/projects/0002_project_project_template.py b/studio/migrations/projects/0002_project_project_template.py deleted file mode 100644 index 5661af68b..000000000 --- a/studio/migrations/projects/0002_project_project_template.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.2.7 on 2024-02-23 13:16 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("projects", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="project", - name="project_template", - field=models.ForeignKey( - null=True, on_delete=django.db.models.deletion.DO_NOTHING, to="projects.projecttemplate" - ), - ), - ] diff --git a/studio/migrations/projects/__init__.py b/studio/migrations/projects/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/studio/settings.py b/studio/settings.py index 9facb24b7..c14237429 100644 --- a/studio/settings.py +++ b/studio/settings.py @@ -91,18 +91,16 @@ "tagulous", "guardian", "crispy_forms", + "crispy_bootstrap5", "common", "portal", "projects", "models", - "monitor", "apps", "api", - "customtags", - "news", "axes", # django-axes for brute force login protection "django_password_validators", # django-password-validators for password validation - "collections_module", + "django_htmx", ] + DJANGO_WIKI_APPS MIDDLEWARE = ( @@ -117,13 +115,16 @@ "corsheaders.middleware.CorsMiddleware", "axes.middleware.AxesMiddleware", "studio.middleware.ExceptionLoggingMiddleware", + "django_htmx.middleware.HtmxMiddleware", ] + DJANGO_WIKI_MIDDLEWARE + (STRUCTLOG_MIDDLEWARE if not DEBUG else []) ) ROOT_URLCONF = "studio.urls" -CRISPY_TEMPLATE_PACK = "bootstrap" + +CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" +CRISPY_TEMPLATE_PACK = "bootstrap5" TEMPLATES = [ { @@ -351,7 +352,7 @@ KUBECONFIG = "/app/cluster.conf" NAMESPACE = "default" KUBE_API_REQUEST_TIMEOUT = 1 -STORAGECLASS = "microk8s-hostpath" +STORAGECLASS = "local-path" # This can be simply "localhost", but it's better to test with a # wildcard dns such as nip.io @@ -378,7 +379,7 @@ # Apps APPS_MODEL = "apps.Apps" -APPINSTANCE_MODEL = "apps.AppInstance" +APPINSTANCE_MODEL = "apps.BaseAppInstance" APPCATEGORIES_MODEL = "apps.AppCategories" # Models @@ -405,18 +406,13 @@ EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD") EMAIL_HOST_PASSWORD = os.getenv("EMAIL_PASSWORD") -# 2024-02-21: Removed because this is not used. -# VERSION = "dev" MIGRATION_MODULES = { - "apps": "studio.migrations.apps", - "models": "studio.migrations.models", - "monitor": "studio.migrations.monitor", - "portal": "studio.migrations.portal", - "projects": "studio.migrations.projects", + "apps": "apps.migrations", + "models": "models.migrations", + "portal": "portal.migrations", + "projects": "projects.migrations", "common": "common.migrations", - "news": "news.migrations", - "collections_module": "collections_module.migrations", } # Defines how many apps a user is allowed to create within one project @@ -440,9 +436,10 @@ "combiner": 0, "mongodb": 0, "netpolicy": 0, + "filemanager": 1, } -PROJECTS_PER_USER_LIMIT = 5 +PROJECTS_PER_USER_LIMIT = 10 STUDIO_ACCESSMODE = os.environ.get("STUDIO_ACCESSMODE", "") ENABLE_PROJECT_EXTRA_SETTINGS = False @@ -519,3 +516,5 @@ logger_factory=structlog.stdlib.LoggerFactory(), cache_logger_on_first_use=True, ) + +LOKI_SVC = None diff --git a/studio/tests.py b/studio/tests.py index 254fdf540..8974f10f1 100644 --- a/studio/tests.py +++ b/studio/tests.py @@ -5,7 +5,7 @@ from django.test import TestCase from guardian.shortcuts import assign_perm, remove_perm -from apps.models import AppInstance, Apps +from apps.models import Apps, AppStatus, JupyterInstance, Subdomain from common.models import EmailVerificationTable, UserProfile from projects.models import Project from scripts.app_instance_permissions import run @@ -21,12 +21,16 @@ def get_data(self, user, access): project = Project.objects.create_project(name="test-perm", owner=user, description="") app = Apps.objects.create(name="FEDn Combiner") - app_instance = AppInstance.objects.create( + subdomain = Subdomain.objects.create(subdomain="test_internal") + app_status = AppStatus.objects.create(status="Created") + app_instance = JupyterInstance.objects.create( access=access, owner=user, name="test_app_instance_private", app=app, project=project, + subdomain=subdomain, + app_status=app_status, ) return [project, app, app_instance] diff --git a/studio/urls.py b/studio/urls.py index 77e99a014..dfe4e5303 100644 --- a/studio/urls.py +++ b/studio/urls.py @@ -47,12 +47,6 @@ path("", include("common.urls", namespace="common")), path("", include("models.urls", namespace="models")), path("", include("portal.urls", namespace="portal")), - path("", include("news.urls", namespace="news")), - path("", include("collections_module.urls", namespace="collections_module")), - path( - "//monitor/", - include("monitor.urls", namespace="monitor"), - ), path("projects//apps/", include("apps.urls", namespace="apps")), ] + staticfiles_urlpatterns() diff --git a/studio/views.py b/studio/views.py index ed75eeeae..e433b1b35 100644 --- a/studio/views.py +++ b/studio/views.py @@ -1,5 +1,4 @@ -import json -from typing import Any, cast +from typing import Any, Callable, cast import requests from django.conf import settings @@ -9,20 +8,17 @@ from django.core.exceptions import ObjectDoesNotExist from django.core.mail import send_mail from django.db.models import Q -from django.db.models.signals import post_save, pre_save +from django.db.models.signals import pre_save from django.dispatch import receiver from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render, reverse -from rest_framework.authentication import ( - BasicAuthentication, - SessionAuthentication, - TokenAuthentication, -) +from rest_framework.authentication import SessionAuthentication, TokenAuthentication from rest_framework.permissions import BasePermission, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.models import AppInstance +from apps.app_registry import APP_REGISTRY +from apps.models import BaseAppInstance, Subdomain from common.models import UserProfile from models.models import Model from projects.models import Project @@ -33,7 +29,21 @@ logger = get_logger(__name__) +def disable_for_loaddata(signal_handler: Callable[..., Any]) -> Callable[..., Any]: + """ + Decorator that turns off signal handlers when loading fixture data. + """ + + def wrapper(*args: Any, **kwargs: Any) -> Any: + if kwargs.get("raw", False): + return + return signal_handler(*args, **kwargs) + + return wrapper + + @receiver(pre_save, sender=User) +@disable_for_loaddata def set_new_user_inactive(sender: Model, instance: User, **kwargs: dict[str, Any]) -> None: if instance._state.adding and settings.INACTIVE_USERS and not instance.is_superuser: logger.info("Creating Inactive User") @@ -54,19 +64,29 @@ def has_permission(self, request: Response, view: object) -> bool: """ Should simply return, or raise a 403 response. """ + release = request.GET.get("release", None) try: - release = request.GET.get("release") - app_instance = AppInstance.objects.filter(parameters__contains={"release": release}).last() - project = app_instance.project + # Must fetch the subdomain and reverse to the related model. + subdomain = Subdomain.objects.get(subdomain=release) + instance = BaseAppInstance.objects.filter(subdomain=subdomain).last() + project = instance.project # TODO: Make it an explicit exception. At least catch `Exception` except: # noqa: E722 project_slug = request.GET.get("project") project = Project.objects.get(slug=project_slug) return cast(bool, request.user.has_perm("can_view_project", project)) - if app_instance.access == "private": - return cast(bool, app_instance.owner == request.user) - elif app_instance.access == "project": + model_class = APP_REGISTRY.get_orm_model(instance.app.slug) + if model_class is None: + return False + instance = getattr(instance, model_class.__name__.lower()) + access = getattr(instance, "access", None) + + if access is None: + return False + elif instance.access == "private": + return cast(bool, instance.owner == request.user) + elif instance.access == "project": return cast(bool, request.user.has_perm("can_view_project", project)) else: return True diff --git a/templates/apps/create.html b/templates/apps/create.html deleted file mode 100644 index a32c9a32f..000000000 --- a/templates/apps/create.html +++ /dev/null @@ -1,511 +0,0 @@ -{% extends 'base.html' %} -{% block title %}Create {{ app.name }}{% endblock %} -{% load static %} -{% load custom_tags %} - -{% block content %} - - -

Create {{ app.name }}

-
-
- - {% if app.slug == 'customapp' %} -

This form allows you to start hosting a custom app that fulfills certain requirements on SciLifeLab Serve (the app itself can be built on any framework). Please read our documentation page on custom apps for the list of requirements and step-by-step instructions on deployment.

- {% elif app.slug == 'dashapp' %} -

This form allows you to start hosting a Dash app at SciLifeLab Serve. Please read our documentation page on Dash apps for step-by-step instructions.

- {% elif app.slug in 'shinyapp,shinyproxyapp' %} -

This form allows you to start hosting a Shiny app at SciLifeLab Serve. Please read our documentation page on Shiny apps for step-by-step instructions.

- {% elif app.slug == 'tissuumaps' %} -

This form allows you to start hosting your own TissUUmaps instance at SciLifeLab Serve. After your app has been created you will need to upload the data to your project. We suggest that you create the app with Permissions initially set to private, upload the data, and then edit the app settings to change the Permissions to public. Please read our documentation page on TissUUmaps apps for step-by-step instructions.

- {% endif %} - {% if app.slug in 'jupyter-lab,rstudio,vscode' %} -

Note that after 7 days the created {{ app.name }} instance will be deleted, only the files saved in 'project-vol' will stay available.

-

Each {{ app.name }} instance can get access to the persistent volume (folder) associated with this project, called 'project-vol'. Please make sure to save all your data files, script files, output from computations, etc. inside 'project-vol'; the files located elsewhere can be deleted at any point. The files saved inside 'project-vol' will be available across all instances of {{ app.name }} (as well as other apps) within this project.

- - {% endif %} -
-
-
-
-
- -
- {% csrf_token %} - - {% if app.slug == "filemanager" %} - - -
-

You are about to activate file manager on SciLifeLab Serve. You can use it to upload or download files to a volume associated with this project. This service will be active for 24 hours and automatically terminated afterwards. The uploaded files will stay on the volume even after this service has been terminated.

-

Click 'Activate' to activate file manager

-
-
- {% else %} -
- {% endif %} - - - -
- - - - -
- Please add a valid name! -
-
- {% if do_display_description_field %} -
- - - - -
- Please add a description of the app! -
-
- {% endif %} - - {% if do_display_description_field or request.user.is_superuser %} -
- - - -
- - -
-
- - {% endif %} - - - {% if form.dep_permissions %} -
- - - - - -
- {% endif %} - - {% if app.slug in 'customapp,dashapp,shinyapp,shinyproxyapp' %} -
- - - - -
- Please add a valid URL! -
-
- {% endif %} - - {% if form.dep_model %} -
- - - - -
- {% endif %} - - {% if form.dep_S3 %} -
- - -
- {% endif %} - - {% if form.dep_flavor %} -
- - - - -
- {% endif %} - - {% if form.dep_environment %} - {% if form.environments.objs.count > 0 %} -
- - -
- {% endif %} - {% endif %} - - {% if form.dep_apps %} - {% for app_name, appinstances in form.app_deps.items %} -
- - -
- {% endfor %} - {% endif %} - - {% for key, vals in form.primitives.items %} - -
{{ vals.meta_title }}
- {% for subkey, subval in vals.items %} - {% if subval.type != "boolean" and subkey == "image" or subkey == "port" or subkey == "userid" or subkey == "proxyheartbeatrate" or subkey == "proxyheartbeattimeout" or subkey == "proxycontainerwaittime" %} -
- {% else %} -
- {% endif %} - {% if subval.type == "boolean" %} - - - {% endif %} - - {% if subval.type == "string" %} - - {% if subkey == "image" %} - - - -
- Please add a valid image name! -
- {% elif subkey == "path" %} - - - {% if request.user.is_superuser %} - - -
- Please add a valid path! -
- {% else %} -
- /home/ - -
- Please add a valid path! -
-
- {% endif %} - - {% else %} - - {% endif %} - {% endif %} - - {% if subval.type == "password" %} - - - {% endif %} - - {% if subval.type == "textfield" %} - - - {% endif %} - - {% if subval.type == "number" %} - {% if subkey == "port" or subkey == "userid" or subkey == "proxyheartbeatrate" or subkey == "proxyheartbeattimeout" or subkey == "proxycontainerwaittime" %} - {% if subkey == "port" %} - - - - -
- Please add a valid port! -
- {% elif subkey == "userid" and request.user.is_superuser %} - - - - -
- Please add a valid User ID between 999 and 1010! -
- {% elif subkey == "proxyheartbeatrate" and request.user.is_superuser %} - - - - -
- Please add a number, needs to be above 1. -
- {% elif subkey == "proxyheartbeattimeout" and request.user.is_superuser %} - - - - -
- Please add a number. Cannot be lower than 0 except for '-1' to set no timeout. -
- {% elif subkey == "proxycontainerwaittime" and request.user.is_superuser %} - - - - -
- Please add a number, needs to be above 20000. -
- {% endif %} - {% else %} - - {% endif %} - {% endif %} - - {% if subval.type == "select" %} - - - - - - {% endif %} - -
- {% endfor %} - - {% endfor %} - - {% if form.dep_appobj %} -
- - -
- {% endif %} -
- - - - -
-
-
- -{# In the custom app creation form, make the path a required field if a persistent volumet is selected #} -{% if app.slug == "customapp" %} - -{% endif %} - - - -{% endblock %} diff --git a/templates/apps/create_base.html b/templates/apps/create_base.html new file mode 100644 index 000000000..77c16c523 --- /dev/null +++ b/templates/apps/create_base.html @@ -0,0 +1,188 @@ +{% extends 'base.html' %} +{% block title %}{{ form_header }} {{ app.name }}{% endblock %} +{% load static %} +{% load custom_tags %} +{% load crispy_forms_tags %} + +{% block content %} + +{% include "breadcrumbs/bc_app_create.html" %} + +
+
+
+
+ {% if app_id %} +

Edit {{ form.instance.name }}

+ {% else %} +

Create {{ form.model_name }}

+

+ {% block app_info %} + {% endblock %} +

+ {% endif %} + +
+ {% crispy form %} +
+
+
+
+ + + + +{% endblock %} diff --git a/templates/apps/create_view.html b/templates/apps/create_view.html new file mode 100644 index 000000000..f2b52b42c --- /dev/null +++ b/templates/apps/create_view.html @@ -0,0 +1,19 @@ +{% extends "apps/create_base.html" %} + +{% block app_info %} + {% if app_slug == 'customapp' %} +

This form allows you to start hosting a custom app that fulfills certain requirements on SciLifeLab Serve (the app itself can be built on any framework). Please read our documentation page on custom apps for the list of requirements and step-by-step instructions on deployment.

+ {% elif app_slug == 'dashapp' %} +

This form allows you to start hosting a Dash app at SciLifeLab Serve. Please read our documentation page on Dash apps for step-by-step instructions.

+ {% elif app_slug in 'shinyapp,shinyproxyapp' %} +

This form allows you to start hosting a Shiny app at SciLifeLab Serve. Please read our documentation page on Shiny apps for step-by-step instructions.

+ {% elif app_slug == 'tissuumaps' %} +

This form allows you to start hosting your own TissUUmaps instance at SciLifeLab Serve. After your app has been created you will need to upload the data to your project. We suggest that you create the app with Permissions initially set to Private, upload the data, and then edit the app settings to change the Permissions to Public. Please read our documentation page on TissUUmaps apps for step-by-step instructions.

+ {% endif %} + {% if app_slug in 'jupyter-lab,rstudio,vscode' %} +

Note that after 7 days the created {{ form.model_name }} instance will be deleted, only the files saved in 'project-vol' will stay available.

+

Each {{ form.model_name }} instance can get access to the persistent volume (folder) associated with this project, called 'project-vol'. Please make sure to save all your data files, script files, output from computations, etc. inside 'project-vol'; the files located elsewhere can be deleted at any point. The files saved inside 'project-vol' will be available across all instances of {{ form.model_name }} (as well as other apps) within this project.

+ + {% endif %} + +{% endblock%} diff --git a/templates/apps/custom_field.html b/templates/apps/custom_field.html new file mode 100644 index 000000000..a296c1dda --- /dev/null +++ b/templates/apps/custom_field.html @@ -0,0 +1,25 @@ +
+
+ + + +
+ +
+ {% if spinner %} +
+ +
+ {% endif %} + {{ field }} + + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+
diff --git a/templates/apps/logs.html b/templates/apps/logs.html index 1e17ea3a0..55783c3c8 100644 --- a/templates/apps/logs.html +++ b/templates/apps/logs.html @@ -1,31 +1,25 @@ {% extends 'base.html' %} -{% block title %}Logs - {{ app.name }}{% endblock %} +{% block title %}Logs - {{ instance.name }}{% endblock %} {% load static %} {% block content %} - +{% include "breadcrumbs/bc_logs.html" %}
-

{{ app.name }} Logs

{{ app.status.latest.status_type }}
+

{{ instance.name }} Logs

{{ instance.status.latest.status_type }}
-

Note: Logs appear a few minutes after an app has been launched. The Status on the top right is an indication of the state of the app. {% if .sluapp.appg == 'customapp' and app.app_dependencies.all %} If the app is not running (due to an error) and you have a volume attached to the app, then you can switch between to the tabs below to see logs for the data copy process. This can give you hints if data copy failed.{% endif %}

+

Note: Logs appear a few minutes after an app has been launched. The Status on the top right is an indication of the state of the app. {% if instance.app.slug == 'customapp' and instance.volume %} If the app is not running (due to an error) and you have a volume attached to the app, then you can switch between to the tabs below to see logs for the data copy process. This can give you hints if data copy failed.{% endif %}

@@ -46,10 +40,9 @@

{{ app.name }} Logs

{{ app.name }} Logs
diff --git a/templates/apps/partials/subdomain_input_group.html b/templates/apps/partials/subdomain_input_group.html new file mode 100644 index 000000000..20c3e1b56 --- /dev/null +++ b/templates/apps/partials/subdomain_input_group.html @@ -0,0 +1,25 @@ +{% load get_setting %} +
+ + + {% if type == "select" %} + {% include "apps/partials/subdomain_select.html" %} + {% else %} + {% include "apps/partials/subdomain_input.html" %} + {% endif %} + +
+ .{% get_setting "DOMAIN" %} +
+
diff --git a/templates/apps/partials/subdomain_select.html b/templates/apps/partials/subdomain_select.html new file mode 100644 index 000000000..fd78316c9 --- /dev/null +++ b/templates/apps/partials/subdomain_select.html @@ -0,0 +1,13 @@ + diff --git a/templates/apps/update.html b/templates/apps/update.html deleted file mode 100644 index 6c5f863b7..000000000 --- a/templates/apps/update.html +++ /dev/null @@ -1,601 +0,0 @@ -{% extends 'base.html' %} -{% block title %}Settings - {{ app.name }}{% endblock %} -{% load static %} -{% load custom_tags %} - -{% block content %} - - - -

Settings {{ app.name }}

-
-
- {% if app.slug == 'customapp' %} -

This form allows you to host an app that fulfills certain requirements on SciLifeLab Serve (the app itself can be built on any framework). Please read our documentation page on custom apps for the list of requirements and step-by-step instructions on deployment.

- {% elif app.slug == 'dashapp' %} -

This form allows you to host a Dash app at SciLifeLab Serve. Please read our documentation page on Dash apps for step-by-step instructions.

- {% elif app.slug in 'shinyapp,shinyproxyapp' %} -

This form allows you to host a Shiny app at SciLifeLab Serve. Please read our documentation page on Shiny apps for step-by-step instructions.

- {% elif app.slug == 'tissuumaps' %} -

You are editing a TissUUmaps app. Remember to upload the data to your project and then edit the app settings to change the Permissions to public. Please read our documentation page on TissUUmaps apps for step-by-step instructions.

- {% endif %} - {% if app.slug in 'jupyter-lab,rstudio,vscode' %} -

Note that after 7 days the created {{ app.name }} instance will be deleted, only the files saved in 'project-vol' will stay available.

-

Each {{ app.name }} instance can get access to the persistent volume (folder) associated with this project, called 'project-vol'. Please make sure to save all your data files, script files, output from computations, etc. inside 'project-vol'; the files located elsewhere can be deleted at any point. The files saved inside 'project-vol' will be available across all instances of {{ app.name }} (as well as other apps) within this project.

- {% endif %} -
-
- -
-
-
-
- {% csrf_token %} -
- -
- - - - -
- Please add a valid name! -
-
- - {% if do_display_description_field %} -
- - - - -
- Please add a description of the app! -
-
- {% endif %} - {% if do_display_description_field or request.user.is_superuser %} -
- - - -
- - -
-
- - {% endif %} - {% if form.dep_permissions %} -
- - - - - - -
- {% endif %} - - {% if app.slug in 'customapp,dashapp,shinyapp,shinyproxyapp' %} -
- - - - -
- Please add a valid URL! -
-
- {% endif %} - - {% if form.dep_model %} -
- - - - -
- {% endif %} - - {% if form.dep_S3 %} -
- - -
- {% endif %} - - {% if form.dep_flavor %} -
- - - - -
- {% endif %} - - {% if form.dep_environment %} - {% if form.environments.objs.count > 0 %} -
- - -
- {% endif %} - {% endif %} - - {% if form.dep_apps %} - {% for app_name, appinstances in form.app_deps.items %} -
- - -
- {% endfor %} - {% endif %} - - - {% for key, vals in form.primitives.items %} - -
{{ vals.meta_title }}
- {% for subkey, subval in vals.items %} - {% if subval.type != "boolean" and subkey == "image" or subkey == "port" or subkey == "userid" or subkey == "proxyheartbeatrate" or subkey == "proxyheartbeattimeout" or subkey == "proxycontainerwaittime" %} -
- {% else %} -
- {% endif %} - {% if subval.type == "boolean" %} - - - {% endif %} - - {% if subval.type == "string" %} - - {% if subkey == "image" %} - - - -
- Please add a valid image name! -
- {% elif subkey == "path" %} - - - {% if request.user.is_superuser and created_by_admin %} - -
- Please add a valid path! -
- {% elif created_by_admin %} -
- -
- {% else %} -
- /home/ - -
- Please add a valid path! -
-
- {% endif %} - - {% else %} - - {% endif %} - {% endif %} - - {% if subval.type == "textfield" %} - - - {% endif %} - - {% if subval.type == "password" %} - - -
- - - - -
- {% endif %} - - {% if subval.type == "number" %} - {% if subkey == "port" or subkey == "userid" or subkey == "proxyheartbeatrate" or subkey == "proxyheartbeattimeout" or subkey == "proxycontainerwaittime" %} - {% if subkey == "port" %} - - - - -
- Please add a valid port! -
- {% elif subkey == "userid" and request.user.is_superuser %} - - - - -
- Please add a valid User ID between 999 and 1010! -
- {% elif subkey == "proxyheartbeatrate" and request.user.is_superuser %} - - - - -
- Please add a number, needs to be above 1. -
- {% elif subkey == "proxyheartbeattimeout" and request.user.is_superuser %} - - - - -
- Please add a number. Cannot be lower than 0 except for '-1' to set no timeout. -
- {% elif subkey == "proxycontainerwaittime" and request.user.is_superuser %} - - - - -
- Please add a number, needs to be above 20000. -
- {% endif %} - {% else %} - - {% endif %} - {% endif %} - - - {% if subval.type == "select" %} - - - - - - {% endif %} - - {% if subval.type == "minio-username" %} - - - {% endif %} - - {% if subval.type == "minio-password" %} - - - {% endif %} - -
- {% endfor %} - - {% endfor %} - - {% if form.dep_appobj %} -
- - -
- {% endif %} -
- - -
-
-
- -{% if app.category.pk == 'serve' %} -
- -
- -
-

Tags

-

Add relevant keywords to help users find your app in our catalog of public apps.

- -
- - {% csrf_token %} - -
-
- - {% include 'common/autocomplete.html' with str_list=all_tags id_suffix="tags" name="tag" required=True %} - -
-
- -
-
- -
-
-
- -
- -
- -
- {% with appinstance.tags|split:"," as tags %} - {% for tag in tags %} -
-
- {% csrf_token %} - - {{tag}} - -
-
- {% endfor %} - - {% endwith %} -
-
-
-
-{% endif %} -
- -{% if app.slug == "customapp" %} - -{% endif %} - - -{% endblock %} diff --git a/templates/breadcrumbs/bc_app_create.html b/templates/breadcrumbs/bc_app_create.html new file mode 100644 index 000000000..eaf7b4907 --- /dev/null +++ b/templates/breadcrumbs/bc_app_create.html @@ -0,0 +1,10 @@ +{% extends "breadcrumbs/breadcrumb_base.html" %} + +{% block breadcrumb_content %} +{% if app_id %} + +{% else %} + +{% endif %} + +{% endblock %} diff --git a/templates/breadcrumbs/bc_logs.html b/templates/breadcrumbs/bc_logs.html new file mode 100644 index 000000000..00641fa54 --- /dev/null +++ b/templates/breadcrumbs/bc_logs.html @@ -0,0 +1,5 @@ +{% extends "breadcrumbs/breadcrumb_base.html" %} + +{% block breadcrumb_content %} + +{% endblock %} diff --git a/templates/breadcrumbs/bc_project_create.html b/templates/breadcrumbs/bc_project_create.html new file mode 100644 index 000000000..724eb1296 --- /dev/null +++ b/templates/breadcrumbs/bc_project_create.html @@ -0,0 +1,6 @@ +{% extends "breadcrumbs/breadcrumb_base.html" %} + +{% block breadcrumb_content %} + + +{% endblock %} diff --git a/templates/breadcrumbs/bc_project_overview.html b/templates/breadcrumbs/bc_project_overview.html new file mode 100644 index 000000000..eeb5beafd --- /dev/null +++ b/templates/breadcrumbs/bc_project_overview.html @@ -0,0 +1 @@ +{% extends "breadcrumbs/breadcrumb_base.html" %} diff --git a/templates/breadcrumbs/breadcrumb_base.html b/templates/breadcrumbs/breadcrumb_base.html new file mode 100644 index 000000000..74f003b16 --- /dev/null +++ b/templates/breadcrumbs/breadcrumb_base.html @@ -0,0 +1,10 @@ + diff --git a/templates/collections/collection.html b/templates/collections/collection.html index 65799bc2b..bb51944dc 100644 --- a/templates/collections/collection.html +++ b/templates/collections/collection.html @@ -8,7 +8,7 @@ diff --git a/templates/common/app_card copy.html b/templates/common/app_card copy.html new file mode 100644 index 000000000..aaa38ea31 --- /dev/null +++ b/templates/common/app_card copy.html @@ -0,0 +1,119 @@ +{% load static %} +{% load custom_tags %} + +
+
+
+
+
{{ app.name }}
+
+
+ {% static 'images/logos/apps/' as static_url %} + App Logo +
+
+ +
+

+ {% if app.description|length > 349 %} + {{ app.description|slice:':349'}}... + + {% else %} + {{ app.description|default_if_none:""}} + {% endif %} +

+
    +
  • +
    Owner:
    +
    {{ app.owner.first_name }} {{ app.owner.last_name }}
    +
  • +
+
+ +
+
+ {% if request.session.app_tags|exists:app.id %} + {% with app.tags as tags %} + {% for tag in tags %} + +
{{ tag }} +
+ {% endfor %} + + {% csrf_token %} + + + {% endwith %} + {% else %} + {% with app.tags as tags %} + + {% for tag in tags %} + +
{{ tag }} +
+ {% endfor %} + {% if tags.count > tag_limit %} + + {% csrf_token %} + + + {% endif %} + {% endwith %} + + {% endif %} +
+
+
+
+ {% if "Serve" in app.app.name or app.app.name == "Python Model Deployment" %} + Copy API Endpoint + {% else %} + Open + {% endif %} + {% if app.app.slug in 'shinyapp,shinyproxyapp,dashapp,customapp' %} + {% if app.pvc == None %} + + + + + + {% endif %} + {% endif %} + {% if app.source_code_url %} + + + + + + + {% endif %} +
+
+ {% if app.status_group == "success" %} + Running + {% else %} + Waiting + {% endif %} +
+
+ +
+
+ + +
diff --git a/templates/common/app_card.html b/templates/common/app_card.html index 2884200de..31cf09413 100644 --- a/templates/common/app_card.html +++ b/templates/common/app_card.html @@ -35,7 +35,7 @@
{{ app.name }}
{% if request.session.app_tags|exists:app.id %} - {% with app.tags|split:"," as tags %} + {% with app.tags.all as tags %} {% for tag in tags %} @@ -50,7 +50,7 @@
{{ app.name }}
{% endwith %} {% else %} - {% with app.tags|split:"," as tags %} + {% with app.tags.all as tags %} {% with tags|count_str as tag_limit %} {% for tag in tags|slice:tag_limit %} {{ app.name }}
{% if "Serve" in app.app.name or app.app.name == "Python Model Deployment" %} - Copy API Endpoint + Copy API Endpoint {% else %} - Open + Open {% endif %} {% if app.app.slug in 'shinyapp,shinyproxyapp,dashapp,customapp' %} {% if app.pvc == None %} diff --git a/templates/common/common_head.html b/templates/common/common_head.html index b288dc4cb..2a52cc043 100644 --- a/templates/common/common_head.html +++ b/templates/common/common_head.html @@ -27,3 +27,4 @@ + diff --git a/templates/common/footer.html b/templates/common/footer.html index d5c942e4b..c1aee30ff 100644 --- a/templates/common/footer.html +++ b/templates/common/footer.html @@ -24,7 +24,7 @@
  • Home
  • Public apps
  • Public models
  • -
  • Collections
  • +
  • Collections
  • User guide
  • Teaching
  • About
  • diff --git a/templates/portal/home.html b/templates/portal/home.html index 1ba7e7ea9..7e5072513 100644 --- a/templates/portal/home.html +++ b/templates/portal/home.html @@ -168,7 +168,7 @@

    Collections {% if link_all_collections %} {% endif %}

    @@ -187,7 +187,7 @@

    News
    - +
    diff --git a/templates/projects/categories/develop.html b/templates/projects/categories/develop.html new file mode 100644 index 000000000..d121a40d4 --- /dev/null +++ b/templates/projects/categories/develop.html @@ -0,0 +1,13 @@ +{% extends 'projects/partials/category_card_base.html' %} + +{% block top_content %} + +{% endblock %} + +{% block bottom_content %} + +
    +*Note that all apps under Develop will be deleted 7 days after creation. +
    + +{% endblock%} diff --git a/templates/projects/categories/manage_files copy.html b/templates/projects/categories/manage_files copy.html new file mode 100644 index 000000000..458b81a78 --- /dev/null +++ b/templates/projects/categories/manage_files copy.html @@ -0,0 +1,61 @@ +
    +
    +
    +
    Manage Files
    +
    + {% if filemanager_instance %} + Status: + + {{ filemanager_instance.status.latest.status_type }} + + {% endif %} +
    +
    + {% if filemanager_instance %} +
    +
    + +

    File Manager is activated and will be closed in {{ hours }} hours

    +

    You can reach the manager by clicking the button below

    + + Open File Manager + +
    + +
    + {% else %} + + +
    +
    +
    +
    +
    +
    +
    +
    Activate File Manager
    +
    +
    +
    + {% if filemanager_instance %} + + {% else %} + Activate + {% endif %} + +
    +
    +
    +
    + App Logo +
    +
    +
    +
    +
    +
    + {% endif %} +
    +
    diff --git a/templates/projects/categories/manage_files.html b/templates/projects/categories/manage_files.html new file mode 100644 index 000000000..4e8acb272 --- /dev/null +++ b/templates/projects/categories/manage_files.html @@ -0,0 +1,13 @@ +{% extends 'projects/partials/category_card_base.html' %} + +{% block top_content %} + +{% endblock %} + +{% block bottom_content %} + +
    +*Note that the file manager will be deleted after 24 hours. +
    + +{% endblock%} diff --git a/templates/projects/categories/models.html b/templates/projects/categories/models.html new file mode 100644 index 000000000..9e89448a2 --- /dev/null +++ b/templates/projects/categories/models.html @@ -0,0 +1,137 @@ + +{% if project.project_template.pk == 1 %} +
    +
    +
    +
    Models
    +
    + {% if models %} + +
    + +{% endif %} diff --git a/templates/projects/categories/serve.html b/templates/projects/categories/serve.html new file mode 100644 index 000000000..81369f5aa --- /dev/null +++ b/templates/projects/categories/serve.html @@ -0,0 +1,9 @@ +{% extends 'projects/partials/category_card_base.html' %} + +{% block content %} + +
    +*Add specifics here +
    + +{% endblock%} diff --git a/templates/projects/overview.html b/templates/projects/overview.html index 05c2c14f3..d85ba12a7 100644 --- a/templates/projects/overview.html +++ b/templates/projects/overview.html @@ -1,6 +1,7 @@ {% extends 'base.html' %} + {% load static %} -{% load can_create_app %} + {% load custom_tags %} {% block title %}{{ project.name }}{% endblock %} @@ -8,575 +9,37 @@ {% if project.status == "active" %} - - -
    -

    {{ project.name }}

    - - - Settings - -
    - -
    -
    - {% include 'common/flash_messages.html' %} -
    -
    - -
    -
    - {% if project.description %} -

    Description: {{ project.description }}

    - {% endif %} -

    Project owner: {{ project.owner.email }}

    - {% if project.authorized.all %} -

    Project members: {{ project.authorized.all|join:"; " }}

    - {% endif %} -
    -
    - - - -
    - {% for objs in resources %} - {% if objs.title == "Manage Files" and not request.user.is_superuser or objs.title == "Additional options [admins only]" and not request.user.is_superuser %} - {% else %} -
    -
    -
    -
    {{ objs.title }}
    -
    - {% if objs.objs %} - - - {% if objs.title == "Develop" %} -
    - *Note that all apps under Develop will be deleted 7 days after creation. -
    - {% endif %} - {% else %} -
    -

    No instances.

    -
    + {% if objs.title == "Develop" and objs.apps %} + {% include "projects/categories/develop.html" %} {% endif %} -
    - {% for app in objs.apps %} - {% if project.project_template.pk == 1 and app.slug in 'mlflow,mlflow-serve,pytorch-serve,tensorflow-serve,python-serve' %} - - {% elif app.slug == "shinyapp" and not request.user.is_superuser %} - - {% else %} -
    -
    -
    -
    -
    -
    -
    {{ app.name }}
    -
    -
    -
    -
    -
    -

    {{ app.description }}

    -
    -
    -
    -
    - {% can_create_app request.user project app as can_create %} - - {% if can_create %} - - - {% if "Serv" in app.name or app.name == "Python Model Deployment" %} - Create - {% else %} - - Create - - - {% endif %} - - - {% else %} - - {% endif %} -
    -
    -
    -
    -
    -
    - {% static 'images/logos/apps/' as static_url %} - App Logo -
    -
    -
    - {% if "Serv" in app.name or app.name == "Python Model Deployment" %} -
    -

    {{ app.name }} will be available soon

    -
    - {% endif %} -
    -
    - {% endif %} - {% endfor %} -
    -
    -
    - {% endif %} - {% endfor %} - - {% if project.project_template.pk == 2 %} -
    -
    -
    -
    Models
    -
    - {% if models %} - -
    - - {% endif %} - - - - -
    -
    -
    -
    Manage Files
    -
    - {% if filemanager_instance %} - Status: - - {{ filemanager_instance.status.latest.status_type }} - - {% endif %} -
    -
    - {% if filemanager_instance %} -
    -
    - -

    File Manager is activated and will be closed in {{ hours }} hours

    -

    You can reach the manager by clicking the button below

    - - Open File Manager - -
    - -
    - {% else %} - -
    -
    -
    -
    -
    -
    -
    -
    Activate File Manager
    -
    -
    -
    - {% if filemanager_instance %} - - {% else %} - Activate - {% endif %} - -
    -
    -
    -
    - App Logo -
    -
    -
    -
    -
    -
    + {% if objs.title == "Serve" and objs.apps %} + {% include "projects/categories/serve.html" %} {% endif %} -
    -
    -
    - - -
    - {% else %} + {% if objs.title == "Manage Files" and objs.apps %} + {% include "projects/categories/manage_files.html" %} + {% endif %} -
    -
    + {% if objs.title == "Additional options [admins only]" and objs.apps %} + {% include "projects/categories/manage_files.html" %} + {% endif %} + {% endfor %} - {% include 'common/loader.html' %} -
    +{% endif %} - - - {% endif %} - - - +{% include "projects/partials/scripts.html" %} - {% endblock %} +{% endblock %} diff --git a/templates/projects/partials/app_instances_table.html b/templates/projects/partials/app_instances_table.html new file mode 100644 index 000000000..62e020846 --- /dev/null +++ b/templates/projects/partials/app_instances_table.html @@ -0,0 +1,114 @@ +{% load static %} + + + + + + + + + + + + + + + {% for instance in objs.instances %} + + + {% static 'images/logos/apps/' as static_url %} + + + {% if instance.url %} + + {% else %} + + {% endif %} + + + + + + + {% endfor %} + + diff --git a/templates/projects/partials/app_templates.html b/templates/projects/partials/app_templates.html new file mode 100644 index 000000000..fddc7f4e9 --- /dev/null +++ b/templates/projects/partials/app_templates.html @@ -0,0 +1,68 @@ +{% load can_create_app %} +{% load static %} + +
    + {% for app in objs.apps %} + +
    +
    +
    +
    +
    +
    +
    {{ app.name }}
    +
    +
    +
    +
    +
    +

    {{ app.description }}

    +
    +
    +
    +
    + {% can_create_app request.user project app as can_create %} + + {% if can_create %} + + + {% if "Serv" in app.name or app.name == "Python Model Deployment" %} + Create + {% else %} + + Create + + + {% endif %} + + + {% else %} + + {% endif %} +
    +
    +
    +
    +
    +
    + {% static 'images/logos/apps/' as static_url %} + App Logo +
    +
    +
    + {% if "Serv" in app.name or app.name == "Python Model Deployment" %} +
    +

    {{ app.name }} will be available soon

    +
    + {% endif %} +
    +
    + {% endfor %} +
    diff --git a/templates/projects/partials/category_card_base.html b/templates/projects/partials/category_card_base.html new file mode 100644 index 000000000..e35225bb2 --- /dev/null +++ b/templates/projects/partials/category_card_base.html @@ -0,0 +1,28 @@ +
    +
    +
    +
    {{ objs.title }}
    +
    + + {% block top_content %} + + {% endblock %} + + {% if objs.instances %} + + + {% block bottom_content %} + {% endblock %} + + {% else %} +
    +

    No instances.

    +
    + {% endif %} + + {% include "projects/partials/app_templates.html" %} +
    +
    diff --git a/templates/projects/partials/project_description.html b/templates/projects/partials/project_description.html new file mode 100644 index 000000000..7cf2bacf7 --- /dev/null +++ b/templates/projects/partials/project_description.html @@ -0,0 +1,11 @@ +
    +
    + {% if project.description %} +

    Description: {{ project.description }}

    + {% endif %} +

    Project owner: {{ project.owner.email }}

    + {% if project.authorized.all %} +

    Project members: {{ project.authorized.all|join:"; " }}

    + {% endif %} +
    +
    diff --git a/templates/projects/partials/project_header.html b/templates/projects/partials/project_header.html new file mode 100644 index 000000000..15c944f45 --- /dev/null +++ b/templates/projects/partials/project_header.html @@ -0,0 +1,7 @@ +
    +

    {{ project.name }}

    + + + Settings + +
    diff --git a/templates/projects/partials/scripts.html b/templates/projects/partials/scripts.html new file mode 100644 index 000000000..27f34066b --- /dev/null +++ b/templates/projects/partials/scripts.html @@ -0,0 +1,118 @@ +{% if project.status == "active" %} + + +
    +{% else %} + +
    +
    + + {% include 'common/loader.html' %} +
    +
    + + + +{% endif %} diff --git a/templates/projects/project_create.html b/templates/projects/project_create.html index 6fca6fb99..f02e01d15 100644 --- a/templates/projects/project_create.html +++ b/templates/projects/project_create.html @@ -3,12 +3,7 @@ {% block title %}New project{% endblock %} {% block content %} - +{% include "breadcrumbs/bc_project_create.html" %}
    diff --git a/templates/projects/project_templates.html b/templates/projects/project_templates.html index a9d5e54c4..222097fe5 100644 --- a/templates/projects/project_templates.html +++ b/templates/projects/project_templates.html @@ -24,7 +24,7 @@

    New project

    -
    +
    @@ -57,9 +57,14 @@

    diff --git a/templates/projects/settings.html b/templates/projects/settings.html index caa45fc71..69e10861d 100644 --- a/templates/projects/settings.html +++ b/templates/projects/settings.html @@ -32,14 +32,6 @@

    Project settings

    Access {% if enable_extra_settings or request.user.is_superuser %} - - S3 storage - - - MLFlow - Flavors @@ -220,69 +212,8 @@
    Environments
    -
    -
    - -
    -
    Default S3 storage
    -
    - -
    -
    - {% csrf_token %} -
    - - -
    - -
    -
    -
    -
    - -
    -
    - -
    -
    Default MLFlow Server
    -
    - -
    -
    - {% csrf_token %} - -
    - - -
    - - -
    -
    -
    -
    {% endif %} {% if request.user.pk == project.owner.pk or request.user.is_superuser %}