diff --git a/.dockerignore b/.dockerignore index 3694f75f..07c1e87d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,6 @@ !manage.py !pyproject.toml !poetry.lock -!dune_processes +!drunc_ui !db db/* diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index 8ba6567c..c266e192 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -16,3 +16,4 @@ jobs: with: use-quiet-mode: yes use-verbose-mode: yes + config-file: .markdown-link-check.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e45330a3..9c4ec723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,4 +31,11 @@ jobs: run: poetry install - name: Run tests - run: poetry run pytest + run: poetry run pytest --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml deleted file mode 100644 index 2750e6a0..00000000 --- a/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: Run pre-commit hooks - -on: - push: - branches: [main] - pull_request: - -jobs: - pre-commit: - runs-on: [ubuntu-latest] - steps: - - uses: actions/checkout@v4 - - uses: pre-commit/action@v3.0.1 - - uses: pre-commit-ci/lite-action@v1.0.3 - if: always() diff --git a/.github/workflows/pre-commit_autoupdate.yml b/.github/workflows/pre-commit_autoupdate.yml deleted file mode 100644 index 5e6fb1f4..00000000 --- a/.github/workflows/pre-commit_autoupdate.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Pre-commit auto-update - -on: - schedule: - - cron: 0 0 * * 1 # midnight every Monday - -jobs: - auto-update: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - - uses: browniebroke/pre-commit-autoupdate-action@main - - uses: peter-evans/create-pull-request@v7 - with: - token: ${{ secrets.GITHUB_TOKEN }} - branch: update/pre-commit-hooks - title: Update pre-commit hooks - commit-message: 'chore: update pre-commit hooks' - body: Update versions of pre-commit hooks to latest version. diff --git a/.gitignore b/.gitignore index b6e47617..0b7189dd 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# VSCode settings +.vscode/settings.json diff --git a/.markdown-link-check.json b/.markdown-link-check.json new file mode 100644 index 00000000..007ab75d --- /dev/null +++ b/.markdown-link-check.json @@ -0,0 +1,5 @@ +{ + "ignorePatterns": [ + ".*localhost.*" + ] +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 06f1ca0f..eb9f4d75 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,12 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-merge-conflict - id: debug-statements - id: trailing-whitespace - id: end-of-file-fixer + exclude: .*/static/.*/js/.* - id: pretty-format-json args: [--autofix, --indent, '4', --no-sort] - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks @@ -14,17 +15,17 @@ repos: - id: pretty-format-yaml args: [--autofix, --indent, '2', --offset, '2'] - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.29.2 + rev: 0.29.4 hooks: - id: check-github-workflows - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.4 + rev: v0.6.9 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.41.0 + rev: v0.42.0 hooks: - id: markdownlint-fix - repo: https://github.com/codespell-project/codespell @@ -32,3 +33,9 @@ repos: hooks: - id: codespell args: [-L, .codespell_ignore.txt] + exclude: .*/static/.*/js/.* + - repo: https://github.com/djlint/djLint + rev: v1.35.2 + hooks: + - id: djlint-reformat-django + - id: djlint-django diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index d4817ccb..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "[python]": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "charliermarsh.ruff" - }, - "editor.rulers": [ - 88 - ], - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true -} diff --git a/README.md b/README.md index 4bb462d1..152dbc42 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,76 @@ -# dune_processes + +[![GitHub](https://img.shields.io/github/license/ImperialCollegeLondon/drunc_ui)](https://raw.githubusercontent.com/ImperialCollegeLondon/drunc_ui/main/LICENSE) +[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/ImperialCollegeLondon/drunc_ui/main.svg)](https://results.pre-commit.ci/latest/github/ImperialCollegeLondon/drunc_ui/main) +[![Test and build](https://github.com/ImperialCollegeLondon/drunc_ui/actions/workflows/ci.yml/badge.svg)](https://github.com/ImperialCollegeLondon/drunc_ui/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/ImperialCollegeLondon/drunc_ui/graph/badge.svg?token=PG0WTYF8EY)](https://codecov.io/gh/ImperialCollegeLondon/drunc_ui) -This repo defines the web app for the Dune Process Manager web interface. +# DUNE Run Control User Interface (drunc-ui) -## For developers +This repo defines the web interface for various drunc tools. Including: + +- The Processes Manager +- The Controller + +## Running the App + +_Note that this depends on a very large docker base image._ + +To run with a demo version of the drunc process manager, run it with docker compose: + +```bash +docker compose up +``` + +It can take a few moment for the services to boot but the application should then be +available in the browser at . Authentication is required to work +with the application so you need to create a user account to work with: + +```bash +docker compose exec app python manage.py createsuperuser +``` + +and follow the prompts. You should then be able to use the details you supplied to pass +the login screen. You can use the "boot" button on the main page to create simple +processes to experiment with. You can also do this via the command line: + +```bash +docker compose exec app python scripts/talk_to_process_manager.py +``` + +To boot a more realistic test session: + +```bash +docker compose exec drunc bash -c "source env.sh && drunc-process-manager-shell grpc://localhost:10054 boot test/config/test-session.data.xml test-session" +``` + +_Note that the above consumes several Gb of memory._ + +Once booted you can interact with the root controller via: + +```bash +docker compose exec drunc bash -c "source env.sh && drunc-controller-shell grpc://localhost:3333" +``` + +For details of working with the controller see the [drunc wiki]. + +Take the services down with `docker compose down` or by pressing Ctrl+C in the +corresponding terminal. + +[drunc wiki]: https://github.com/DUNE-DAQ/drunc/wiki/Controller + +## Development + +Working with the full functionality of the web application requires a number of services +to be started and to work in concert. The Docker Compose stack provides the required +services and is suitable for development and manual testing but is not suitable for +running QA (pre-commit) tooling or unit tests. The project directory is mounted into the +`app` service which allows the Django development server's auto-reload mechanism to +detect changes to local files and work as expected. + +It is recommended that you follow the below instructions on working with poetry to run +the project's QA tooling and Unit Tests. + +### Working with Poetry This is a Python application that uses [poetry](https://python-poetry.org) for packaging and dependency management. It also provides [pre-commit](https://pre-commit.com/) hooks @@ -32,44 +100,58 @@ To get started: pre-commit install ``` -1. Run the main app (this will not receive any data from the drunc process manager): +Pre-commit should now work as expected when making commits even without the need to have +an active poetry shell. You can also manually run pre-commit (e.g. `pre-commit run -a`) +and the unit tests with `pytest`. Remember you'll need to prefix these with `poetry run` +first if you don't have an active poetry shell. + +#### Running the web application with Poetry + +You can also start the web application though at a minimum this requires the drunc +process manager to be running. Note that drunc only works on Linux so this approach will +not work on any other platforms. See the next section on also working with +Kafka. Assuming you have an active poetry shell for all steps: + +1. Start the drunc shell: ```bash - python manage.py runserver + drunc-unified-shell --log-level debug ./data/process-manager-no-kafka.json ``` -### Running the App - -_Note that this depends on a very large docker base image._ +1. In another terminal, run the main app: -To run this with a demo version of the drunc process manager, run it with docker compose: + ```bash + python manage.py runserver + ``` -```bash -docker compose up -d -``` +1. As above you'll need to create a user to get past the login page: -Dummy processes can be sent to the server with the `scripts/talk_to_process_manager.py` script: + ```bash + python manage.py createsuperuser + ``` -```bash -docker compose exec app python scripts/talk_to_process_manager.py -``` +Note that if you boot any processes in the web application this will immediately die +with an exit code of 255. This is because the drunc shell requires an ssh server on +localhost in order to be able to run processes. In most cases this isn't very limiting. -To boot a more realistic test session: +#### Running the web application with Poetry and Kafka -```bash -docker compose exec drunc bash -c "source env.sh && drunc-process-manager-shell grpc://localhost:10054 boot test/config/test-session.data.xml test-session" -``` +In the event that you want to work with the full application without using Docker +Compose you must start the required components manually. Assuming you have an active +poetry shell for all steps. -_Note that the above consumes several Gb of memory._ +1. Start Kafka - See [Running drunc with pocket kafka]. -Once booted you can interact with the root controller via: +1. Start the drunc shell: + `drunc-unified-shell --log-level debug ./data/process-manager-pocket-kafka.json` -```bash -docker compose exec drunc bash -c "source env.sh && drunc-controller-shell grpc://localhost:3333" -``` +1. Start the application server: + `python manage.py runserver` -For details of working with the controller see the [drunc wiki]. +1. Start the Kafka consumer: + `python manage.py kafka_consumer --debug` -Take the servers down with `docker compose down`. +From here you should be able to see broadcast messages displayed at the top of the index +page on every refresh. -[drunc wiki]: https://github.com/DUNE-DAQ/drunc/wiki/Controller +[Running drunc with pocket kafka]: https://github.com/DUNE-DAQ/drunc/wiki/Running-drunc-with-pocket-kafka diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..aac9fa44 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,12 @@ +# Don't fail CI if coverage drops +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true + +ignore: + - drunc_ui/settings/_production.py diff --git a/controller/__init__.py b/controller/__init__.py new file mode 100644 index 00000000..b1b64566 --- /dev/null +++ b/controller/__init__.py @@ -0,0 +1 @@ +"""The controller app for the drunc_ui project.""" diff --git a/controller/admin.py b/controller/admin.py new file mode 100644 index 00000000..751c9757 --- /dev/null +++ b/controller/admin.py @@ -0,0 +1,3 @@ +"""Admin module for the controller app.""" + +# Register your models here. diff --git a/controller/apps.py b/controller/apps.py new file mode 100644 index 00000000..38baaf40 --- /dev/null +++ b/controller/apps.py @@ -0,0 +1,10 @@ +"""Apps module for the controller app.""" + +from django.apps import AppConfig + + +class ControllerConfig(AppConfig): + """The app config for the controller app.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "controller" diff --git a/controller/migrations/__init__.py b/controller/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/controller/models.py b/controller/models.py new file mode 100644 index 00000000..1873bc56 --- /dev/null +++ b/controller/models.py @@ -0,0 +1,3 @@ +"""Models module for the controller app.""" + +# Create your models here. diff --git a/controller/templates/controller/index.html b/controller/templates/controller/index.html new file mode 100644 index 00000000..69f65f25 --- /dev/null +++ b/controller/templates/controller/index.html @@ -0,0 +1,4 @@ +{% extends "main/base.html" %} +{% block title %} + Controller +{% endblock title %} diff --git a/controller/urls.py b/controller/urls.py new file mode 100644 index 00000000..2bf73a57 --- /dev/null +++ b/controller/urls.py @@ -0,0 +1,10 @@ +"""Urls module for the controller app.""" + +from django.urls import path + +from . import views + +app_name = "controller" +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/controller/views.py b/controller/views.py new file mode 100644 index 00000000..f85e30ce --- /dev/null +++ b/controller/views.py @@ -0,0 +1,11 @@ +"""Views module for the controller app.""" + +from django.contrib.auth.decorators import login_required +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render + + +@login_required +def index(request: HttpRequest) -> HttpResponse: + """View that renders the index/home page.""" + return render(request=request, template_name="controller/index.html") diff --git a/data/README.md b/data/README.md new file mode 100644 index 00000000..7e081cf2 --- /dev/null +++ b/data/README.md @@ -0,0 +1,9 @@ +# Data files + +## process-manager-pocket-kafka.json + +Process manager configuration file for use in local development with Kafka. + +## process-manager-no-kafka.json + +Process manager configuration file for use in local development without Kafka. diff --git a/data/process-manager-pocket-kafka.json b/data/process-manager-pocket-kafka.json new file mode 100644 index 00000000..5432175f --- /dev/null +++ b/data/process-manager-pocket-kafka.json @@ -0,0 +1,13 @@ +{ + "type": "ssh", + "name": "SSHProcessManager", + "command_address": "0.0.0.0:10054", + "authoriser": { + "type": "dummy" + }, + "broadcaster": { + "type": "kafka", + "kafka_address": "127.0.0.1:30092", + "publish_timeout": 2 + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 44bc8d87..6de95046 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: app: build: . command: - - sh + - bash - -c - | python manage.py migrate @@ -12,6 +12,10 @@ services: volumes: - .:/usr/src/app - db:/usr/src/app/db + environment: + - PROCESS_MANAGER_URL=drunc:10054 + depends_on: + - drunc drunc: build: ./drunc_docker_service/ command: @@ -20,8 +24,44 @@ services: - | /usr/sbin/sshd && source env.sh && - drunc-process-manager --log-level debug file://./data/process-manager-no-kafka.json + drunc-process-manager --log-level debug file:///process-manager-kafka.json expose: - 10054 + depends_on: + kafka: + condition: service_healthy + kafka: + image: bitnami/kafka:latest + environment: + - KAFKA_CFG_NODE_ID=0 + - KAFKA_CFG_PROCESS_ROLES=controller,broker + - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093 + - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT + - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=0@kafka:9093 + - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER + expose: + - 9092 + healthcheck: + test: timeout 5s kafka-cluster.sh cluster-id --bootstrap-server localhost:9092 + interval: 1s + timeout: 6s + retries: 20 + kafka_consumer: + build: . + command: + - bash + - -c + - | + python manage.py kafka_consumer --debug + environment: + - KAFKA_ADDRESS=kafka:9092 + volumes: + - .:/usr/src/app + - db:/usr/src/app/db + depends_on: + kafka: + condition: service_healthy + app: + condition: service_started volumes: db: diff --git a/drunc_docker_service/Dockerfile b/drunc_docker_service/Dockerfile index fa71f003..7931bc2f 100644 --- a/drunc_docker_service/Dockerfile +++ b/drunc_docker_service/Dockerfile @@ -18,7 +18,7 @@ RUN source /cvmfs/dunedaq.opensciencegrid.org/setup_dunedaq.sh && \ pip install git+https://github.com/DUNE-DAQ/drunc.git@v0.10.2 WORKDIR /basedir/fddaq-v5.1.0-a9/ -COPY process-manager-no-kafka.json data/ +COPY process-manager-no-kafka.json process-manager-kafka.json data/ RUN mkdir -p /root/.ssh && \ ssh-keygen -b 2048 -t rsa -f /root/.ssh/id_rsa -q -N "" && \ diff --git a/drunc_docker_service/process-manager-kafka.json b/drunc_docker_service/process-manager-kafka.json new file mode 100644 index 00000000..bfff8d6b --- /dev/null +++ b/drunc_docker_service/process-manager-kafka.json @@ -0,0 +1,13 @@ +{ + "type": "ssh", + "name": "SSHProcessManager", + "command_address": "0.0.0.0:10054", + "authoriser": { + "type": "dummy" + }, + "broadcaster": { + "type": "kafka", + "kafka_address": "kafka:9092", + "publish_timeout": 2 + } +} diff --git a/drunc_ui/__init__.py b/drunc_ui/__init__.py new file mode 100644 index 00000000..77d01269 --- /dev/null +++ b/drunc_ui/__init__.py @@ -0,0 +1 @@ +"""The main module for drunc_ui.""" diff --git a/dune_processes/asgi.py b/drunc_ui/asgi.py similarity index 70% rename from dune_processes/asgi.py rename to drunc_ui/asgi.py index c8dc97b5..0b8db6fd 100644 --- a/dune_processes/asgi.py +++ b/drunc_ui/asgi.py @@ -1,4 +1,4 @@ -"""ASGI config for dune_processes project. +"""ASGI config for drunc_ui project. It exposes the ASGI callable as a module-level variable named ``application``. @@ -10,6 +10,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dune_processes.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "drunc_ui.settings") application = get_asgi_application() diff --git a/drunc_ui/settings/__init__.py b/drunc_ui/settings/__init__.py new file mode 100644 index 00000000..866575ed --- /dev/null +++ b/drunc_ui/settings/__init__.py @@ -0,0 +1,3 @@ +"""Django settings for drunc_ui project.""" + +from .settings import * # noqa: F403 diff --git a/dune_processes/settings/_production.py b/drunc_ui/settings/_production.py similarity index 100% rename from dune_processes/settings/_production.py rename to drunc_ui/settings/_production.py diff --git a/dune_processes/settings/settings.py b/drunc_ui/settings/settings.py similarity index 79% rename from dune_processes/settings/settings.py rename to drunc_ui/settings/settings.py index 169bace8..1bd9130e 100644 --- a/dune_processes/settings/settings.py +++ b/drunc_ui/settings/settings.py @@ -1,4 +1,4 @@ -"""Django settings for dune_processes project. +"""Django settings for drunc_ui project. Generated by 'django-admin startproject' using Django 5.1. @@ -9,8 +9,11 @@ https://docs.djangoproject.com/en/5.1/ref/settings/ """ +import os from pathlib import Path +import django_stubs_ext + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -48,7 +51,7 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = "dune_processes.urls" +ROOT_URLCONF = "drunc_ui.urls" TEMPLATES = [ { @@ -66,7 +69,7 @@ }, ] -WSGI_APPLICATION = "dune_processes.wsgi.application" +WSGI_APPLICATION = "drunc_ui.wsgi.application" # Database @@ -76,6 +79,12 @@ "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db" / "db.sqlite3", + # avoid database locking issues between Kafka consumer and web app + # https://docs.djangoproject.com/en/5.1/ref/databases/#database-is-locked-errors + "OPTIONS": { + "timeout": 5, + "transaction_mode": "IMMEDIATE", + }, } } @@ -123,7 +132,7 @@ # Custom settings -INSTALLED_APPS += ["main", "django_tables2"] +INSTALLED_APPS += ["main", "process_manager", "controller", "django_tables2"] MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware") @@ -136,3 +145,15 @@ INSTALLED_APPS += ["django_bootstrap5"] DJANGO_TABLES2_TEMPLATE = "django_tables2/bootstrap5.html" + +PROCESS_MANAGER_URL = os.getenv("PROCESS_MANAGER_URL", "localhost:10054") + +INSTALLED_APPS += ["crispy_forms", "crispy_bootstrap5"] +CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" +CRISPY_TEMPLATE_PACK = "bootstrap5" + +KAFKA_ADDRESS = os.getenv("KAFKA_ADDRESS", "kafka:9092") + +MESSAGE_EXPIRE_SECS = float(os.getenv("MESSAGE_EXPIRE_SECS", 1800)) + +django_stubs_ext.monkeypatch() diff --git a/dune_processes/urls.py b/drunc_ui/urls.py similarity index 82% rename from dune_processes/urls.py rename to drunc_ui/urls.py index 2a1d5c1c..40144ff3 100644 --- a/dune_processes/urls.py +++ b/drunc_ui/urls.py @@ -1,4 +1,4 @@ -"""URL configuration for dune_processes project. +"""URL configuration for drunc_ui project. The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/5.1/topics/http/urls/ @@ -21,4 +21,6 @@ urlpatterns = [ path("admin/", admin.site.urls), path("", include("main.urls")), + path("process_manager/", include("process_manager.urls")), + path("controller/", include("controller.urls")), ] diff --git a/dune_processes/wsgi.py b/drunc_ui/wsgi.py similarity index 70% rename from dune_processes/wsgi.py rename to drunc_ui/wsgi.py index 469105e5..c399ad11 100644 --- a/dune_processes/wsgi.py +++ b/drunc_ui/wsgi.py @@ -1,4 +1,4 @@ -"""WSGI config for dune_processes project. +"""WSGI config for drunc_ui project. It exposes the WSGI callable as a module-level variable named ``application``. @@ -10,6 +10,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dune_processes.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "drunc_ui.settings") application = get_wsgi_application() diff --git a/dune_processes/__init__.py b/dune_processes/__init__.py deleted file mode 100644 index 02b3764e..00000000 --- a/dune_processes/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The main module for dune_processes.""" diff --git a/dune_processes/settings/__init__.py b/dune_processes/settings/__init__.py deleted file mode 100644 index 78a5b6bc..00000000 --- a/dune_processes/settings/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Django settings for dune_processes project.""" - -from .settings import * # noqa: F403 diff --git a/main/__init__.py b/main/__init__.py index 7e320202..d7b97e75 100644 --- a/main/__init__.py +++ b/main/__init__.py @@ -1 +1 @@ -"""The main app for the dune_processes project.""" +"""The main app for the drunc_ui project.""" diff --git a/main/management/__init__.py b/main/management/__init__.py new file mode 100644 index 00000000..907c2a75 --- /dev/null +++ b/main/management/__init__.py @@ -0,0 +1 @@ +"""Django management module.""" diff --git a/main/management/commands/__init__.py b/main/management/commands/__init__.py new file mode 100644 index 00000000..70b2b488 --- /dev/null +++ b/main/management/commands/__init__.py @@ -0,0 +1 @@ +"""Django management commands.""" diff --git a/main/management/commands/kafka_consumer.py b/main/management/commands/kafka_consumer.py new file mode 100644 index 00000000..39b1ec3a --- /dev/null +++ b/main/management/commands/kafka_consumer.py @@ -0,0 +1,67 @@ +"""Django management command to populate Kafka messages into application database.""" + +from argparse import ArgumentParser +from datetime import UTC, datetime, timedelta +from typing import Any + +from django.conf import settings +from django.core.management.base import BaseCommand +from druncschema.broadcast_pb2 import BroadcastMessage +from kafka import KafkaConsumer + +from ...models import DruncMessage + + +class Command(BaseCommand): + """Consumes messages from Kafka and stores them in the database.""" + + help = __doc__ + + def add_arguments(self, parser: ArgumentParser) -> None: + """Add commandline options.""" + parser.add_argument("--debug", action="store_true") + + def handle(self, debug: bool = False, **kwargs: Any) -> None: # type: ignore[misc] + """Command business logic.""" + consumer = KafkaConsumer(bootstrap_servers=[settings.KAFKA_ADDRESS]) + consumer.subscribe(pattern="control.*.process_manager") + # TODO: determine why the below doesn't work + # consumer.subscribe(pattern="control.no_session.process_manager") + + self.stdout.write("Listening for messages from Kafka.") + while True: + for messages in consumer.poll(timeout_ms=500).values(): + message_timestamps = [] + message_bodies = [] + for message in messages: + if debug: + self.stdout.write(f"Message received: {message}") + self.stdout.flush() + + # Convert Kafka timestamp (milliseconds) to datetime (seconds). + timestamp = datetime.fromtimestamp(message.timestamp / 1e3, tz=UTC) + message_timestamps.append(timestamp) + + bm = BroadcastMessage() + bm.ParseFromString(message.value) + message_bodies.append(bm.data.value.decode("utf-8")) + + if message_bodies: + DruncMessage.objects.bulk_create( + [ + DruncMessage(timestamp=t, message=msg) + for t, msg in zip(message_timestamps, message_bodies) + ] + ) + + # Remove expired messages from the database. + message_timeout = timedelta(seconds=settings.MESSAGE_EXPIRE_SECS) + expire_time = datetime.now(tz=UTC) - message_timeout + query = DruncMessage.objects.filter(timestamp__lt=expire_time) + if query.count(): + if debug: + self.stdout.write( + f"Deleting {query.count()} messages " + f"older than {expire_time}." + ) + query.delete() diff --git a/main/migrations/0002_alter_user_options.py b/main/migrations/0002_alter_user_options.py new file mode 100644 index 00000000..49ebbf2d --- /dev/null +++ b/main/migrations/0002_alter_user_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.1 on 2024-09-23 16:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'permissions': [('can_modify_processes', 'Can modify processes'), ('can_view_process_logs', 'Can view process logs')]}, + ), + ] diff --git a/main/migrations/0003_druncmessage.py b/main/migrations/0003_druncmessage.py new file mode 100644 index 00000000..12d81f9a --- /dev/null +++ b/main/migrations/0003_druncmessage.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.1 on 2024-10-10 20:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0002_alter_user_options'), + ] + + operations = [ + migrations.CreateModel( + name='DruncMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField()), + ('message', models.TextField()), + ], + ), + ] diff --git a/main/models.py b/main/models.py index 4a77c1a0..76e8deeb 100644 --- a/main/models.py +++ b/main/models.py @@ -1,8 +1,25 @@ """Models module for the main app.""" +from typing import ClassVar + from django.contrib.auth.models import AbstractUser -from django.db import models # noqa: F401 +from django.db import models class User(AbstractUser): """Custom user model for this project.""" + + class Meta: + """Meta class for the User model.""" + + permissions: ClassVar = [ + ("can_modify_processes", "Can modify processes"), + ("can_view_process_logs", "Can view process logs"), + ] + + +class DruncMessage(models.Model): + """Model for drunc broadcast messages.""" + + timestamp = models.DateTimeField() + message = models.TextField() diff --git a/main/static/main/js/_hyperscript.min.js b/main/static/main/js/_hyperscript.min.js new file mode 100644 index 00000000..355c78ae --- /dev/null +++ b/main/static/main/js/_hyperscript.min.js @@ -0,0 +1 @@ +(function(e,t){const r=t(e);if(typeof exports==="object"&&typeof exports["nodeName"]!=="string"){module.exports=r}else{e["_hyperscript"]=r;if("document"in e)e["_hyperscript"].browserInit()}})(typeof self!=="undefined"?self:this,(e=>{"use strict";const t={dynamicResolvers:[function(e,t){if(e==="Fixed"){return Number(t).toFixed()}else if(e.indexOf("Fixed:")===0){let r=e.split(":")[1];return Number(t).toFixed(parseInt(r))}}],String:function(e){if(e.toString){return e.toString()}else{return""+e}},Int:function(e){return parseInt(e)},Float:function(e){return parseFloat(e)},Number:function(e){return Number(e)},Date:function(e){return new Date(e)},Array:function(e){return Array.from(e)},JSON:function(e){return JSON.stringify(e)},Object:function(e){if(e instanceof String){e=e.toString()}if(typeof e==="string"){return JSON.parse(e)}else{return Object.assign({},e)}}};const r={attributes:"_, script, data-script",defaultTransition:"all 500ms ease-in",disableSelector:"[disable-scripting], [data-disable-scripting]",hideShowStrategies:{},conversions:t};class n{static OP_TABLE={"+":"PLUS","-":"MINUS","*":"MULTIPLY","/":"DIVIDE",".":"PERIOD","..":"ELLIPSIS","\\":"BACKSLASH",":":"COLON","%":"PERCENT","|":"PIPE","!":"EXCLAMATION","?":"QUESTION","#":"POUND","&":"AMPERSAND",$:"DOLLAR",";":"SEMI",",":"COMMA","(":"L_PAREN",")":"R_PAREN","<":"L_ANG",">":"R_ANG","<=":"LTE_ANG",">=":"GTE_ANG","==":"EQ","===":"EQQ","!=":"NEQ","!==":"NEQQ","{":"L_BRACE","}":"R_BRACE","[":"L_BRACKET","]":"R_BRACKET","=":"EQUALS"};static isValidCSSClassChar(e){return n.isAlpha(e)||n.isNumeric(e)||e==="-"||e==="_"||e===":"}static isValidCSSIDChar(e){return n.isAlpha(e)||n.isNumeric(e)||e==="-"||e==="_"||e===":"}static isWhitespace(e){return e===" "||e==="\t"||n.isNewline(e)}static positionString(e){return"[Line: "+e.line+", Column: "+e.column+"]"}static isNewline(e){return e==="\r"||e==="\n"}static isNumeric(e){return e>="0"&&e<="9"}static isAlpha(e){return e>="a"&&e<="z"||e>="A"&&e<="Z"}static isIdentifierChar(e,t){return e==="_"||e==="$"}static isReservedChar(e){return e==="`"||e==="^"}static isValidSingleQuoteStringStart(e){if(e.length>0){var t=e[e.length-1];if(t.type==="IDENTIFIER"||t.type==="CLASS_REF"||t.type==="ID_REF"){return false}if(t.op&&(t.value===">"||t.value===")")){return false}}return true}static tokenize(e,t){var r=[];var a=e;var o=0;var s=0;var u=1;var l="";var c=0;function f(){return t&&c===0}while(o=0){return this.consumeToken()}}requireToken(e,t){var r=this.matchToken(e,t);if(r){return r}else{this.raiseError(this,"Expected '"+e+"' but found '"+this.currentToken().value+"'")}}peekToken(e,t,r){t=t||0;r=r||"IDENTIFIER";if(this.tokens[t]&&this.tokens[t].value===e&&this.tokens[t].type===r){return this.tokens[t]}}matchToken(e,t){if(this.follows.indexOf(e)!==-1){return}t=t||"IDENTIFIER";if(this.currentToken()&&this.currentToken().value===e&&this.currentToken().type===t){return this.consumeToken()}}consumeToken(){var e=this.tokens.shift();this.consumed.push(e);this._lastConsumed=e;this.consumeWhitespace();return e}consumeUntil(e,t){var r=[];var n=this.token(0,true);while((t==null||n.type!==t)&&(e==null||n.value!==e)&&n.type!=="EOF"){var i=this.tokens.shift();this.consumed.push(i);r.push(n);n=this.token(0,true)}this.consumeWhitespace();return r}lastWhitespace(){if(this.consumed[this.consumed.length-1]&&this.consumed[this.consumed.length-1].type==="WHITESPACE"){return this.consumed[this.consumed.length-1].value}else{return""}}consumeUntilWhitespace(){return this.consumeUntil(null,"WHITESPACE")}hasMore(){return this.tokens.length>0}token(e,t){var r;var n=0;do{if(!t){while(this.tokens[n]&&this.tokens[n].type==="WHITESPACE"){n++}}r=this.tokens[n];e--;n++}while(e>-1);if(r){return r}else{return{type:"EOF",value:"<<>>"}}}currentToken(){return this.token(0)}lastMatch(){return this._lastConsumed}static sourceFor=function(){return this.programSource.substring(this.startToken.start,this.endToken.end)};static lineFor=function(){return this.programSource.split("\n")[this.startToken.line-1]};follows=[];pushFollow(e){this.follows.push(e)}popFollow(){this.follows.pop()}clearFollows(){var e=this.follows;this.follows=[];return e}restoreFollows(e){this.follows=e}}class a{constructor(e){this.runtime=e;this.possessivesDisabled=false;this.addGrammarElement("feature",(function(e,t,r){if(r.matchOpToken("(")){var n=e.requireElement("feature",r);r.requireOpToken(")");return n}var i=e.FEATURES[r.currentToken().value||""];if(i){return i(e,t,r)}}));this.addGrammarElement("command",(function(e,t,r){if(r.matchOpToken("(")){const t=e.requireElement("command",r);r.requireOpToken(")");return t}var n=e.COMMANDS[r.currentToken().value||""];let i;if(n){i=n(e,t,r)}else if(r.currentToken().type==="IDENTIFIER"){i=e.parseElement("pseudoCommand",r)}if(i){return e.parseElement("indirectStatement",r,i)}return i}));this.addGrammarElement("commandList",(function(e,t,r){if(r.hasMore()){var n=e.parseElement("command",r);if(n){r.matchToken("then");const t=e.parseElement("commandList",r);if(t)n.next=t;return n}}return{type:"emptyCommandListCommand",op:function(e){return t.findNext(this,e)},execute:function(e){return t.unifiedExec(this,e)}}}));this.addGrammarElement("leaf",(function(e,t,r){var n=e.parseAnyOf(e.LEAF_EXPRESSIONS,r);if(n==null){return e.parseElement("symbol",r)}return n}));this.addGrammarElement("indirectExpression",(function(e,t,r,n){for(var i=0;i{this.unifiedExec(e,t)})).catch((e=>{this.unifiedExec({op:function(){throw e}},t)}));return}else if(r===o.HALT){if(t.meta.finallyHandler&&!t.meta.handlingFinally){t.meta.handlingFinally=true;e=t.meta.finallyHandler}else{if(t.meta.onHalt){t.meta.onHalt()}if(t.meta.currentException){if(t.meta.reject){t.meta.reject(t.meta.currentException);return}else{throw t.meta.currentException}}else{return}}}else{e=r}}}unifiedEval(e,t){var r=[t];var n=false;var i=false;if(e.args){for(var a=0;a{r=this.wrapArrays(r);Promise.all(r).then((function(r){if(i){this.unwrapAsyncs(r)}try{var a=e.op.apply(e,r);t(a)}catch(e){n(e)}})).catch((function(e){n(e)}))}))}else{if(i){this.unwrapAsyncs(r)}return e.op.apply(e,r)}}_scriptAttrs=null;getScriptAttributes(){if(this._scriptAttrs==null){this._scriptAttrs=r.attributes.replace(/ /g,"").split(",")}return this._scriptAttrs}getScript(e){for(var t=0;t{this.initElement(e,e instanceof HTMLScriptElement&&e.type==="text/hyperscript"?document.body:e)}))}}initElement(e,t){if(e.closest&&e.closest(r.disableSelector)){return}var n=this.getInternalData(e);if(!n.initialized){var i=this.getScript(e);if(i){try{n.initialized=true;n.script=i;const r=this.lexer,s=this.parser;var a=r.tokenize(i);var o=s.parseHyperScript(a);if(!o)return;o.apply(t||e,e);setTimeout((()=>{this.triggerEvent(t||e,"load",{hyperscript:true})}),1)}catch(t){this.triggerEvent(e,"exception",{error:t});console.error("hyperscript errors were found on the following element:",e,"\n\n",t.message,t.stack)}}}}internalDataMap=new WeakMap;getInternalData(e){var t=this.internalDataMap.get(e);if(typeof t==="undefined"){this.internalDataMap.set(e,t={})}return t}typeCheck(e,t,r){if(e==null&&r){return true}var n=Object.prototype.toString.call(e).slice(8,-1);return n===t}getElementScope(e){var t=e.meta&&e.meta.owner;if(t){var r=this.getInternalData(t);var n="elementScope";if(e.meta.feature&&e.meta.feature.behavior){n=e.meta.feature.behavior+"Scope"}var i=h(r,n);return i}else{return{}}}isReservedWord(e){return["meta","it","result","locals","event","target","detail","sender","body"].includes(e)}isHyperscriptContext(e){return e instanceof f}resolveSymbol(t,r,n){if(t==="me"||t==="my"||t==="I"){return r.me}if(t==="it"||t==="its"||t==="result"){return r.result}if(t==="you"||t==="your"||t==="yourself"){return r.you}else{if(n==="global"){return e[t]}else if(n==="element"){var i=this.getElementScope(r);return i[t]}else if(n==="local"){return r.locals[t]}else{if(r.meta&&r.meta.context){var a=r.meta.context[t];if(typeof a!=="undefined"){return a}if(r.meta.context.detail){a=r.meta.context.detail[t];if(typeof a!=="undefined"){return a}}}if(this.isHyperscriptContext(r)&&!this.isReservedWord(t)){var o=r.locals[t]}else{var o=r[t]}if(typeof o!=="undefined"){return o}else{var i=this.getElementScope(r);o=i[t];if(typeof o!=="undefined"){return o}else{return e[t]}}}}}setSymbol(t,r,n,i){if(n==="global"){e[t]=i}else if(n==="element"){var a=this.getElementScope(r);a[t]=i}else if(n==="local"){r.locals[t]=i}else{if(this.isHyperscriptContext(r)&&!this.isReservedWord(t)&&typeof r.locals[t]!=="undefined"){r.locals[t]=i}else{var a=this.getElementScope(r);var o=a[t];if(typeof o!=="undefined"){a[t]=i}else{if(this.isHyperscriptContext(r)&&!this.isReservedWord(t)){r.locals[t]=i}else{r[t]=i}}}}}findNext(e,t){if(e){if(e.resolveNext){return e.resolveNext(t)}else if(e.next){return e.next}else{return this.findNext(e.parent,t)}}}flatGet(e,t,r){if(e!=null){var n=r(e,t);if(typeof n!=="undefined"){return n}if(this.shouldAutoIterate(e)){var i=[];for(var a of e){var o=r(a,t);i.push(o)}return i}}}resolveProperty(e,t){return this.flatGet(e,t,((e,t)=>e[t]))}resolveAttribute(e,t){return this.flatGet(e,t,((e,t)=>e.getAttribute&&e.getAttribute(t)))}resolveStyle(e,t){return this.flatGet(e,t,((e,t)=>e.style&&e.style[t]))}resolveComputedStyle(e,t){return this.flatGet(e,t,((e,t)=>getComputedStyle(e).getPropertyValue(t)))}assignToNamespace(t,r,n,i){let a;if(typeof document!=="undefined"&&t===document.body){a=e}else{a=this.getHyperscriptFeatures(t)}var o;while((o=r.shift())!==undefined){var s=a[o];if(s==null){s={};a[o]=s}a=s}a[n]=i}getHyperTrace(e,t){var r=[];var n=e;while(n.meta.caller){n=n.meta.caller}if(n.meta.traceMap){return n.meta.traceMap.get(t,r)}}registerHyperTrace(e,t){var r=[];var n=null;while(e!=null){r.push(e);n=e;e=e.meta.caller}if(n.meta.traceMap==null){n.meta.traceMap=new Map}if(!n.meta.traceMap.get(t)){var i={trace:r,print:function(e){e=e||console.error;e("hypertrace /// ");var t=0;for(var n=0;n",i.meta.feature.displayName.padEnd(t+2),"-",i.meta.owner)}}};n.meta.traceMap.set(t,i)}}escapeSelector(e){return e.replace(/:/g,(function(e){return"\\"+e}))}nullCheck(e,t){if(e==null){throw new Error("'"+t.sourceFor()+"' is null")}}isEmpty(e){return e==undefined||e.length===0}doesExist(e){if(e==null){return false}if(this.shouldAutoIterate(e)){for(const t of e){return true}return false}return true}getRootNode(e){if(e&&e instanceof Node){var t=e.getRootNode();if(t instanceof Document||t instanceof ShadowRoot)return t}return document}getEventQueueFor(e,t){let r=this.getInternalData(e);var n=r.eventQueues;if(n==null){n=new Map;r.eventQueues=n}var i=n.get(t);if(i==null){i={queue:[],executing:false};n.set(t,i)}return i}beepValueToConsole(e,t,r){if(this.triggerEvent(e,"hyperscript:beep",{element:e,expression:t,value:r})){var n;if(r){if(r instanceof m){n="ElementCollection"}else if(r.constructor){n=r.constructor.name}else{n="unknown"}}else{n="object (null)"}var a=r;if(n==="String"){a='"'+a+'"'}else if(r instanceof m){a=Array.from(r)}console.log("///_ BEEP! The expression ("+i.sourceFor.call(t).replace("beep! ","")+") evaluates to:",a,"of type "+n)}}hyperscriptUrl="document"in e&&document.currentScript?document.currentScript.src:null}function s(){let e=document.cookie.split("; ").map((e=>{let t=e.split("=");return{name:t[0],value:decodeURIComponent(t[1])}}));return e}function u(e){document.cookie=e+"=;expires=Thu, 01 Jan 1970 00:00:00 GMT"}function l(){for(const e of s()){u(e.name)}}const c=new Proxy({},{get(e,t){if(t==="then"||t==="asyncWrapper"){return null}else if(t==="length"){return s().length}else if(t==="clear"){return u}else if(t==="clearAll"){return l}else if(typeof t==="string"){if(!isNaN(t)){return s()[parseInt(t)]}else{let e=document.cookie.split("; ").find((e=>e.startsWith(t+"=")))?.split("=")[1];if(e){return decodeURIComponent(e)}}}else if(t===Symbol.iterator){return s()[t]}},set(e,t,r){var n=null;if("string"===typeof r){n=encodeURIComponent(r);n+=";samesite=lax"}else{n=encodeURIComponent(r.value);if(r.expires){n+=";expires="+r.maxAge}if(r.maxAge){n+=";max-age="+r.maxAge}if(r.partitioned){n+=";partitioned="+r.partitioned}if(r.path){n+=";path="+r.path}if(r.samesite){n+=";samesite="+r.path}if(r.secure){n+=";secure="+r.path}}document.cookie=t+"="+n;return true}});class f{constructor(t,r,n,i,a){this.meta={parser:a.parser,lexer:a.lexer,runtime:a,owner:t,feature:r,iterators:{},ctx:this};this.locals={cookies:c};this.me=n,this.you=undefined;this.result=undefined;this.event=i;this.target=i?i.target:null;this.detail=i?i.detail:null;this.sender=i?i.detail?i.detail.sender:null:null;this.body="document"in e?document.body:null;a.addFeatures(t,this)}}class m{constructor(e,t,r){this._css=e;this.relativeToElement=t;this.escape=r;this[p]=true}get css(){if(this.escape){return o.prototype.escapeSelector(this._css)}else{return this._css}}get className(){return this._css.substr(1)}get id(){return this.className()}contains(e){for(let t of this){if(t.contains(e)){return true}}return false}get length(){return this.selectMatches().length}[Symbol.iterator](){let e=this.selectMatches();return e[Symbol.iterator]()}selectMatches(){let e=o.prototype.getRootNode(this.relativeToElement).querySelectorAll(this.css);return e}}const p=Symbol();function h(e,t){var r=e[t];if(r){return r}else{var n={};e[t]=n;return n}}function v(e){try{return JSON.parse(e)}catch(e){d(e);return null}}function d(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function E(e,t){return new(e.bind.apply(e,[e].concat(t)))}function T(t){t.addLeafExpression("parenthesized",(function(e,t,r){if(r.matchOpToken("(")){var n=r.clearFollows();try{var i=e.requireElement("expression",r)}finally{r.restoreFollows(n)}r.requireOpToken(")");return i}}));t.addLeafExpression("string",(function(e,t,r){var i=r.matchTokenType("STRING");if(!i)return;var a=i.value;var o;if(i.template){var s=n.tokenize(a,true);o=e.parseStringTemplate(s)}else{o=[]}return{type:"string",token:i,args:o,op:function(e){var t="";for(var r=1;re instanceof Element))}get css(){let e="",t=0;for(const r of this.templateParts){if(r instanceof Element){e+="[data-hs-query-id='"+t+++"']"}else e+=r}return e}[Symbol.iterator](){this.elements.forEach(((e,t)=>e.dataset.hsQueryId=t));const e=super[Symbol.iterator]();this.elements.forEach((e=>e.removeAttribute("data-hs-query-id")));return e}}t.addLeafExpression("queryRef",(function(e,t,i){var a=i.matchOpToken("<");if(!a)return;var o=i.consumeUntil("/");i.requireOpToken("/");i.requireOpToken(">");var s=o.map((function(e){if(e.type==="STRING"){return'"'+e.value+'"'}else{return e.value}})).join("");var u,l,c;if(s.indexOf("$")>=0){u=true;l=n.tokenize(s,true);c=e.parseStringTemplate(l)}return{type:"queryRef",css:s,args:c,op:function(e,...t){if(u){return new r(s,e.me,t)}else{return new m(s,e.me)}},evaluate:function(e){return t.unifiedEval(this,e)}}}));t.addLeafExpression("attributeRef",(function(e,t,r){var n=r.matchTokenType("ATTRIBUTE_REF");if(!n)return;if(!n.value)return;var i=n.value;if(i.indexOf("[")===0){var a=i.substring(2,i.length-1)}else{var a=i.substring(1)}var o="["+a+"]";var s=a.split("=");var u=s[0];var l=s[1];if(l){if(l.indexOf('"')===0){l=l.substring(1,l.length-1)}}return{type:"attributeRef",name:u,css:o,value:l,op:function(e){var t=e.you||e.me;if(t){return t.getAttribute(u)}},evaluate:function(e){return t.unifiedEval(this,e)}}}));t.addLeafExpression("styleRef",(function(e,t,r){var n=r.matchTokenType("STYLE_REF");if(!n)return;if(!n.value)return;var i=n.value.substr(1);if(i.startsWith("computed-")){i=i.substr("computed-".length);return{type:"computedStyleRef",name:i,op:function(e){var r=e.you||e.me;if(r){return t.resolveComputedStyle(r,i)}},evaluate:function(e){return t.unifiedEval(this,e)}}}else{return{type:"styleRef",name:i,op:function(e){var r=e.you||e.me;if(r){return t.resolveStyle(r,i)}},evaluate:function(e){return t.unifiedEval(this,e)}}}}));t.addGrammarElement("objectKey",(function(e,t,r){var n;if(n=r.matchTokenType("STRING")){return{type:"objectKey",key:n.value,evaluate:function(){return n.value}}}else if(r.matchOpToken("[")){var i=e.parseElement("expression",r);r.requireOpToken("]");return{type:"objectKey",expr:i,args:[i],op:function(e,t){return t},evaluate:function(e){return t.unifiedEval(this,e)}}}else{var a="";do{n=r.matchTokenType("IDENTIFIER")||r.matchOpToken("-");if(n)a+=n.value}while(n);return{type:"objectKey",key:a,evaluate:function(){return a}}}}));t.addLeafExpression("objectLiteral",(function(e,t,r){if(!r.matchOpToken("{"))return;var n=[];var i=[];if(!r.matchOpToken("}")){do{var a=e.requireElement("objectKey",r);r.requireOpToken(":");var o=e.requireElement("expression",r);i.push(o);n.push(a)}while(r.matchOpToken(","));r.requireOpToken("}")}return{type:"objectLiteral",args:[n,i],op:function(e,t,r){var n={};for(var i=0;i");var a=e.requireElement("expression",r);return{type:"blockLiteral",args:n,expr:a,evaluate:function(e){var t=function(){for(var t=0;t=0;a--){var o=i[a];if(o.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}if(n){return i[i.length-1]}};var l=function(e,t,r,n){var i=[];o.prototype.forEach(t,(function(t){if(t.matches(r)||t===e){i.push(t)}}));for(var a=0;a","<=",">=","==","===","!=","!==");var a=i?i.value:null;var o=true;var s=false;if(a==null){if(r.matchToken("is")||r.matchToken("am")){if(r.matchToken("not")){if(r.matchToken("in")){a="not in"}else if(r.matchToken("a")){a="not a";s=true}else if(r.matchToken("empty")){a="not empty";o=false}else{if(r.matchToken("really")){a="!=="}else{a="!="}if(r.matchToken("equal")){r.matchToken("to")}}}else if(r.matchToken("in")){a="in"}else if(r.matchToken("a")){a="a";s=true}else if(r.matchToken("empty")){a="empty";o=false}else if(r.matchToken("less")){r.requireToken("than");if(r.matchToken("or")){r.requireToken("equal");r.requireToken("to");a="<="}else{a="<"}}else if(r.matchToken("greater")){r.requireToken("than");if(r.matchToken("or")){r.requireToken("equal");r.requireToken("to");a=">="}else{a=">"}}else{if(r.matchToken("really")){a="==="}else{a="=="}if(r.matchToken("equal")){r.matchToken("to")}}}else if(r.matchToken("equals")){a="=="}else if(r.matchToken("really")){r.requireToken("equals");a="==="}else if(r.matchToken("exist")||r.matchToken("exists")){a="exist";o=false}else if(r.matchToken("matches")||r.matchToken("match")){a="match"}else if(r.matchToken("contains")||r.matchToken("contain")){a="contain"}else if(r.matchToken("includes")||r.matchToken("include")){a="include"}else if(r.matchToken("do")||r.matchToken("does")){r.requireToken("not");if(r.matchToken("matches")||r.matchToken("match")){a="not match"}else if(r.matchToken("contains")||r.matchToken("contain")){a="not contain"}else if(r.matchToken("exist")||r.matchToken("exist")){a="not exist";o=false}else if(r.matchToken("include")){a="not include"}else{e.raiseParseError(r,"Expected matches or contains")}}}if(a){var u,l,c;if(s){u=r.requireTokenType("IDENTIFIER");l=!r.matchOpToken("!")}else if(o){c=e.requireElement("mathExpression",r);if(a==="match"||a==="not match"){c=c.css?c.css:c}}var m=n;n={type:"comparisonOperator",operator:a,typeName:u,nullOk:l,lhs:n,rhs:c,args:[n,c],op:function(e,r,n){if(a==="=="){return r==n}else if(a==="!="){return r!=n}if(a==="==="){return r===n}else if(a==="!=="){return r!==n}if(a==="match"){return r!=null&&p(m,r,n)}if(a==="not match"){return r==null||!p(m,r,n)}if(a==="in"){return n!=null&&f(c,n,r)}if(a==="not in"){return n==null||!f(c,n,r)}if(a==="contain"){return r!=null&&f(m,r,n)}if(a==="not contain"){return r==null||!f(m,r,n)}if(a==="include"){return r!=null&&f(m,r,n)}if(a==="not include"){return r==null||!f(m,r,n)}if(a==="==="){return r===n}else if(a==="!=="){return r!==n}else if(a==="<"){return r"){return r>n}else if(a==="<="){return r<=n}else if(a===">="){return r>=n}else if(a==="empty"){return t.isEmpty(r)}else if(a==="not empty"){return!t.isEmpty(r)}else if(a==="exist"){return t.doesExist(r)}else if(a==="not exist"){return!t.doesExist(r)}else if(a==="a"){return t.typeCheck(r,u.value,l)}else if(a==="not a"){return!t.typeCheck(r,u.value,l)}else{throw"Unknown comparison : "+a}},evaluate:function(e){return t.unifiedEval(this,e)}}}return n}));t.addGrammarElement("comparisonExpression",(function(e,t,r){return e.parseAnyOf(["comparisonOperator","mathExpression"],r)}));t.addGrammarElement("logicalOperator",(function(e,t,r){var n=e.parseElement("comparisonExpression",r);var i,a=null;i=r.matchToken("and")||r.matchToken("or");while(i){a=a||i;if(a.value!==i.value){e.raiseParseError(r,"You must parenthesize logical operations with different operators")}var o=e.requireElement("comparisonExpression",r);const s=i.value;n={type:"logicalOperator",operator:s,lhs:n,rhs:o,args:[n,o],op:function(e,t,r){if(s==="and"){return t&&r}else{return t||r}},evaluate:function(e){return t.unifiedEval(this,e)}};i=r.matchToken("and")||r.matchToken("or")}return n}));t.addGrammarElement("logicalExpression",(function(e,t,r){return e.parseAnyOf(["logicalOperator","mathExpression"],r)}));t.addGrammarElement("asyncExpression",(function(e,t,r){if(r.matchToken("async")){var n=e.requireElement("logicalExpression",r);var i={type:"asyncExpression",value:n,evaluate:function(e){return{asyncWrapper:true,value:this.value.evaluate(e)}}};return i}else{return e.parseElement("logicalExpression",r)}}));t.addGrammarElement("expression",(function(e,t,r){r.matchToken("the");return e.parseElement("asyncExpression",r)}));t.addGrammarElement("assignableExpression",(function(e,t,r){r.matchToken("the");var n=e.parseElement("primaryExpression",r);if(n&&(n.type==="symbol"||n.type==="ofExpression"||n.type==="propertyAccess"||n.type==="attributeRefAccess"||n.type==="attributeRef"||n.type==="styleRef"||n.type==="arrayIndex"||n.type==="possessive")){return n}else{e.raiseParseError(r,"A target expression must be writable. The expression type '"+(n&&n.type)+"' is not.")}return n}));t.addGrammarElement("hyperscript",(function(e,t,r){var n=[];if(r.hasMore()){while(e.featureStart(r.currentToken())||r.currentToken().value==="("){var i=e.requireElement("feature",r);n.push(i);r.matchToken("end")}}return{type:"hyperscript",features:n,apply:function(e,t,r){for(const i of n){i.install(e,t,r)}}}}));var v=function(e){var t=[];if(e.token(0).value==="("&&(e.token(1).value===")"||e.token(2).value===","||e.token(2).value===")")){e.matchOpToken("(");do{t.push(e.requireTokenType("IDENTIFIER"))}while(e.matchOpToken(","));e.requireOpToken(")")}return t};t.addFeature("on",(function(e,t,r){if(!r.matchToken("on"))return;var n=false;if(r.matchToken("every")){n=true}var i=[];var a=null;do{var o=e.requireElement("eventName",r,"Expected event name");var s=o.evaluate();if(a){a=a+" or "+s}else{a="on "+s}var u=v(r);var l=null;if(r.matchOpToken("[")){l=e.requireElement("expression",r);r.requireOpToken("]")}var c,f,m;if(r.currentToken().type==="NUMBER"){var p=r.consumeToken();if(!p.value)return;c=parseInt(p.value);if(r.matchToken("to")){var h=r.consumeToken();if(!h.value)return;f=parseInt(h.value)}else if(r.matchToken("and")){m=true;r.requireToken("on")}}var d,E;if(s==="intersection"){d={};if(r.matchToken("with")){d["with"]=e.requireElement("expression",r).evaluate()}if(r.matchToken("having")){do{if(r.matchToken("margin")){d["rootMargin"]=e.requireElement("stringLike",r).evaluate()}else if(r.matchToken("threshold")){d["threshold"]=e.requireElement("expression",r).evaluate()}else{e.raiseParseError(r,"Unknown intersection config specification")}}while(r.matchToken("and"))}}else if(s==="mutation"){E={};if(r.matchToken("of")){do{if(r.matchToken("anything")){E["attributes"]=true;E["subtree"]=true;E["characterData"]=true;E["childList"]=true}else if(r.matchToken("childList")){E["childList"]=true}else if(r.matchToken("attributes")){E["attributes"]=true;E["attributeOldValue"]=true}else if(r.matchToken("subtree")){E["subtree"]=true}else if(r.matchToken("characterData")){E["characterData"]=true;E["characterDataOldValue"]=true}else if(r.currentToken().type==="ATTRIBUTE_REF"){var T=r.consumeToken();if(E["attributeFilter"]==null){E["attributeFilter"]=[]}if(T.value.indexOf("@")==0){E["attributeFilter"].push(T.value.substring(1))}else{e.raiseParseError(r,"Only shorthand attribute references are allowed here")}}else{e.raiseParseError(r,"Unknown mutation config specification")}}while(r.matchToken("or"))}else{E["attributes"]=true;E["characterData"]=true;E["childList"]=true}}var y=null;var k=false;if(r.matchToken("from")){if(r.matchToken("elsewhere")){k=true}else{r.pushFollow("or");try{y=e.requireElement("expression",r)}finally{r.popFollow()}if(!y){e.raiseParseError(r,'Expected either target value or "elsewhere".')}}}if(y===null&&k===false&&r.matchToken("elsewhere")){k=true}if(r.matchToken("in")){var x=e.parseElement("unaryExpression",r)}if(r.matchToken("debounced")){r.requireToken("at");var g=e.requireElement("unaryExpression",r);var b=g.evaluate({})}else if(r.matchToken("throttled")){r.requireToken("at");var g=e.requireElement("unaryExpression",r);var w=g.evaluate({})}i.push({execCount:0,every:n,on:s,args:u,filter:l,from:y,inExpr:x,elsewhere:k,startCount:c,endCount:f,unbounded:m,debounceTime:b,throttleTime:w,mutationSpec:E,intersectionSpec:d,debounced:undefined,lastExec:undefined})}while(r.matchToken("or"));var S=true;if(!n){if(r.matchToken("queue")){if(r.matchToken("all")){var q=true;var S=false}else if(r.matchToken("first")){var N=true}else if(r.matchToken("none")){var I=true}else{r.requireToken("last")}}}var C=e.requireElement("commandList",r);e.ensureTerminated(C);var R,A;if(r.matchToken("catch")){R=r.requireTokenType("IDENTIFIER").value;A=e.requireElement("commandList",r);e.ensureTerminated(A)}if(r.matchToken("finally")){var L=e.requireElement("commandList",r);e.ensureTerminated(L)}var O={displayName:a,events:i,start:C,every:n,execCount:0,errorHandler:A,errorSymbol:R,execute:function(e){let r=t.getEventQueueFor(e.me,O);if(r.executing&&n===false){if(I||N&&r.queue.length>0){return}if(S){r.queue.length=0}r.queue.push(e);return}O.execCount++;r.executing=true;e.meta.onHalt=function(){r.executing=false;var e=r.queue.shift();if(e){setTimeout((function(){O.execute(e)}),1)}};e.meta.reject=function(r){console.error(r.message?r.message:r);var n=t.getHyperTrace(e,r);if(n){n.print()}t.triggerEvent(e.me,"exception",{error:r})};C.execute(e)},install:function(e,r){for(const r of O.events){var n;if(r.elsewhere){n=[document]}else if(r.from){n=r.from.evaluate(t.makeContext(e,O,e,null))}else{n=[e]}t.implicitLoop(n,(function(n){var i=r.on;if(n==null){console.warn("'%s' feature ignored because target does not exists:",a,e);return}if(r.mutationSpec){i="hyperscript:mutation";const e=new MutationObserver((function(e,r){if(!O.executing){t.triggerEvent(n,i,{mutationList:e,observer:r})}}));e.observe(n,r.mutationSpec)}if(r.intersectionSpec){i="hyperscript:intersection";const e=new IntersectionObserver((function(r){for(const o of r){var a={observer:e};a=Object.assign(a,o);a["intersecting"]=o.isIntersecting;t.triggerEvent(n,i,a)}}),r.intersectionSpec);e.observe(n)}var o=n.addEventListener||n.on;o.call(n,i,(function a(o){if(typeof Node!=="undefined"&&e instanceof Node&&n!==e&&!e.isConnected){n.removeEventListener(i,a);return}var s=t.makeContext(e,O,e,o);if(r.elsewhere&&e.contains(o.target)){return}if(r.from){s.result=n}for(const e of r.args){let t=s.event[e.value];if(t!==undefined){s.locals[e.value]=t}else if("detail"in s.event){s.locals[e.value]=s.event["detail"][e.value]}}s.meta.errorHandler=A;s.meta.errorSymbol=R;s.meta.finallyHandler=L;if(r.filter){var u=s.meta.context;s.meta.context=s.event;try{var l=r.filter.evaluate(s);if(l){}else{return}}finally{s.meta.context=u}}if(r.inExpr){var c=o.target;while(true){if(c.matches&&c.matches(r.inExpr.css)){s.result=c;break}else{c=c.parentElement;if(c==null){return}}}}r.execCount++;if(r.startCount){if(r.endCount){if(r.execCountr.endCount){return}}else if(r.unbounded){if(r.execCount{var a=false;for(const s of i){var o=n=>{e.result=n;if(s.args){for(const t of s.args){e.locals[t.value]=n[t.value]||(n.detail?n.detail[t.value]:null)}}if(!a){a=true;r(t.findNext(this,e))}};if(s.name){n.addEventListener(s.name,o,{once:true})}else if(s.time!=null){setTimeout(o,s.time,s.time)}}}))}};return n}else{var s;if(r.matchToken("a")){r.requireToken("tick");s=0}else{s=e.requireElement("expression",r)}n={type:"waitCmd",time:s,args:[s],op:function(e,r){return new Promise((n=>{setTimeout((()=>{n(t.findNext(this,e))}),r)}))},execute:function(e){return t.unifiedExec(this,e)}};return n}}));t.addGrammarElement("dotOrColonPath",(function(e,t,r){var n=r.matchTokenType("IDENTIFIER");if(n){var i=[n.value];var a=r.matchOpToken(".")||r.matchOpToken(":");if(a){do{i.push(r.requireTokenType("IDENTIFIER","NUMBER").value)}while(r.matchOpToken(a.value))}return{type:"dotOrColonPath",path:i,evaluate:function(){return i.join(a?a.value:"")}}}}));t.addGrammarElement("eventName",(function(e,t,r){var n;if(n=r.matchTokenType("STRING")){return{evaluate:function(){return n.value}}}return e.parseElement("dotOrColonPath",r)}));function d(e,t,r,n){var i=t.requireElement("eventName",n);var a=t.parseElement("namedArgumentList",n);if(e==="send"&&n.matchToken("to")||e==="trigger"&&n.matchToken("on")){var o=t.requireElement("expression",n)}else{var o=t.requireElement("implicitMeTarget",n)}var s={eventName:i,details:a,to:o,args:[o,i,a],op:function(e,t,n,i){r.nullCheck(t,o);r.implicitLoop(t,(function(t){r.triggerEvent(t,n,i,e.me)}));return r.findNext(s,e)}};return s}t.addCommand("trigger",(function(e,t,r){if(r.matchToken("trigger")){return d("trigger",e,t,r)}}));t.addCommand("send",(function(e,t,r){if(r.matchToken("send")){return d("send",e,t,r)}}));var T=function(e,t,r,n){if(n){if(e.commandBoundary(r.currentToken())){e.raiseParseError(r,"'return' commands must return a value. If you do not wish to return a value, use 'exit' instead.")}else{var i=e.requireElement("expression",r)}}var a={value:i,args:[i],op:function(e,r){var n=e.meta.resolve;e.meta.returned=true;e.meta.returnValue=r;if(n){if(r){n(r)}else{n()}}return t.HALT}};return a};t.addCommand("return",(function(e,t,r){if(r.matchToken("return")){return T(e,t,r,true)}}));t.addCommand("exit",(function(e,t,r){if(r.matchToken("exit")){return T(e,t,r,false)}}));t.addCommand("halt",(function(e,t,r){if(r.matchToken("halt")){if(r.matchToken("the")){r.requireToken("event");if(r.matchOpToken("'")){r.requireToken("s")}var n=true}if(r.matchToken("bubbling")){var i=true}else if(r.matchToken("default")){var a=true}var o=T(e,t,r,false);var s={keepExecuting:true,bubbling:i,haltDefault:a,exit:o,op:function(e){if(e.event){if(i){e.event.stopPropagation()}else if(a){e.event.preventDefault()}else{e.event.stopPropagation();e.event.preventDefault()}if(n){return t.findNext(this,e)}else{return o}}}};return s}}));t.addCommand("log",(function(e,t,r){if(!r.matchToken("log"))return;var n=[e.parseElement("expression",r)];while(r.matchOpToken(",")){n.push(e.requireElement("expression",r))}if(r.matchToken("with")){var i=e.requireElement("expression",r)}var a={exprs:n,withExpr:i,args:[i,n],op:function(e,r,n){if(r){r.apply(null,n)}else{console.log.apply(null,n)}return t.findNext(this,e)}};return a}));t.addCommand("beep!",(function(e,t,r){if(!r.matchToken("beep!"))return;var n=[e.parseElement("expression",r)];while(r.matchOpToken(",")){n.push(e.requireElement("expression",r))}var i={exprs:n,args:[n],op:function(e,r){for(let i=0;i{if(!r.matchToken("pick"))return;r.matchToken("the");if(r.matchToken("item")||r.matchToken("items")||r.matchToken("character")||r.matchToken("characters")){const n=g(e,t,r);r.requireToken("from");const i=e.requireElement("expression",r);return{args:[i,n.from,n.to],op(e,r,i,a){if(n.toEnd)a=r.length;if(!n.includeStart)i++;if(n.includeEnd)a++;if(a==null||a==undefined)a=i+1;e.result=r.slice(i,a);return t.findNext(this,e)}}}if(r.matchToken("match")){r.matchToken("of");const n=e.parseElement("expression",r);let i="";if(r.matchOpToken("|")){i=r.requireToken("identifier").value}r.requireToken("from");const a=e.parseElement("expression",r);return{args:[a,n],op(e,r,n){e.result=new RegExp(n,i).exec(r);return t.findNext(this,e)}}}if(r.matchToken("matches")){r.matchToken("of");const n=e.parseElement("expression",r);let i="gu";if(r.matchOpToken("|")){i="g"+r.requireToken("identifier").value.replace("g","")}console.log("flags",i);r.requireToken("from");const a=e.parseElement("expression",r);return{args:[a,n],op(e,r,n){e.result=new w(n,i,r);return t.findNext(this,e)}}}}));t.addCommand("increment",(function(e,t,r){if(!r.matchToken("increment"))return;var n;var i=e.parseElement("assignableExpression",r);if(r.matchToken("by")){n=e.requireElement("expression",r)}var a={type:"implicitIncrementOp",target:i,args:[i,n],op:function(e,t,r){t=t?parseFloat(t):0;r=n?parseFloat(r):1;var i=t+r;e.result=i;return i},evaluate:function(e){return t.unifiedEval(this,e)}};return k(e,t,r,i,a)}));t.addCommand("decrement",(function(e,t,r){if(!r.matchToken("decrement"))return;var n;var i=e.parseElement("assignableExpression",r);if(r.matchToken("by")){n=e.requireElement("expression",r)}var a={type:"implicitDecrementOp",target:i,args:[i,n],op:function(e,t,r){t=t?parseFloat(t):0;r=n?parseFloat(r):1;var i=t-r;e.result=i;return i},evaluate:function(e){return t.unifiedEval(this,e)}};return k(e,t,r,i,a)}));function S(e,t){var r="text";var n;e.matchToken("a")||e.matchToken("an");if(e.matchToken("json")||e.matchToken("Object")){r="json"}else if(e.matchToken("response")){r="response"}else if(e.matchToken("html")){r="html"}else if(e.matchToken("text")){}else{n=t.requireElement("dotOrColonPath",e).evaluate()}return{type:r,conversion:n}}t.addCommand("fetch",(function(e,t,r){if(!r.matchToken("fetch"))return;var n=e.requireElement("stringLike",r);if(r.matchToken("as")){var i=S(r,e)}if(r.matchToken("with")&&r.currentToken().value!=="{"){var a=e.parseElement("nakedNamedArgumentList",r)}else{var a=e.parseElement("objectLiteral",r)}if(i==null&&r.matchToken("as")){i=S(r,e)}var o=i?i.type:"text";var s=i?i.conversion:null;var u={url:n,argExpressions:a,args:[n,a],op:function(e,r,n){var i=n||{};i["sender"]=e.me;i["headers"]=i["headers"]||{};var a=new AbortController;let l=e.me.addEventListener("fetch:abort",(function(){a.abort()}),{once:true});i["signal"]=a.signal;t.triggerEvent(e.me,"hyperscript:beforeFetch",i);t.triggerEvent(e.me,"fetch:beforeRequest",i);n=i;var c=false;if(n.timeout){setTimeout((function(){if(!c){a.abort()}}),n.timeout)}return fetch(r,n).then((function(r){let n={response:r};t.triggerEvent(e.me,"fetch:afterResponse",n);r=n.response;if(o==="response"){e.result=r;t.triggerEvent(e.me,"fetch:afterRequest",{result:r});c=true;return t.findNext(u,e)}if(o==="json"){return r.json().then((function(r){e.result=r;t.triggerEvent(e.me,"fetch:afterRequest",{result:r});c=true;return t.findNext(u,e)}))}return r.text().then((function(r){if(s)r=t.convertValue(r,s);if(o==="html")r=t.convertValue(r,"Fragment");e.result=r;t.triggerEvent(e.me,"fetch:afterRequest",{result:r});c=true;return t.findNext(u,e)}))})).catch((function(r){t.triggerEvent(e.me,"fetch:error",{reason:r});throw r})).finally((function(){e.me.removeEventListener("fetch:abort",l)}))}};return u}))}function y(e){e.addCommand("settle",(function(e,t,r){if(r.matchToken("settle")){if(!e.commandBoundary(r.currentToken())){var n=e.requireElement("expression",r)}else{var n=e.requireElement("implicitMeTarget",r)}var i={type:"settleCmd",args:[n],op:function(e,r){t.nullCheck(r,n);var a=null;var o=false;var s=false;var u=new Promise((function(e){a=e}));r.addEventListener("transitionstart",(function(){s=true}),{once:true});setTimeout((function(){if(!s&&!o){a(t.findNext(i,e))}}),500);r.addEventListener("transitionend",(function(){if(!o){a(t.findNext(i,e))}}),{once:true});return u},execute:function(e){return t.unifiedExec(this,e)}};return i}}));e.addCommand("add",(function(e,t,r){if(r.matchToken("add")){var n=e.parseElement("classRef",r);var i=null;var a=null;if(n==null){i=e.parseElement("attributeRef",r);if(i==null){a=e.parseElement("styleLiteral",r);if(a==null){e.raiseParseError(r,"Expected either a class reference or attribute expression")}}}else{var o=[n];while(n=e.parseElement("classRef",r)){o.push(n)}}if(r.matchToken("to")){var s=e.requireElement("expression",r)}else{var s=e.requireElement("implicitMeTarget",r)}if(r.matchToken("when")){if(a){e.raiseParseError(r,"Only class and properties are supported with a when clause")}var u=e.requireElement("expression",r)}if(o){return{classRefs:o,to:s,args:[s,o],op:function(e,r,n){t.nullCheck(r,s);t.forEach(n,(function(n){t.implicitLoop(r,(function(r){if(u){e.result=r;let i=t.evaluateNoPromise(u,e);if(i){if(r instanceof Element)r.classList.add(n.className)}else{if(r instanceof Element)r.classList.remove(n.className)}e.result=null}else{if(r instanceof Element)r.classList.add(n.className)}}))}));return t.findNext(this,e)}}}else if(i){return{type:"addCmd",attributeRef:i,to:s,args:[s],op:function(e,r,n){t.nullCheck(r,s);t.implicitLoop(r,(function(r){if(u){e.result=r;let n=t.evaluateNoPromise(u,e);if(n){r.setAttribute(i.name,i.value)}else{r.removeAttribute(i.name)}e.result=null}else{r.setAttribute(i.name,i.value)}}));return t.findNext(this,e)},execute:function(e){return t.unifiedExec(this,e)}}}else{return{type:"addCmd",cssDeclaration:a,to:s,args:[s,a],op:function(e,r,n){t.nullCheck(r,s);t.implicitLoop(r,(function(e){e.style.cssText+=n}));return t.findNext(this,e)},execute:function(e){return t.unifiedExec(this,e)}}}}}));e.addGrammarElement("styleLiteral",(function(e,t,r){if(!r.matchOpToken("{"))return;var n=[""];var i=[];while(r.hasMore()){if(r.matchOpToken("\\")){r.consumeToken()}else if(r.matchOpToken("}")){break}else if(r.matchToken("$")){var a=r.matchOpToken("{");var o=e.parseElement("expression",r);if(a)r.requireOpToken("}");i.push(o);n.push("")}else{var s=r.consumeToken();n[n.length-1]+=r.source.substring(s.start,s.end)}n[n.length-1]+=r.lastWhitespace()}return{type:"styleLiteral",args:[i],op:function(e,t){var r="";n.forEach((function(e,n){r+=e;if(n in t)r+=t[n]}));return r},evaluate:function(e){return t.unifiedEval(this,e)}}}));e.addCommand("remove",(function(e,t,r){if(r.matchToken("remove")){var n=e.parseElement("classRef",r);var i=null;var a=null;if(n==null){i=e.parseElement("attributeRef",r);if(i==null){a=e.parseElement("expression",r);if(a==null){e.raiseParseError(r,"Expected either a class reference, attribute expression or value expression")}}}else{var o=[n];while(n=e.parseElement("classRef",r)){o.push(n)}}if(r.matchToken("from")){var s=e.requireElement("expression",r)}else{if(a==null){var s=e.requireElement("implicitMeTarget",r)}}if(a){return{elementExpr:a,from:s,args:[a,s],op:function(e,r,n){t.nullCheck(r,a);t.implicitLoop(r,(function(e){if(e.parentElement&&(n==null||n.contains(e))){e.parentElement.removeChild(e)}}));return t.findNext(this,e)}}}else{return{classRefs:o,attributeRef:i,elementExpr:a,from:s,args:[o,s],op:function(e,r,n){t.nullCheck(n,s);if(r){t.forEach(r,(function(e){t.implicitLoop(n,(function(t){t.classList.remove(e.className)}))}))}else{t.implicitLoop(n,(function(e){e.removeAttribute(i.name)}))}return t.findNext(this,e)}}}}}));e.addCommand("toggle",(function(e,t,r){if(r.matchToken("toggle")){r.matchAnyToken("the","my");if(r.currentToken().type==="STYLE_REF"){let t=r.consumeToken();var n=t.value.substr(1);var a=true;var o=i(e,r,n);if(r.matchToken("of")){r.pushFollow("with");try{var s=e.requireElement("expression",r)}finally{r.popFollow()}}else{var s=e.requireElement("implicitMeTarget",r)}}else if(r.matchToken("between")){var u=true;var l=e.parseElement("classRef",r);r.requireToken("and");var c=e.requireElement("classRef",r)}else{var l=e.parseElement("classRef",r);var f=null;if(l==null){f=e.parseElement("attributeRef",r);if(f==null){e.raiseParseError(r,"Expected either a class reference or attribute expression")}}else{var m=[l];while(l=e.parseElement("classRef",r)){m.push(l)}}}if(a!==true){if(r.matchToken("on")){var s=e.requireElement("expression",r)}else{var s=e.requireElement("implicitMeTarget",r)}}if(r.matchToken("for")){var p=e.requireElement("expression",r)}else if(r.matchToken("until")){var h=e.requireElement("dotOrColonPath",r,"Expected event name");if(r.matchToken("from")){var v=e.requireElement("expression",r)}}var d={classRef:l,classRef2:c,classRefs:m,attributeRef:f,on:s,time:p,evt:h,from:v,toggle:function(e,r,n,i){t.nullCheck(e,s);if(a){t.implicitLoop(e,(function(e){o("toggle",e)}))}else if(u){t.implicitLoop(e,(function(e){if(e.classList.contains(r.className)){e.classList.remove(r.className);e.classList.add(n.className)}else{e.classList.add(r.className);e.classList.remove(n.className)}}))}else if(i){t.forEach(i,(function(r){t.implicitLoop(e,(function(e){e.classList.toggle(r.className)}))}))}else{t.forEach(e,(function(e){if(e.hasAttribute(f.name)){e.removeAttribute(f.name)}else{e.setAttribute(f.name,f.value)}}))}},args:[s,p,h,v,l,c,m],op:function(e,r,n,i,a,o,s,u){if(n){return new Promise((function(i){d.toggle(r,o,s,u);setTimeout((function(){d.toggle(r,o,s,u);i(t.findNext(d,e))}),n)}))}else if(i){return new Promise((function(n){var l=a||e.me;l.addEventListener(i,(function(){d.toggle(r,o,s,u);n(t.findNext(d,e))}),{once:true});d.toggle(r,o,s,u)}))}else{this.toggle(r,o,s,u);return t.findNext(d,e)}}};return d}}));var t={display:function(r,n,i){if(i){n.style.display=i}else if(r==="toggle"){if(getComputedStyle(n).display==="none"){t.display("show",n,i)}else{t.display("hide",n,i)}}else if(r==="hide"){const t=e.runtime.getInternalData(n);if(t.originalDisplay==null){t.originalDisplay=n.style.display}n.style.display="none"}else{const t=e.runtime.getInternalData(n);if(t.originalDisplay&&t.originalDisplay!=="none"){n.style.display=t.originalDisplay}else{n.style.removeProperty("display")}}},visibility:function(e,r,n){if(n){r.style.visibility=n}else if(e==="toggle"){if(getComputedStyle(r).visibility==="hidden"){t.visibility("show",r,n)}else{t.visibility("hide",r,n)}}else if(e==="hide"){r.style.visibility="hidden"}else{r.style.visibility="visible"}},opacity:function(e,r,n){if(n){r.style.opacity=n}else if(e==="toggle"){if(getComputedStyle(r).opacity==="0"){t.opacity("show",r,n)}else{t.opacity("hide",r,n)}}else if(e==="hide"){r.style.opacity="0"}else{r.style.opacity="1"}}};var n=function(e,t,r){var n;var i=r.currentToken();if(i.value==="when"||i.value==="with"||e.commandBoundary(i)){n=e.parseElement("implicitMeTarget",r)}else{n=e.parseElement("expression",r)}return n};var i=function(e,n,i){var a=r.defaultHideShowStrategy;var o=t;if(r.hideShowStrategies){o=Object.assign(o,r.hideShowStrategies)}i=i||a||"display";var s=o[i];if(s==null){e.raiseParseError(n,"Unknown show/hide strategy : "+i)}return s};e.addCommand("hide",(function(e,t,r){if(r.matchToken("hide")){var a=n(e,t,r);var o=null;if(r.matchToken("with")){o=r.requireTokenType("IDENTIFIER","STYLE_REF").value;if(o.indexOf("*")===0){o=o.substr(1)}}var s=i(e,r,o);return{target:a,args:[a],op:function(e,r){t.nullCheck(r,a);t.implicitLoop(r,(function(e){s("hide",e)}));return t.findNext(this,e)}}}}));e.addCommand("show",(function(e,t,r){if(r.matchToken("show")){var a=n(e,t,r);var o=null;if(r.matchToken("with")){o=r.requireTokenType("IDENTIFIER","STYLE_REF").value;if(o.indexOf("*")===0){o=o.substr(1)}}var s=null;if(r.matchOpToken(":")){var u=r.consumeUntilWhitespace();r.matchTokenType("WHITESPACE");s=u.map((function(e){return e.value})).join("")}if(r.matchToken("when")){var l=e.requireElement("expression",r)}var c=i(e,r,o);return{target:a,when:l,args:[a],op:function(e,r){t.nullCheck(r,a);t.implicitLoop(r,(function(r){if(l){e.result=r;let n=t.evaluateNoPromise(l,e);if(n){c("show",r,s)}else{c("hide",r)}e.result=null}else{c("show",r,s)}}));return t.findNext(this,e)}}}}));e.addCommand("take",(function(e,t,r){if(r.matchToken("take")){let u=null;let l=[];while(u=e.parseElement("classRef",r)){l.push(u)}var n=null;var i=null;let c=l.length>0;if(!c){n=e.parseElement("attributeRef",r);if(n==null){e.raiseParseError(r,"Expected either a class reference or attribute expression")}if(r.matchToken("with")){i=e.requireElement("expression",r)}}if(r.matchToken("from")){var a=e.requireElement("expression",r)}if(r.matchToken("for")){var o=e.requireElement("expression",r)}else{var o=e.requireElement("implicitMeTarget",r)}if(c){var s={classRefs:l,from:a,forElt:o,args:[l,a,o],op:function(e,r,n,i){t.nullCheck(i,o);t.implicitLoop(r,(function(e){var r=e.className;if(n){t.implicitLoop(n,(function(e){e.classList.remove(r)}))}else{t.implicitLoop(e,(function(e){e.classList.remove(r)}))}t.implicitLoop(i,(function(e){e.classList.add(r)}))}));return t.findNext(this,e)}};return s}else{var s={attributeRef:n,from:a,forElt:o,args:[a,o,i],op:function(e,r,i,s){t.nullCheck(r,a);t.nullCheck(i,o);t.implicitLoop(r,(function(e){if(!s){e.removeAttribute(n.name)}else{e.setAttribute(n.name,s)}}));t.implicitLoop(i,(function(e){e.setAttribute(n.name,n.value||"")}));return t.findNext(this,e)}};return s}}}));function a(t,r,n,i){if(n!=null){var a=t.resolveSymbol(n,r)}else{var a=r}if(a instanceof Element||a instanceof HTMLDocument){while(a.firstChild)a.removeChild(a.firstChild);a.append(e.runtime.convertValue(i,"Fragment"));t.processNode(a)}else{if(n!=null){t.setSymbol(n,r,null,i)}else{throw"Don't know how to put a value into "+typeof r}}}e.addCommand("put",(function(e,t,r){if(r.matchToken("put")){var n=e.requireElement("expression",r);var i=r.matchAnyToken("into","before","after");if(i==null&&r.matchToken("at")){r.matchToken("the");i=r.matchAnyToken("start","end");r.requireToken("of")}if(i==null){e.raiseParseError(r,"Expected one of 'into', 'before', 'at start of', 'at end of', 'after'")}var o=e.requireElement("expression",r);var s=i.value;var u=false;var l=false;var c=null;var f=null;if(o.type==="arrayIndex"&&s==="into"){u=true;f=o.prop;c=o.root}else if(o.prop&&o.root&&s==="into"){f=o.prop.value;c=o.root}else if(o.type==="symbol"&&s==="into"){l=true;f=o.name}else if(o.type==="attributeRef"&&s==="into"){var m=true;f=o.name;c=e.requireElement("implicitMeTarget",r)}else if(o.type==="styleRef"&&s==="into"){var p=true;f=o.name;c=e.requireElement("implicitMeTarget",r)}else if(o.attribute&&s==="into"){var m=o.attribute.type==="attributeRef";var p=o.attribute.type==="styleRef";f=o.attribute.name;c=o.root}else{c=o}var h={target:o,operation:s,symbolWrite:l,value:n,args:[c,f,n],op:function(e,r,n,i){if(l){a(t,e,n,i)}else{t.nullCheck(r,c);if(s==="into"){if(m){t.implicitLoop(r,(function(e){e.setAttribute(n,i)}))}else if(p){t.implicitLoop(r,(function(e){e.style[n]=i}))}else if(u){r[n]=i}else{t.implicitLoop(r,(function(e){a(t,e,n,i)}))}}else{var o=s==="before"?Element.prototype.before:s==="after"?Element.prototype.after:s==="start"?Element.prototype.prepend:s==="end"?Element.prototype.append:Element.prototype.append;t.implicitLoop(r,(function(e){o.call(e,i instanceof Node?i:t.convertValue(i,"Fragment"));if(e.parentElement){t.processNode(e.parentElement)}else{t.processNode(e)}}))}}return t.findNext(this,e)}};return h}}));function o(e,t,r){var n;if(r.matchToken("the")||r.matchToken("element")||r.matchToken("elements")||r.currentToken().type==="CLASS_REF"||r.currentToken().type==="ID_REF"||r.currentToken().op&&r.currentToken().value==="<"){e.possessivesDisabled=true;try{n=e.parseElement("expression",r)}finally{delete e.possessivesDisabled}if(r.matchOpToken("'")){r.requireToken("s")}}else if(r.currentToken().type==="IDENTIFIER"&&r.currentToken().value==="its"){var i=r.matchToken("its");n={type:"pseudopossessiveIts",token:i,name:i.value,evaluate:function(e){return t.resolveSymbol("it",e)}}}else{r.matchToken("my")||r.matchToken("me");n=e.parseElement("implicitMeTarget",r)}return n}e.addCommand("transition",(function(e,t,n){if(n.matchToken("transition")){var i=o(e,t,n);var a=[];var s=[];var u=[];var l=n.currentToken();while(!e.commandBoundary(l)&&l.value!=="over"&&l.value!=="using"){if(n.currentToken().type==="STYLE_REF"){let e=n.consumeToken();let t=e.value.substr(1);a.push({type:"styleRefValue",evaluate:function(){return t}})}else{a.push(e.requireElement("stringLike",n))}if(n.matchToken("from")){s.push(e.requireElement("expression",n))}else{s.push(null)}n.requireToken("to");if(n.matchToken("initial")){u.push({type:"initial_literal",evaluate:function(){return"initial"}})}else{u.push(e.requireElement("expression",n))}l=n.currentToken()}if(n.matchToken("over")){var c=e.requireElement("expression",n)}else if(n.matchToken("using")){var f=e.requireElement("expression",n)}var m={to:u,args:[i,a,s,u,f,c],op:function(e,n,a,o,s,u,l){t.nullCheck(n,i);var c=[];t.implicitLoop(n,(function(e){var n=new Promise((function(n,i){var c=e.style.transition;if(l){e.style.transition="all "+l+"ms ease-in"}else if(u){e.style.transition=u}else{e.style.transition=r.defaultTransition}var f=t.getInternalData(e);var m=getComputedStyle(e);var p={};for(var h=0;he.forEach((e=>S(e))))).then((()=>n((function(){a();k.processNode(document.documentElement);e.document.addEventListener("htmx:load",(function(e){k.processNode(e.detail.elt)}))}))));function n(e){if(document.readyState!=="loading"){setTimeout(e)}else{document.addEventListener("DOMContentLoaded",e)}}function i(){var e=document.querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function a(){var e=i();if(e){Object.assign(r,e)}}}const S=Object.assign(b,{config:r,use(e){e(S)},internals:{lexer:x,parser:g,runtime:k,Lexer:n,Tokens:i,Parser:a,Runtime:o},ElementCollection:m,addFeature:g.addFeature.bind(g),addCommand:g.addCommand.bind(g),addLeafExpression:g.addLeafExpression.bind(g),addIndirectExpression:g.addIndirectExpression.bind(g),evaluate:k.evaluate.bind(k),parse:k.parse.bind(k),processNode:k.processNode.bind(k),version:"0.9.12",browserInit:w});return S})); diff --git a/main/static/main/js/htmx.min.js b/main/static/main/js/htmx.min.js new file mode 100644 index 00000000..c11fbbdf --- /dev/null +++ b/main/static/main/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.2"};Q.onLoad=$;Q.process=Dt;Q.on=be;Q.off=we;Q.trigger=de;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=Bn;Q.removeExtension=Un;Q.logAll=z;Q.logNone=J;Q.parseInterval=h;Q._=_;const n={addTriggerHandler:Et,bodyContains:le,canAccessLocalStorage:j,findThisElement:Ee,filterValues:hn,swap:ze,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:T,getExpressionVars:Cn,getHeaders:dn,getInputValues:cn,getInternalData:ie,getSwapSpecification:pn,getTriggerSpecs:lt,getTarget:Ce,makeFragment:D,mergeObjects:ue,makeSettleInfo:xn,oobSwap:Te,querySelectorExt:ae,settleImmediately:Gt,shouldCancel:ht,triggerEvent:de,triggerErrorEvent:fe,withExtensions:Bt};const v=["get","post","put","delete","patch"];const O=v.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");const R=e("head");function e(e,t=false){return new RegExp(`<${e}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${e}>`,t?"gim":"im")}function h(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 f(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 k(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 D(e){const t=e.replace(R,"");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){k(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 U(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 d(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||f(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(d(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(d(H(e,!!n)).querySelectorAll(i(t)))}}var me=function(t,e,n){const r=d(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 ae(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(d(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 ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Oe(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=d(n)}const r={shouldSwap:true,target:e,fragment:t};if(!de(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){_e(s,e,e,t,i)}se(i.elts,function(e){de(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(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=d(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Re(t,i);u.tasks.push(function(){Re(t,s)})}}})}function Ne(e){return function(){o(e,Q.config.addedClass);Dt(ce(e));Ae(d(e));de(e,"htmx:load")}}function Ae(e){const t="[autofocus]";const n=G(f(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}function ze(e,t,r,o){if(!o){o={}}e=y(e);const n=document.activeElement;let i={};try{i={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const s=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=D(t);s.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){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)){n=e.target!==undefined?e.target:n}else{e={value:e}}de(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){fe(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=h(b(o,/[,\[\s]/));b(o,We);var i=rt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const a={trigger:u};var i=rt(e,o,"event");if(i){a.eventFilter=i}while(o.length>0&&o[0]!==","){b(o,We);const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=h(b(o,x))}else if(f==="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 d=ot(o);if(d.length>0){s+=" "+d}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=ot(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=h(b(o,x))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=b(o,x)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=ot(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=b(o,x)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}}r.push(a)}}if(o.length===l){fe(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(f(e,"form")){return[{trigger:"submit"}]}else if(f(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(f(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 at(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function dt(t,n,e){if(t instanceof HTMLAnchorElement&&at(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){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(ft(n)){a(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ce(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(f(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;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function mt(s,l,e,u,c){const a=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||ht(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(!f(ce(e.target),u.target)){return}}if(u.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(u.changed){const n=ie(o);const r=o.value;if(n.lastValue===r){return}n.lastValue=r}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(u.throttle>0){if(!a.throttle){de(s,"htmx:trigger");l(s,e);a.throttle=E().setTimeout(function(){a.throttle=null},u.throttle)}}else if(u.delay>0){a.delayed=E().setTimeout(function(){de(s,"htmx:trigger");l(s,e)},u.delay)}else{de(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")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){de(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){de(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)){a(n);return}he(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=ae(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(ft(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){ke(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){fe(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){de(ne().body,"htmx:historyCacheMissLoad",i);const e=D(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=jt();const r=xn(n);Dn(e.title);Ve(n,t,r);Gt(r.tasks);Ut=o;de(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(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=D(t.content);const r=jt();const o=xn(r);Dn(n.title);Ve(r,n,o);Gt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Ut=e;de(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","");e.setAttribute("data-disabled-by-htmx","")});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");e.removeAttribute("data-disabled-by-htmx")}})}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){de(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});de(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function un(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}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 a=ee(c,"name");rn(a,c.value,o)}const u=Se(e,"hx-include");se(u,function(e){sn(n,r,i,ce(e),l);if(!f(e,"form")){se(d(e).querySelectorAll(it),function(e){sn(n,r,i,e,l)})}});un(r,o);return{errors:i,formData:r,values:An(r)}}function an(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 fn(e){e=Ln(e);let n="";e.forEach(function(e,t){n=an(n,t,e)});return n}function dn(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 hn(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=U(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 a=l.substr(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const d=l.substr("focus-scroll:".length);r.focusScroll=d=="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"||f(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function yn(t,n,r){let o=null;Bt(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 fn(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(ae(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(ae(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{fe(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 On(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 Rn(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(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 he(e,t,null,null,{targetOverride:y(n),returnPromise:true})}else{return he(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 he(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 de(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"&&!(e[n]instanceof Blob)){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 if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(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 he(t,n,r,o,i,D){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){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let c=ie(r);const a=c.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const N=ee(a,"formmethod");if(N!=null){if(N.toLowerCase()!=="dialog"){t=N}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(de(r,"htmx:confirm",G)===false){oe(s);return e}}let d=r;let h=re(r,"hx-sync");let g=null;let F=false;if(h){const A=h.split(":");const I=A[0].trim();if(I==="this"){d=Ee(r,"hx-sync")}else{d=ce(ae(r,I))}h=(A[1]||"drop").trim();c=ie(d);if(h==="drop"&&c.xhr&&c.abortable!==true){oe(s);return e}else if(h==="abort"){if(c.xhr){oe(s);return e}else{F=true}}else if(h==="replace"){de(d,"htmx:abort")}else if(h.indexOf("queue")===0){const Z=h.split(" ");g=(Z[1]||"last").trim()}}if(c.xhr){if(c.abortable){de(d,"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(){he(t,n,r,o,i)})}else if(g==="all"){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){he(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 B=re(r,"hx-prompt");if(B){var y=prompt(B);if(y===null||!de(r,"htmx:prompt",{prompt:y,target:u})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let x=dn(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 U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){un(j,Ln(i.values))}const V=Ln(Cn(r));const w=un(j,V);let v=hn(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(!de(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){de(r,"htmx:validation:halted",C);oe(s);m();return e}const $=n.split("#");const z=$[0];const O=$[1];let R=n;if(E){R=z;const Y=!v.keys().next().done;if(Y){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=fn(v);if(O){R+="#"+O}}}if(!qn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in x){if(x.hasOwnProperty(k)){const W=x[k];On(p,k,W)}}}const H={xhr:p,target:u,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Tn(r);H.pathInfo.responsePath=Rn(p);M(r,H);if(H.keepIndicators!==true){en(T,q)}de(r,"htmx:afterRequest",H);de(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){de(e,"htmx:afterRequest",H);de(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ue({error:e},H));throw e}};p.onerror=function(){en(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){en(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){en(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!de(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){de(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});de(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 a=null;let f=null;if(l){a="push";f=l}else if(u){a="replace";f=u}else if(c){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function Pn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function kn(e){for(var t=0;t0){E().setTimeout(e,y.swapDelay)}else{e()}}if(f){fe(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 Bn(e,t){if(t.init){t.init(n)}Xn[e]=ue(Fn(),t)}function Un(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;Dt(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){de(e,"htmx:restored",{document:ne(),triggerEvent:de})})}else{if(n){n(e)}}};E().setTimeout(function(){de(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/main/tables.py b/main/tables.py deleted file mode 100644 index 95e710e1..00000000 --- a/main/tables.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Tables for the main app.""" - -import django_tables2 as tables - -restart_column_template = ( - "{text}".format( - href="\"{% url 'main:restart' record.uuid%}\"", - message="You are about to restart process {{record.uuid}}. Are you sure?", - text="RESTART", - ) -) - -kill_column_template = ( - "{text}".format( - href="\"{% url 'main:kill' record.uuid%}\"", - message="You are about to kill process {{record.uuid}}. Are you sure?", - text="KILL", - ) -) - -flush_column_template = "FLUSH" - -logs_column_template = "LOGS" - - -class ProcessTable(tables.Table): - """Defines and Process Table for the data from the Process Manager.""" - - uuid = tables.Column(verbose_name="UUID") - name = tables.Column(verbose_name="Name") - user = tables.Column(verbose_name="User") - session = tables.Column(verbose_name="Session") - status_code = tables.Column(verbose_name="Status Code") - exit_code = tables.Column(verbose_name="Exit Code") - logs = tables.TemplateColumn(logs_column_template, verbose_name="Logs") - restart = tables.TemplateColumn(restart_column_template, verbose_name="Restart") - flush = tables.TemplateColumn(flush_column_template, verbose_name="Flush") - kill = tables.TemplateColumn(kill_column_template, verbose_name="Kill") diff --git a/main/templates/main/base.html b/main/templates/main/base.html index 78b05266..e2c61726 100644 --- a/main/templates/main/base.html +++ b/main/templates/main/base.html @@ -2,22 +2,29 @@ {% load django_bootstrap5 %} - - - {% block title %}SITE NAME{% endblock %} + + + {% block title %} + SITE NAME + {% endblock title %} + + + {% bootstrap_css %} {% bootstrap_javascript %} - - - - -
- {% block content %}{% endblock %} -
- + diff --git a/main/templates/main/boot_process.html b/main/templates/main/boot_process.html deleted file mode 100644 index c9a527b2..00000000 --- a/main/templates/main/boot_process.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "main/base.html" %} - -{% block title %}Boot Process{% endblock title %} - -{% block content %} - -
- {% csrf_token %} - {{ form }} - -
- -{% endblock content %} diff --git a/main/templates/main/help.html b/main/templates/main/help.html new file mode 100644 index 00000000..356b71f4 --- /dev/null +++ b/main/templates/main/help.html @@ -0,0 +1,5 @@ +{% extends "main/base.html" %} +{% block content %} +

Help

+

This is the help page.

+{% endblock content %} diff --git a/main/templates/main/index.html b/main/templates/main/index.html index 1ec71552..8b2edf04 100644 --- a/main/templates/main/index.html +++ b/main/templates/main/index.html @@ -1,12 +1,12 @@ {% extends "main/base.html" %} -{% load render_table from django_tables2 %} - -{% block title %}Home{% endblock title %} - +{% block title %} + Home +{% endblock title %} {% block content %} -
- Boot - {% render_table table %} -
- + {% endblock content %} diff --git a/main/templates/main/logs.html b/main/templates/main/logs.html deleted file mode 100644 index a76d7877..00000000 --- a/main/templates/main/logs.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "main/base.html" %} - -{% block title %}Logs{% endblock title %} - -{% block content %} - -
-{{ log_text }} -
- -Return to table - -{% endblock content %} diff --git a/main/templates/main/navbar.html b/main/templates/main/navbar.html new file mode 100644 index 00000000..9470c58a --- /dev/null +++ b/main/templates/main/navbar.html @@ -0,0 +1,37 @@ + + diff --git a/main/templates/registration/login.html b/main/templates/registration/login.html index 417c5cc1..5220aea0 100644 --- a/main/templates/registration/login.html +++ b/main/templates/registration/login.html @@ -1,37 +1,26 @@ {% extends "../main/base.html" %} - +{% load crispy_forms_tags %} +{% load django_bootstrap5 %} {% block content %} - - {% if form.errors %} -

Your username and password didn't match. Please try again.

- {% endif %} - + {% if form.errors %}

Your username and password didn't match. Please try again.

{% endif %} {% if next %} {% if user.is_authenticated %} -

Your account doesn't have access to this page. To proceed, - please login with an account that has access.

+

+ Your account doesn't have access to this page. To proceed, + please login with an account that has access. +

{% else %}

Please login to see this page.

{% endif %} {% endif %} -
{% csrf_token %} - - - - - - - - - -
{{ form.username.label_tag }}{{ form.username }}
{{ form.password.label_tag }}{{ form.password }}
- + {{ form|crispy }} + {% bootstrap_button button_type="submit" content="Login" %}
- {# Assumes you set up the password_reset view in your URLconf #} -

Lost password?

- -{% endblock %} +

+ Lost password? +

+{% endblock content %} diff --git a/main/urls.py b/main/urls.py index 85795014..3d908fa8 100644 --- a/main/urls.py +++ b/main/urls.py @@ -8,9 +8,5 @@ urlpatterns = [ path("", views.index, name="index"), path("accounts/", include("django.contrib.auth.urls")), - path("restart/", views.restart_process, name="restart"), - path("kill/", views.kill_process, name="kill"), - path("flush/", views.flush_process, name="flush"), - path("logs/", views.logs, name="logs"), - path("boot_process/", views.BootProcessView.as_view(), name="boot_process"), + path("help/", views.HelpView.as_view(), name="help"), ] diff --git a/main/views.py b/main/views.py index 4ea9d564..a3a2e6a0 100644 --- a/main/views.py +++ b/main/views.py @@ -1,208 +1,20 @@ """Views for the main app.""" -import asyncio -import uuid -from enum import Enum - -import django_tables2 from django.contrib.auth.decorators import login_required -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.http import HttpRequest, HttpResponse from django.shortcuts import render -from django.urls import reverse, reverse_lazy -from django.views.generic.edit import FormView -from drunc.process_manager.process_manager_driver import ProcessManagerDriver -from drunc.utils.shell_utils import DecodedResponse, create_dummy_token_from_uname -from druncschema.process_manager_pb2 import ( - LogRequest, - ProcessInstance, - ProcessInstanceList, - ProcessQuery, - ProcessUUID, -) - -from .forms import BootProcessForm -from .tables import ProcessTable - - -def get_process_manager_driver() -> ProcessManagerDriver: - """Get a ProcessManagerDriver instance.""" - token = create_dummy_token_from_uname() - return ProcessManagerDriver("drunc:10054", token=token, aio_channel=True) - - -async def get_session_info() -> ProcessInstanceList: - """Get info about all sessions from process manager.""" - pmd = get_process_manager_driver() - query = ProcessQuery(names=[".*"]) - return await pmd.ps(query) +from django.views import View @login_required def index(request: HttpRequest) -> HttpResponse: """View that renders the index/home page.""" - val = asyncio.run(get_session_info()) - - status_enum_lookup = dict(item[::-1] for item in ProcessInstance.StatusCode.items()) - - table_data = [] - process_instances = val.data.values - for process_instance in process_instances: - metadata = process_instance.process_description.metadata - table_data.append( - { - "uuid": process_instance.uuid.uuid, - "name": metadata.name, - "user": metadata.user, - "session": metadata.session, - "status_code": status_enum_lookup[process_instance.status_code], - "exit_code": process_instance.return_code, - } - ) - - table = ProcessTable(table_data) - - # sort table data based on request parameters - table_configurator = django_tables2.RequestConfig(request) - table_configurator.configure(table) - - context = {"table": table} - - return render(request=request, context=context, template_name="main/index.html") - - -# an enum for process actions -class ProcessAction(Enum): - """Enum for process actions.""" - - RESTART = "restart" - KILL = "kill" - FLUSH = "flush" - - -async def _process_call(uuid: str, action: ProcessAction) -> None: - """Perform an action on a process with a given UUID. - - Args: - uuid: UUID of the process to be actioned. - action: Action to be performed {restart,kill}. - """ - pmd = get_process_manager_driver() - query = ProcessQuery(uuids=[ProcessUUID(uuid=uuid)]) - - match action: - case ProcessAction.RESTART: - await pmd.restart(query) - case ProcessAction.KILL: - await pmd.kill(query) - case ProcessAction.FLUSH: - await pmd.flush(query) - - -@login_required -def restart_process(request: HttpRequest, uuid: uuid.UUID) -> HttpResponse: - """Restart the process associated to the given UUID. - - Args: - request: HttpRequest object. This is not used in the function, but is required - by Django. - uuid: UUID of the process to be restarted. - - Returns: - HttpResponse, redirecting to the main page. - """ - asyncio.run(_process_call(str(uuid), ProcessAction.RESTART)) - return HttpResponseRedirect(reverse("main:index")) - - -@login_required -def kill_process(request: HttpRequest, uuid: uuid.UUID) -> HttpResponse: - """Kill the process associated to the given UUID. - - Args: - request: Django HttpRequest object (unused, but required by Django). - uuid: UUID of the process to be killed. - - Returns: - HttpResponse redirecting to the index page. - """ - asyncio.run(_process_call(str(uuid), ProcessAction.KILL)) - return HttpResponseRedirect(reverse("main:index")) - - -@login_required -def flush_process(request: HttpRequest, uuid: uuid.UUID) -> HttpResponse: - """Flush the process associated to the given UUID. - - Args: - request: Django HttpRequest object (unused, but required by Django). - uuid: UUID of the process to be flushed. - - Returns: - HttpResponse redirecting to the index page. - """ - asyncio.run(_process_call(str(uuid), ProcessAction.FLUSH)) - return HttpResponseRedirect(reverse("main:index")) - - -async def _get_process_logs(uuid: str) -> list[DecodedResponse]: - """Retrieve logs for a process from the process manager. - - Args: - uuid: UUID of the process. - - Returns: - The process logs. - """ - pmd = get_process_manager_driver() - query = ProcessQuery(uuids=[ProcessUUID(uuid=uuid)]) - request = LogRequest(query=query, how_far=100) - return [item async for item in pmd.logs(request)] - - -@login_required -def logs(request: HttpRequest, uuid: uuid.UUID) -> HttpResponse: - """Display the logs of a process. - - Args: - request: the triggering request. - uuid: identifier for the process. - - Returns: - The rendered page. - """ - logs_response = asyncio.run(_get_process_logs(str(uuid))) - context = dict(log_text="\n".join(val.data.line for val in logs_response)) - return render(request=request, context=context, template_name="main/logs.html") - - -async def _boot_process(user: str, data: dict[str, str | int]) -> None: - """Boot a process with the given data. - - Args: - user: the user to boot the process as. - data: the data for the process. - """ - pmd = get_process_manager_driver() - async for item in pmd.dummy_boot(user=user, **data): - pass - - -class BootProcessView(LoginRequiredMixin, FormView): # type: ignore [type-arg] - """View for the BootProcess form.""" - - template_name = "main/boot_process.html" - form_class = BootProcessForm - success_url = reverse_lazy("main:index") + return render(request=request, template_name="main/index.html") - def form_valid(self, form: BootProcessForm) -> HttpResponse: - """Boot a Process when valid form data has been POSTed. - Args: - form: the form instance that has been validated. +class HelpView(View): + """View that renders the help page.""" - Returns: - A redirect to the index page. - """ - asyncio.run(_boot_process("root", form.cleaned_data)) - return super().form_valid(form) + def get(self, request: HttpRequest) -> HttpResponse: + """Render the help page.""" + return render(request=request, template_name="main/help.html") diff --git a/manage.py b/manage.py index 5799dea9..7dbcab8b 100755 --- a/manage.py +++ b/manage.py @@ -7,7 +7,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dune_processes.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "drunc_ui.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/poetry.lock b/poetry.lock index 0a8a6eef..635d0f4f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -171,6 +171,39 @@ files = [ [package.extras] toml = ["tomli"] +[[package]] +name = "crispy-bootstrap5" +version = "2024.10" +description = "Bootstrap5 template pack for django-crispy-forms" +optional = false +python-versions = ">=3.8" +files = [ + {file = "crispy_bootstrap5-2024.10-py3-none-any.whl", hash = "sha256:59e91dac5e45a8c954af3fbcaa6804cd5aef4402f027af2f99a352b096c4016f"}, + {file = "crispy_bootstrap5-2024.10.tar.gz", hash = "sha256:55b442fe675dd95ad280123c7fe464f454186e90b8e5642e751f436c87627c44"}, +] + +[package.dependencies] +django = ">=4.2" +django-crispy-forms = ">=2.3" + +[package.extras] +test = ["pytest", "pytest-django"] + +[[package]] +name = "cssbeautifier" +version = "1.15.1" +description = "CSS unobfuscator and beautifier." +optional = false +python-versions = "*" +files = [ + {file = "cssbeautifier-1.15.1.tar.gz", hash = "sha256:9f7064362aedd559c55eeecf6b6bed65e05f33488dcbe39044f0403c26e1c006"}, +] + +[package.dependencies] +editorconfig = ">=0.12.2" +jsbeautifier = "*" +six = ">=1.13.0" + [[package]] name = "distlib" version = "0.3.8" @@ -184,13 +217,13 @@ files = [ [[package]] name = "django" -version = "5.1.1" +version = "5.1.2" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" files = [ - {file = "Django-5.1.1-py3-none-any.whl", hash = "sha256:71603f27dac22a6533fb38d83072eea9ddb4017fead6f67f2562a40402d61c3f"}, - {file = "Django-5.1.1.tar.gz", hash = "sha256:021ffb7fdab3d2d388bc8c7c2434eb9c1f6f4d09e6119010bbb1694dda286bc2"}, + {file = "Django-5.1.2-py3-none-any.whl", hash = "sha256:f11aa87ad8d5617171e3f77e1d5d16f004b79a2cf5d2e1d2b97a6a1f8e9ba5ed"}, + {file = "Django-5.1.2.tar.gz", hash = "sha256:bd7376f90c99f96b643722eee676498706c9fd7dc759f55ebfaf2c08ebcdf4f0"}, ] [package.dependencies] @@ -204,33 +237,50 @@ bcrypt = ["bcrypt"] [[package]] name = "django-bootstrap5" -version = "24.2" +version = "24.3" description = "Bootstrap 5 for Django" optional = false python-versions = ">=3.8" files = [ - {file = "django_bootstrap5-24.2-py3-none-any.whl", hash = "sha256:6a5d83e9ff1952f7c07c54cebcb76c85f09787b8b57eeb4ec07554cd583acc64"}, - {file = "django_bootstrap5-24.2.tar.gz", hash = "sha256:a3cee2b3d45745210c5b898af2917f310f44df746269fe09a93be28a0adc2a4b"}, + {file = "django_bootstrap5-24.3-py3-none-any.whl", hash = "sha256:98f7916f9f7f9c34dfe157f7c062a28ebf6d17ff95307bef260ba794ba34e2a0"}, + {file = "django_bootstrap5-24.3.tar.gz", hash = "sha256:ca5ec5568fc2a3fc239f0cb8ce41a7df3802b8c4af930d7f64451784ec7ddc16"}, ] [package.dependencies] Django = ">=4.2" +[package.extras] +jinja = ["Jinja2 (>=3.0,<4)"] + +[[package]] +name = "django-crispy-forms" +version = "2.3" +description = "Best way to have Django DRY forms" +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_crispy_forms-2.3-py3-none-any.whl", hash = "sha256:efc4c31e5202bbec6af70d383a35e12fc80ea769d464fb0e7fe21768bb138a20"}, + {file = "django_crispy_forms-2.3.tar.gz", hash = "sha256:2db17ae08527201be1273f0df789e5f92819e23dd28fec69cffba7f3762e1a38"}, +] + +[package.dependencies] +django = ">=4.2" + [[package]] name = "django-stubs" -version = "5.0.4" +version = "5.1.0" description = "Mypy stubs for Django" optional = false python-versions = ">=3.8" files = [ - {file = "django_stubs-5.0.4-py3-none-any.whl", hash = "sha256:c2502f5ecbae50c68f9a86d52b5b2447d8648fd205036dad0ccb41e19a445927"}, - {file = "django_stubs-5.0.4.tar.gz", hash = "sha256:78e3764488fdfd2695f12502136548ec22f8d4b1780541a835042b8238d11514"}, + {file = "django_stubs-5.1.0-py3-none-any.whl", hash = "sha256:b98d49a80aa4adf1433a97407102d068de26c739c405431d93faad96dd282c40"}, + {file = "django_stubs-5.1.0.tar.gz", hash = "sha256:86128c228b65e6c9a85e5dc56eb1c6f41125917dae0e21e6cfecdf1b27e630c5"}, ] [package.dependencies] asgiref = "*" django = "*" -django-stubs-ext = ">=5.0.4" +django-stubs-ext = ">=5.1.0" mypy = {version = ">=1.11.0,<1.12.0", optional = true, markers = "extra == \"compatible-mypy\""} types-PyYAML = "*" typing-extensions = ">=4.11.0" @@ -242,13 +292,13 @@ redis = ["redis"] [[package]] name = "django-stubs-ext" -version = "5.0.4" +version = "5.1.0" description = "Monkey-patching and extensions for django-stubs" optional = false python-versions = ">=3.8" files = [ - {file = "django_stubs_ext-5.0.4-py3-none-any.whl", hash = "sha256:910cbaff3d1e8e806a5c27d5ddd4088535aae8371ea921b7fd680fdfa5f14e30"}, - {file = "django_stubs_ext-5.0.4.tar.gz", hash = "sha256:85da065224204774208be29c7d02b4482d5a69218a728465c2fbe41725fdc819"}, + {file = "django_stubs_ext-5.1.0-py3-none-any.whl", hash = "sha256:a455fc222c90b30b29ad8c53319559f5b54a99b4197205ddbb385aede03b395d"}, + {file = "django_stubs_ext-5.1.0.tar.gz", hash = "sha256:ed7d51c0b731651879fc75f331fb0806d98b67bfab464e96e2724db6b46ef926"}, ] [package.dependencies] @@ -272,6 +322,30 @@ Django = ">=3.2" [package.extras] tablib = ["tablib"] +[[package]] +name = "djlint" +version = "1.35.2" +description = "HTML Template Linter and Formatter" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "djlint-1.35.2-py3-none-any.whl", hash = "sha256:4ba995bad378f2afa77c8ea56ba1c14429d9ff26a18e8ae23bc71eedb9152243"}, + {file = "djlint-1.35.2.tar.gz", hash = "sha256:318de9d4b9b0061a111f8f5164ecbacd8215f449dd4bd5a76d2a691c815ee103"}, +] + +[package.dependencies] +click = ">=8.0.1" +colorama = ">=0.4.4" +cssbeautifier = ">=1.14.4" +html-tag-names = ">=0.1.2" +html-void-elements = ">=0.1.0" +jsbeautifier = ">=1.14.4" +json5 = ">=0.9.11" +pathspec = ">=0.12.0" +PyYAML = ">=6.0" +regex = ">=2023" +tqdm = ">=4.62.2" + [[package]] name = "drunc" version = "0.10.4" @@ -327,6 +401,16 @@ url = "https://github.com/DUNE-DAQ/druncschema.git" reference = "HEAD" resolved_reference = "0210c612596dc2e3776d7a3a1651f97d4decb4cc" +[[package]] +name = "editorconfig" +version = "0.12.4" +description = "EditorConfig File Locator and Interpreter for Python" +optional = false +python-versions = "*" +files = [ + {file = "EditorConfig-0.12.4.tar.gz", hash = "sha256:24857fa1793917dd9ccf0c7810a07e05404ce9b823521c7dce22a4fb5d125f80"}, +] + [[package]] name = "filelock" version = "3.15.4" @@ -515,6 +599,28 @@ setproctitle = ["setproctitle"] testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] tornado = ["tornado (>=0.2)"] +[[package]] +name = "html-tag-names" +version = "0.1.2" +description = "List of known HTML tag names" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "html-tag-names-0.1.2.tar.gz", hash = "sha256:04924aca48770f36b5a41c27e4d917062507be05118acb0ba869c97389084297"}, + {file = "html_tag_names-0.1.2-py3-none-any.whl", hash = "sha256:eeb69ef21078486b615241f0393a72b41352c5219ee648e7c61f5632d26f0420"}, +] + +[[package]] +name = "html-void-elements" +version = "0.1.0" +description = "List of HTML void tag names." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "html-void-elements-0.1.0.tar.gz", hash = "sha256:931b88f84cd606fee0b582c28fcd00e41d7149421fb673e1e1abd2f0c4f231f0"}, + {file = "html_void_elements-0.1.0-py3-none-any.whl", hash = "sha256:784cf39db03cdeb017320d9301009f8f3480f9d7b254d0974272e80e0cb5e0d2"}, +] + [[package]] name = "identify" version = "2.6.0" @@ -540,6 +646,31 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "jsbeautifier" +version = "1.15.1" +description = "JavaScript unobfuscator and beautifier." +optional = false +python-versions = "*" +files = [ + {file = "jsbeautifier-1.15.1.tar.gz", hash = "sha256:ebd733b560704c602d744eafc839db60a1ee9326e30a2a80c4adb8718adc1b24"}, +] + +[package.dependencies] +editorconfig = ">=0.12.2" +six = ">=1.13.0" + +[[package]] +name = "json5" +version = "0.9.25" +description = "A Python implementation of the JSON5 data format." +optional = false +python-versions = ">=3.8" +files = [ + {file = "json5-0.9.25-py3-none-any.whl", hash = "sha256:34ed7d834b1341a86987ed52f3f76cd8ee184394906b6e22a1e0deb9ab294e8f"}, + {file = "json5-0.9.25.tar.gz", hash = "sha256:548e41b9be043f9426776f05df8635a00fe06104ea51ed24b67f908856e151ae"}, +] + [[package]] name = "kafka-python" version = "2.0.2" @@ -679,6 +810,17 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + [[package]] name = "platformdirs" version = "4.2.2" @@ -712,13 +854,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.8.0" +version = "4.0.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, - {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, + {file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"}, + {file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"}, ] [package.dependencies] @@ -932,6 +1074,109 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "regex" +version = "2024.9.11" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.8" +files = [ + {file = "regex-2024.9.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1494fa8725c285a81d01dc8c06b55287a1ee5e0e382d8413adc0a9197aac6408"}, + {file = "regex-2024.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0e12c481ad92d129c78f13a2a3662317e46ee7ef96c94fd332e1c29131875b7d"}, + {file = "regex-2024.9.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16e13a7929791ac1216afde26f712802e3df7bf0360b32e4914dca3ab8baeea5"}, + {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46989629904bad940bbec2106528140a218b4a36bb3042d8406980be1941429c"}, + {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a906ed5e47a0ce5f04b2c981af1c9acf9e8696066900bf03b9d7879a6f679fc8"}, + {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a091b0550b3b0207784a7d6d0f1a00d1d1c8a11699c1a4d93db3fbefc3ad35"}, + {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ddcd9a179c0a6fa8add279a4444015acddcd7f232a49071ae57fa6e278f1f71"}, + {file = "regex-2024.9.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b41e1adc61fa347662b09398e31ad446afadff932a24807d3ceb955ed865cc8"}, + {file = "regex-2024.9.11-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ced479f601cd2f8ca1fd7b23925a7e0ad512a56d6e9476f79b8f381d9d37090a"}, + {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:635a1d96665f84b292e401c3d62775851aedc31d4f8784117b3c68c4fcd4118d"}, + {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c0256beda696edcf7d97ef16b2a33a8e5a875affd6fa6567b54f7c577b30a137"}, + {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3ce4f1185db3fbde8ed8aa223fc9620f276c58de8b0d4f8cc86fd1360829edb6"}, + {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:09d77559e80dcc9d24570da3745ab859a9cf91953062e4ab126ba9d5993688ca"}, + {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7a22ccefd4db3f12b526eccb129390942fe874a3a9fdbdd24cf55773a1faab1a"}, + {file = "regex-2024.9.11-cp310-cp310-win32.whl", hash = "sha256:f745ec09bc1b0bd15cfc73df6fa4f726dcc26bb16c23a03f9e3367d357eeedd0"}, + {file = "regex-2024.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:01c2acb51f8a7d6494c8c5eafe3d8e06d76563d8a8a4643b37e9b2dd8a2ff623"}, + {file = "regex-2024.9.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2cce2449e5927a0bf084d346da6cd5eb016b2beca10d0013ab50e3c226ffc0df"}, + {file = "regex-2024.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b37fa423beefa44919e009745ccbf353d8c981516e807995b2bd11c2c77d268"}, + {file = "regex-2024.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:64ce2799bd75039b480cc0360907c4fb2f50022f030bf9e7a8705b636e408fad"}, + {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4cc92bb6db56ab0c1cbd17294e14f5e9224f0cc6521167ef388332604e92679"}, + {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d05ac6fa06959c4172eccd99a222e1fbf17b5670c4d596cb1e5cde99600674c4"}, + {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040562757795eeea356394a7fb13076ad4f99d3c62ab0f8bdfb21f99a1f85664"}, + {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6113c008a7780792efc80f9dfe10ba0cd043cbf8dc9a76ef757850f51b4edc50"}, + {file = "regex-2024.9.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e5fb5f77c8745a60105403a774fe2c1759b71d3e7b4ca237a5e67ad066c7199"}, + {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:54d9ff35d4515debf14bc27f1e3b38bfc453eff3220f5bce159642fa762fe5d4"}, + {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df5cbb1fbc74a8305b6065d4ade43b993be03dbe0f8b30032cced0d7740994bd"}, + {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7fb89ee5d106e4a7a51bce305ac4efb981536301895f7bdcf93ec92ae0d91c7f"}, + {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a738b937d512b30bf75995c0159c0ddf9eec0775c9d72ac0202076c72f24aa96"}, + {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e28f9faeb14b6f23ac55bfbbfd3643f5c7c18ede093977f1df249f73fd22c7b1"}, + {file = "regex-2024.9.11-cp311-cp311-win32.whl", hash = "sha256:18e707ce6c92d7282dfce370cd205098384b8ee21544e7cb29b8aab955b66fa9"}, + {file = "regex-2024.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:313ea15e5ff2a8cbbad96ccef6be638393041b0a7863183c2d31e0c6116688cf"}, + {file = "regex-2024.9.11-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b0d0a6c64fcc4ef9c69bd5b3b3626cc3776520a1637d8abaa62b9edc147a58f7"}, + {file = "regex-2024.9.11-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:49b0e06786ea663f933f3710a51e9385ce0cba0ea56b67107fd841a55d56a231"}, + {file = "regex-2024.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5b513b6997a0b2f10e4fd3a1313568e373926e8c252bd76c960f96fd039cd28d"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee439691d8c23e76f9802c42a95cfeebf9d47cf4ffd06f18489122dbb0a7ad64"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8f877c89719d759e52783f7fe6e1c67121076b87b40542966c02de5503ace42"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23b30c62d0f16827f2ae9f2bb87619bc4fba2044911e2e6c2eb1af0161cdb766"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85ab7824093d8f10d44330fe1e6493f756f252d145323dd17ab6b48733ff6c0a"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8dee5b4810a89447151999428fe096977346cf2f29f4d5e29609d2e19e0199c9"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98eeee2f2e63edae2181c886d7911ce502e1292794f4c5ee71e60e23e8d26b5d"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:57fdd2e0b2694ce6fc2e5ccf189789c3e2962916fb38779d3e3521ff8fe7a822"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d552c78411f60b1fdaafd117a1fca2f02e562e309223b9d44b7de8be451ec5e0"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a0b2b80321c2ed3fcf0385ec9e51a12253c50f146fddb2abbb10f033fe3d049a"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:18406efb2f5a0e57e3a5881cd9354c1512d3bb4f5c45d96d110a66114d84d23a"}, + {file = "regex-2024.9.11-cp312-cp312-win32.whl", hash = "sha256:e464b467f1588e2c42d26814231edecbcfe77f5ac414d92cbf4e7b55b2c2a776"}, + {file = "regex-2024.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:9e8719792ca63c6b8340380352c24dcb8cd7ec49dae36e963742a275dfae6009"}, + {file = "regex-2024.9.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c157bb447303070f256e084668b702073db99bbb61d44f85d811025fcf38f784"}, + {file = "regex-2024.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4db21ece84dfeefc5d8a3863f101995de646c6cb0536952c321a2650aa202c36"}, + {file = "regex-2024.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:220e92a30b426daf23bb67a7962900ed4613589bab80382be09b48896d211e92"}, + {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb1ae19e64c14c7ec1995f40bd932448713d3c73509e82d8cd7744dc00e29e86"}, + {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f47cd43a5bfa48f86925fe26fbdd0a488ff15b62468abb5d2a1e092a4fb10e85"}, + {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9d4a76b96f398697fe01117093613166e6aa8195d63f1b4ec3f21ab637632963"}, + {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ea51dcc0835eea2ea31d66456210a4e01a076d820e9039b04ae8d17ac11dee6"}, + {file = "regex-2024.9.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7aaa315101c6567a9a45d2839322c51c8d6e81f67683d529512f5bcfb99c802"}, + {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c57d08ad67aba97af57a7263c2d9006d5c404d721c5f7542f077f109ec2a4a29"}, + {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8404bf61298bb6f8224bb9176c1424548ee1181130818fcd2cbffddc768bed8"}, + {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dd4490a33eb909ef5078ab20f5f000087afa2a4daa27b4c072ccb3cb3050ad84"}, + {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:eee9130eaad130649fd73e5cd92f60e55708952260ede70da64de420cdcad554"}, + {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a2644a93da36c784e546de579ec1806bfd2763ef47babc1b03d765fe560c9f8"}, + {file = "regex-2024.9.11-cp313-cp313-win32.whl", hash = "sha256:e997fd30430c57138adc06bba4c7c2968fb13d101e57dd5bb9355bf8ce3fa7e8"}, + {file = "regex-2024.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:042c55879cfeb21a8adacc84ea347721d3d83a159da6acdf1116859e2427c43f"}, + {file = "regex-2024.9.11-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:35f4a6f96aa6cb3f2f7247027b07b15a374f0d5b912c0001418d1d55024d5cb4"}, + {file = "regex-2024.9.11-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:55b96e7ce3a69a8449a66984c268062fbaa0d8ae437b285428e12797baefce7e"}, + {file = "regex-2024.9.11-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb130fccd1a37ed894824b8c046321540263013da72745d755f2d35114b81a60"}, + {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:323c1f04be6b2968944d730e5c2091c8c89767903ecaa135203eec4565ed2b2b"}, + {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be1c8ed48c4c4065ecb19d882a0ce1afe0745dfad8ce48c49586b90a55f02366"}, + {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5b029322e6e7b94fff16cd120ab35a253236a5f99a79fb04fda7ae71ca20ae8"}, + {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6fff13ef6b5f29221d6904aa816c34701462956aa72a77f1f151a8ec4f56aeb"}, + {file = "regex-2024.9.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:587d4af3979376652010e400accc30404e6c16b7df574048ab1f581af82065e4"}, + {file = "regex-2024.9.11-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:079400a8269544b955ffa9e31f186f01d96829110a3bf79dc338e9910f794fca"}, + {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f9268774428ec173654985ce55fc6caf4c6d11ade0f6f914d48ef4719eb05ebb"}, + {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:23f9985c8784e544d53fc2930fc1ac1a7319f5d5332d228437acc9f418f2f168"}, + {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:ae2941333154baff9838e88aa71c1d84f4438189ecc6021a12c7573728b5838e"}, + {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e93f1c331ca8e86fe877a48ad64e77882c0c4da0097f2212873a69bbfea95d0c"}, + {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:846bc79ee753acf93aef4184c040d709940c9d001029ceb7b7a52747b80ed2dd"}, + {file = "regex-2024.9.11-cp38-cp38-win32.whl", hash = "sha256:c94bb0a9f1db10a1d16c00880bdebd5f9faf267273b8f5bd1878126e0fbde771"}, + {file = "regex-2024.9.11-cp38-cp38-win_amd64.whl", hash = "sha256:2b08fce89fbd45664d3df6ad93e554b6c16933ffa9d55cb7e01182baaf971508"}, + {file = "regex-2024.9.11-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:07f45f287469039ffc2c53caf6803cd506eb5f5f637f1d4acb37a738f71dd066"}, + {file = "regex-2024.9.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4838e24ee015101d9f901988001038f7f0d90dc0c3b115541a1365fb439add62"}, + {file = "regex-2024.9.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6edd623bae6a737f10ce853ea076f56f507fd7726bee96a41ee3d68d347e4d16"}, + {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c69ada171c2d0e97a4b5aa78fbb835e0ffbb6b13fc5da968c09811346564f0d3"}, + {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02087ea0a03b4af1ed6ebab2c54d7118127fee8d71b26398e8e4b05b78963199"}, + {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69dee6a020693d12a3cf892aba4808fe168d2a4cef368eb9bf74f5398bfd4ee8"}, + {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297f54910247508e6e5cae669f2bc308985c60540a4edd1c77203ef19bfa63ca"}, + {file = "regex-2024.9.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecea58b43a67b1b79805f1a0255730edaf5191ecef84dbc4cc85eb30bc8b63b9"}, + {file = "regex-2024.9.11-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:eab4bb380f15e189d1313195b062a6aa908f5bd687a0ceccd47c8211e9cf0d4a"}, + {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0cbff728659ce4bbf4c30b2a1be040faafaa9eca6ecde40aaff86f7889f4ab39"}, + {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:54c4a097b8bc5bb0dfc83ae498061d53ad7b5762e00f4adaa23bee22b012e6ba"}, + {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:73d6d2f64f4d894c96626a75578b0bf7d9e56dcda8c3d037a2118fdfe9b1c664"}, + {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:e53b5fbab5d675aec9f0c501274c467c0f9a5d23696cfc94247e1fb56501ed89"}, + {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0ffbcf9221e04502fc35e54d1ce9567541979c3fdfb93d2c554f0ca583a19b35"}, + {file = "regex-2024.9.11-cp39-cp39-win32.whl", hash = "sha256:e4c22e1ac1f1ec1e09f72e6c44d8f2244173db7eb9629cc3a346a8d7ccc31142"}, + {file = "regex-2024.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:faa3c142464efec496967359ca99696c896c591c56c53506bac1ad465f66e919"}, + {file = "regex-2024.9.11.tar.gz", hash = "sha256:6c188c307e8433bcb63dc1915022deb553b4203a70722fc542c363bf120a01fd"}, +] + [[package]] name = "rich" version = "13.7.1" @@ -952,29 +1197,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.6.5" +version = "0.6.9" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.6.5-py3-none-linux_armv6l.whl", hash = "sha256:7e4e308f16e07c95fc7753fc1aaac690a323b2bb9f4ec5e844a97bb7fbebd748"}, - {file = "ruff-0.6.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:932cd69eefe4daf8c7d92bd6689f7e8182571cb934ea720af218929da7bd7d69"}, - {file = "ruff-0.6.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a8d42d11fff8d3143ff4da41742a98f8f233bf8890e9fe23077826818f8d680"}, - {file = "ruff-0.6.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a50af6e828ee692fb10ff2dfe53f05caecf077f4210fae9677e06a808275754f"}, - {file = "ruff-0.6.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:794ada3400a0d0b89e3015f1a7e01f4c97320ac665b7bc3ade24b50b54cb2972"}, - {file = "ruff-0.6.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:381413ec47f71ce1d1c614f7779d88886f406f1fd53d289c77e4e533dc6ea200"}, - {file = "ruff-0.6.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:52e75a82bbc9b42e63c08d22ad0ac525117e72aee9729a069d7c4f235fc4d276"}, - {file = "ruff-0.6.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09c72a833fd3551135ceddcba5ebdb68ff89225d30758027280968c9acdc7810"}, - {file = "ruff-0.6.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:800c50371bdcb99b3c1551d5691e14d16d6f07063a518770254227f7f6e8c178"}, - {file = "ruff-0.6.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e25ddd9cd63ba1f3bd51c1f09903904a6adf8429df34f17d728a8fa11174253"}, - {file = "ruff-0.6.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7291e64d7129f24d1b0c947ec3ec4c0076e958d1475c61202497c6aced35dd19"}, - {file = "ruff-0.6.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9ad7dfbd138d09d9a7e6931e6a7e797651ce29becd688be8a0d4d5f8177b4b0c"}, - {file = "ruff-0.6.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:005256d977021790cc52aa23d78f06bb5090dc0bfbd42de46d49c201533982ae"}, - {file = "ruff-0.6.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:482c1e6bfeb615eafc5899127b805d28e387bd87db38b2c0c41d271f5e58d8cc"}, - {file = "ruff-0.6.5-py3-none-win32.whl", hash = "sha256:cf4d3fa53644137f6a4a27a2b397381d16454a1566ae5335855c187fbf67e4f5"}, - {file = "ruff-0.6.5-py3-none-win_amd64.whl", hash = "sha256:3e42a57b58e3612051a636bc1ac4e6b838679530235520e8f095f7c44f706ff9"}, - {file = "ruff-0.6.5-py3-none-win_arm64.whl", hash = "sha256:51935067740773afdf97493ba9b8231279e9beef0f2a8079188c4776c25688e0"}, - {file = "ruff-0.6.5.tar.gz", hash = "sha256:4d32d87fab433c0cf285c3683dd4dae63be05fd7a1d65b3f5bf7cdd05a6b96fb"}, + {file = "ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd"}, + {file = "ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec"}, + {file = "ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039"}, + {file = "ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d"}, + {file = "ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117"}, + {file = "ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93"}, + {file = "ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2"}, ] [[package]] @@ -1004,6 +1249,17 @@ files = [ {file = "sh-2.0.7.tar.gz", hash = "sha256:029d45198902bfb967391eccfd13a88d92f7cebd200411e93f99ebacc6afbb35"}, ] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sqlparse" version = "0.5.1" @@ -1019,6 +1275,26 @@ files = [ dev = ["build", "hatch"] doc = ["sphinx"] +[[package]] +name = "tqdm" +version = "4.66.5" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd"}, + {file = "tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + [[package]] name = "types-pyyaml" version = "6.0.12.20240808" @@ -1089,4 +1365,4 @@ brotli = ["brotli"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "fe31c0b3c18f9e2201d7dd942661dc7ad7f44f5a0fcb9aa4d359ff1256f6f2e4" +content-hash = "acc4d47e3b08eb55b91d2ace35a62ec7c07b7ea6bc06da46a8c043490be3f933" diff --git a/process_manager/__init__.py b/process_manager/__init__.py new file mode 100644 index 00000000..0cb9b3c4 --- /dev/null +++ b/process_manager/__init__.py @@ -0,0 +1 @@ +"""The process_manager app for the drunc_ui project.""" diff --git a/process_manager/admin.py b/process_manager/admin.py new file mode 100644 index 00000000..eb1b9492 --- /dev/null +++ b/process_manager/admin.py @@ -0,0 +1,3 @@ +"""Admin module for the process_manager app.""" + +# Register your models here. diff --git a/process_manager/apps.py b/process_manager/apps.py new file mode 100644 index 00000000..3736c310 --- /dev/null +++ b/process_manager/apps.py @@ -0,0 +1,10 @@ +"""Apps module for the process_manager app.""" + +from django.apps import AppConfig + + +class ProcessManagerConfig(AppConfig): + """The app config for the process_manager app.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "process_manager" diff --git a/main/forms.py b/process_manager/forms.py similarity index 85% rename from main/forms.py rename to process_manager/forms.py index b4879025..457ad0da 100644 --- a/main/forms.py +++ b/process_manager/forms.py @@ -1,4 +1,4 @@ -"""Forms for the main app.""" +"""Forms for the process_manager app.""" from django import forms diff --git a/process_manager/migrations/__init__.py b/process_manager/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/process_manager/models.py b/process_manager/models.py new file mode 100644 index 00000000..86b89433 --- /dev/null +++ b/process_manager/models.py @@ -0,0 +1,3 @@ +"""Models module for the process_manager app.""" + +# Create your models here. diff --git a/process_manager/process_manager_interface.py b/process_manager/process_manager_interface.py new file mode 100644 index 00000000..3b4c95dc --- /dev/null +++ b/process_manager/process_manager_interface.py @@ -0,0 +1,104 @@ +"""Module providing functions to interact with the drunc process manager.""" + +import asyncio +from collections.abc import Iterable +from enum import Enum + +from django.conf import settings +from drunc.process_manager.process_manager_driver import ProcessManagerDriver +from drunc.utils.shell_utils import DecodedResponse, create_dummy_token_from_uname +from druncschema.process_manager_pb2 import ( + LogRequest, + ProcessInstanceList, + ProcessQuery, + ProcessUUID, +) + + +def get_process_manager_driver() -> ProcessManagerDriver: + """Get a ProcessManagerDriver instance.""" + token = create_dummy_token_from_uname() + return ProcessManagerDriver( + settings.PROCESS_MANAGER_URL, token=token, aio_channel=True + ) + + +async def _get_session_info() -> ProcessInstanceList: + pmd = get_process_manager_driver() + query = ProcessQuery(names=[".*"]) + return await pmd.ps(query) + + +def get_session_info() -> ProcessInstanceList: + """Get info about all sessions from process manager.""" + return asyncio.run(_get_session_info()) + + +class ProcessAction(Enum): + """Enum for process actions.""" + + RESTART = "restart" + KILL = "kill" + FLUSH = "flush" + + +async def _process_call(uuids: Iterable[str], action: ProcessAction) -> None: + pmd = get_process_manager_driver() + uuids_ = [ProcessUUID(uuid=u) for u in uuids] + + match action: + case ProcessAction.RESTART: + for uuid_ in uuids_: + query = ProcessQuery(uuids=[uuid_]) + await pmd.restart(query) + case ProcessAction.KILL: + query = ProcessQuery(uuids=uuids_) + await pmd.kill(query) + case ProcessAction.FLUSH: + query = ProcessQuery(uuids=uuids_) + await pmd.flush(query) + + +def process_call(uuids: Iterable[str], action: ProcessAction) -> None: + """Perform an action on a process with a given UUID. + + Args: + uuids: List of UUIDs of the process to be actioned. + action: Action to be performed {restart,flush,kill}. + """ + return asyncio.run(_process_call(uuids, action)) + + +async def _get_process_logs(uuid: str) -> list[DecodedResponse]: + pmd = get_process_manager_driver() + query = ProcessQuery(uuids=[ProcessUUID(uuid=uuid)]) + request = LogRequest(query=query, how_far=100) + return [item async for item in pmd.logs(request)] + + +def get_process_logs(uuid: str) -> list[DecodedResponse]: + """Retrieve logs for a process from the process manager. + + Args: + uuid: UUID of the process. + + Returns: + The process logs. + """ + return asyncio.run(_get_process_logs(uuid)) + + +async def _boot_process(user: str, data: dict[str, str | int]) -> None: + pmd = get_process_manager_driver() + async for item in pmd.dummy_boot(user=user, **data): + pass + + +def boot_process(user: str, data: dict[str, str | int]) -> None: + """Boot a process with the given data. + + Args: + user: the user to boot the process as. + data: the data for the process. + """ + return asyncio.run(_boot_process(user, data)) diff --git a/process_manager/tables.py b/process_manager/tables.py new file mode 100644 index 00000000..41f4ff5d --- /dev/null +++ b/process_manager/tables.py @@ -0,0 +1,61 @@ +"""Tables for the process_manager app.""" + +import django_tables2 as tables +from django.utils.safestring import mark_safe + +logs_column_template = ( + "LOGS" +) + +header_checkbox_hyperscript = "on click set .row-checkbox.checked to my.checked" + +row_checkbox_hyperscript = """ +on click +if <.row-checkbox:not(:checked)/> is empty + set #header-checkbox.checked to true +else + set #header-checkbox.checked to false +""" + + +class ProcessTable(tables.Table): + """Defines and Process Table for the data from the Process Manager.""" + + class Meta: # noqa: D106 + orderable = False + + uuid = tables.Column(verbose_name="UUID") + name = tables.Column(verbose_name="Name") + user = tables.Column(verbose_name="User") + session = tables.Column(verbose_name="Session") + status_code = tables.Column(verbose_name="Status Code") + exit_code = tables.Column(verbose_name="Exit Code") + logs = tables.TemplateColumn(logs_column_template, verbose_name="Logs") + select = tables.CheckBoxColumn( + accessor="uuid", + verbose_name="Select", + attrs={ + "th__input": { + "id": "header-checkbox", + "hx-preserve": "true", + "_": header_checkbox_hyperscript, + } + }, + ) + + def render_select(self, value: str) -> str: + """Customise behaviour of checkboxes in the select column. + + Overriding the default render method for this column is required as the + hx-preserve attitribute requires all elements to have unique id values. We also + need to add the hyperscript required for the header checkbox behaviour. + + Called during table rendering. + + Args: + value: uuid from the table row data + """ + return mark_safe( + f' + {% csrf_token %} + {{ form|crispy }} + {% bootstrap_button button_type="submit" content="Submit" %} + +{% endblock content %} diff --git a/process_manager/templates/process_manager/index.html b/process_manager/templates/process_manager/index.html new file mode 100644 index 00000000..cc224fb8 --- /dev/null +++ b/process_manager/templates/process_manager/index.html @@ -0,0 +1,86 @@ +{% extends "main/base.html" %} +{% block title %} + Home +{% endblock title %} +{% block extra_css %} + +{% endblock extra_css %} +{% block extra_js %} + +{% endblock extra_js %} +{% block content %} +
+
+
+ {% csrf_token %} + Boot + + + + +

 

+ +
+
+
+
+
+
+ Messages + +
+
+
    +
    +
+
+
+
+
+{% endblock content %} diff --git a/process_manager/templates/process_manager/logs.html b/process_manager/templates/process_manager/logs.html new file mode 100644 index 00000000..37af64f6 --- /dev/null +++ b/process_manager/templates/process_manager/logs.html @@ -0,0 +1,10 @@ +{% extends "main/base.html" %} +{% block title %} + Logs +{% endblock title %} +{% block content %} + +
{{ log_text }}
+ + Return to table +{% endblock content %} diff --git a/process_manager/templates/process_manager/partials/message_items.html b/process_manager/templates/process_manager/partials/message_items.html new file mode 100644 index 00000000..992879ab --- /dev/null +++ b/process_manager/templates/process_manager/partials/message_items.html @@ -0,0 +1,5 @@ +
+ {% for message in messages %}
  • {{ message }}
  • {% endfor %} +
    diff --git a/process_manager/templates/process_manager/partials/process_table.html b/process_manager/templates/process_manager/partials/process_table.html new file mode 100644 index 00000000..7853b54b --- /dev/null +++ b/process_manager/templates/process_manager/partials/process_table.html @@ -0,0 +1,2 @@ +{% load render_table from django_tables2 %} +{% render_table table %} diff --git a/process_manager/urls.py b/process_manager/urls.py new file mode 100644 index 00000000..f37d9570 --- /dev/null +++ b/process_manager/urls.py @@ -0,0 +1,20 @@ +"""Urls module for the process_manager app.""" + +from django.urls import include, path + +from .views import actions, pages, partials + +app_name = "process_manager" + +partial_urlpatterns = [ + path("process_table/", partials.process_table, name="process_table"), + path("messages/", partials.messages, name="messages"), +] + +urlpatterns = [ + path("", pages.index, name="index"), + path("process_action/", actions.process_action, name="process_action"), + path("logs/", pages.logs, name="logs"), + path("boot_process/", pages.BootProcessView.as_view(), name="boot_process"), + path("partials/", include(partial_urlpatterns)), +] diff --git a/process_manager/views/__init__.py b/process_manager/views/__init__.py new file mode 100644 index 00000000..00c1b690 --- /dev/null +++ b/process_manager/views/__init__.py @@ -0,0 +1 @@ +"""Module for app view functions.""" diff --git a/process_manager/views/actions.py b/process_manager/views/actions.py new file mode 100644 index 00000000..a54fb777 --- /dev/null +++ b/process_manager/views/actions.py @@ -0,0 +1,32 @@ +"""View functions for performing actions on DUNE processes.""" + +from django.contrib.auth.decorators import login_required, permission_required +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.urls import reverse + +from ..process_manager_interface import ProcessAction, process_call + + +@login_required +@permission_required("main.can_modify_processes", raise_exception=True) +def process_action(request: HttpRequest) -> HttpResponse: + """Perform an action on the selected processes. + + Both the action and the selected processes are retrieved from the request. + + Args: + request: Django HttpRequest object. + + Returns: + HttpResponse redirecting to the index page. + """ + try: + action = request.POST.get("action", "") + action_enum = ProcessAction(action.lower()) + except ValueError: + # action.lower() is not a valid enum value + return HttpResponseRedirect(reverse("process_manager:index")) + + if uuids_ := request.POST.getlist("select"): + process_call(uuids_, action_enum) + return HttpResponseRedirect(reverse("process_manager:index")) diff --git a/process_manager/views/pages.py b/process_manager/views/pages.py new file mode 100644 index 00000000..6fd3488a --- /dev/null +++ b/process_manager/views/pages.py @@ -0,0 +1,59 @@ +"""View functions for pages.""" + +import uuid + +from django.contrib.auth.decorators import login_required, permission_required +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.urls import reverse_lazy +from django.views.generic.edit import FormView + +from ..forms import BootProcessForm +from ..process_manager_interface import boot_process, get_process_logs + + +@login_required +def index(request: HttpRequest) -> HttpResponse: + """View that renders the index/home page.""" + return render(request=request, template_name="process_manager/index.html") + + +@login_required +@permission_required("main.can_view_process_logs", raise_exception=True) +def logs(request: HttpRequest, uuid: uuid.UUID) -> HttpResponse: + """Display the logs of a process. + + Args: + request: the triggering request. + uuid: identifier for the process. + + Returns: + The rendered page. + """ + logs_response = get_process_logs(str(uuid)) + context = dict(log_text="\n".join(val.data.line for val in logs_response)) + return render( + request=request, context=context, template_name="process_manager/logs.html" + ) + + +class BootProcessView(PermissionRequiredMixin, FormView[BootProcessForm]): + """View for the BootProcess form.""" + + template_name = "process_manager/boot_process.html" + form_class = BootProcessForm + success_url = reverse_lazy("process_manager:index") + permission_required = "main.can_modify_processes" + + def form_valid(self, form: BootProcessForm) -> HttpResponse: + """Boot a Process when valid form data has been POSTed. + + Args: + form: the form instance that has been validated. + + Returns: + A redirect to the index page. + """ + boot_process("root", form.cleaned_data) + return super().form_valid(form) diff --git a/process_manager/views/partials.py b/process_manager/views/partials.py new file mode 100644 index 00000000..cd9ee77f --- /dev/null +++ b/process_manager/views/partials.py @@ -0,0 +1,109 @@ +"""View functions for partials.""" + +import django_tables2 +from django.contrib.auth.decorators import login_required +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.utils.timezone import localtime +from druncschema.process_manager_pb2 import ProcessInstance + +from main.models import DruncMessage + +from ..process_manager_interface import get_session_info +from ..tables import ProcessTable + + +def filter_table( + search: str, table: list[dict[str, str | int]] +) -> list[dict[str, str | int]]: + """Filter table data based on search parameter. + + If the search parameter is empty, the table data is returned unfiltered. Otherwise, + the table data is filtered based on the search parameter. The search parameter can + be a string or a string with a column name and search string separated by a colon. + If the search parameter is a column name, the search string is matched against the + values in that column only. Otherwise, the search string is matched against all + columns. + + Args: + search: The search string to filter the table data. + table: The table data to filter. + + Returns: + The filtered table data. + """ + if not search or not table: + return table + + all_cols = list(table[0].keys()) + column, _, search = search.partition(":") + if not search: + # No column-based filtering + search = column + columns = all_cols + elif column not in all_cols: + # If column is unknown, search all columns + columns = all_cols + else: + # Search only the specified column + columns = [column] + search = search.lower() + return [row for row in table if any(search in str(row[k]).lower() for k in columns)] + + +@login_required +def process_table(request: HttpRequest) -> HttpResponse: + """Renders the process table. + + This view may be called using either GET or POST methods. GET renders the table with + no check boxes selected. POST renders the table with checked boxes for any table row + with a uuid provided in the select key of the request data. + """ + session_info = get_session_info() + + status_enum_lookup = dict(item[::-1] for item in ProcessInstance.StatusCode.items()) + + table_data = [] + process_instances = session_info.data.values + for process_instance in process_instances: + metadata = process_instance.process_description.metadata + uuid = process_instance.uuid.uuid + table_data.append( + { + "uuid": uuid, + "name": metadata.name, + "user": metadata.user, + "session": metadata.session, + "status_code": status_enum_lookup[process_instance.status_code], + "exit_code": process_instance.return_code, + } + ) + # Filter table data based on search parameter + table_data = filter_table(request.GET.get("search", ""), table_data) + table = ProcessTable(table_data) + + # sort table data based on request parameters + table_configurator = django_tables2.RequestConfig(request) + table_configurator.configure(table) + + return render( + request=request, + context=dict(table=table), + template_name="process_manager/partials/process_table.html", + ) + + +@login_required +def messages(request: HttpRequest) -> HttpResponse: + """Renders Kafka messages from the database.""" + messages = [] + for msg in DruncMessage.objects.all(): + # Time is stored as UTC. localtime(t) converts this to our configured timezone. + timestamp = localtime(msg.timestamp).strftime("%Y-%m-%d %H:%M:%S") + messages.append(f"{timestamp}: {msg.message}") + + return render( + request=request, + context=dict(messages=messages[::-1]), + template_name="process_manager/partials/message_items.html", + ) diff --git a/pyproject.toml b/pyproject.toml index 4654d470..f9d0bfb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.poetry] -name = "dune_processes" +name = "drunc_ui" version = "0.1.0" description = "[Description for project.]" authors = [ @@ -14,17 +14,21 @@ whitenoise = "^6.7.0" drunc = { git = "https://github.com/DUNE-DAQ/drunc.git" } druncschema = { git = "https://github.com/DUNE-DAQ/druncschema.git" } django-tables2 = "^2.7.0" -django-bootstrap5 = "^24.2" +django-bootstrap5 = "^24.3" pytest-asyncio = "^0.24.0" +django-crispy-forms = "^2.3" +crispy-bootstrap5 = "^2024.10" +django-stubs-ext = "^5.1.0" [tool.poetry.group.dev.dependencies] pytest = "^8.3" pytest-cov = "^5.0.0" pytest-mypy = "^0.10.0" pytest-mock = "^3.7.0" -pre-commit = "^3.0.4" -ruff = "^0.6.5" -django-stubs = { extras = ["compatible-mypy"], version = "^5.0.4" } +pre-commit = "^4.0.1" +ruff = "^0.6.9" +djlint = "^1.35.2" +django-stubs = { extras = ["compatible-mypy"], version = "^5.1.0" } pytest-django = "^4.9.0" [build-system] @@ -37,7 +41,7 @@ disallow_any_generics = true warn_unreachable = true warn_unused_ignores = true disallow_untyped_defs = true -exclude = [".venv/", "manage.py", "main/migrations/"] +exclude = [".venv/", "manage.py", "*/migrations/"] plugins = ["mypy_django_plugin.main"] [[tool.mypy.overrides]] @@ -45,19 +49,19 @@ module = "tests.*" disallow_untyped_defs = false [[tool.mypy.overrides]] -module = ["druncschema.*", "drunc.*", "django_tables2.*"] +module = ["druncschema.*", "drunc.*", "django_tables2.*", "kafka.*"] ignore_missing_imports = true [tool.django-stubs] -django_settings_module = "dune_processes.settings" +django_settings_module = "drunc_ui.settings" [tool.pytest.ini_options] -addopts = "-v --mypy -p no:warnings --cov=dune_processes --cov-report=html --doctest-modules --ignore=manage.py --ignore=dune_processes/settings/" -DJANGO_SETTINGS_MODULE = "dune_processes.settings" +addopts = "-v --mypy -p no:warnings --cov=. --cov-report=html --doctest-modules --ignore=manage.py --ignore=drunc_ui/settings/" +DJANGO_SETTINGS_MODULE = "drunc_ui.settings" FAIL_INVALID_TEMPLATE_VARS = true [tool.ruff] -exclude = ["main/migrations"] +exclude = ["*/migrations"] target-version = "py312" [tool.ruff.lint] @@ -76,3 +80,10 @@ pydocstyle.convention = "google" "D100", "D104", ] # Missing docstring in public module, Missing docstring in public package + +[tool.djlint] +profile = "django" +indent = 2 + +[tool.coverage.run] +omit = ["tests/*", "scripts/*"] diff --git a/tests/__init__.py b/tests/__init__.py index f513847b..dc33d24e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Unit tests for MyProject.""" +"""Unit tests for drunc_ui.""" diff --git a/tests/conftest.py b/tests/conftest.py index 9fbbe5eb..b24ae6aa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,19 +1,53 @@ """Configuration for pytest.""" import pytest +from django.contrib.auth.models import Permission from django.test import Client @pytest.fixture def auth_client(django_user_model) -> Client: """Return an authenticated client.""" - user = django_user_model.objects.create(username="testuser") + user = django_user_model.objects.create(username="user") + client = Client() + client.force_login(user) + return client + + +def _privileged_user_client(django_user_model, username, permission_name): + user = django_user_model.objects.create(username=username) + permission = Permission.objects.get(codename=permission_name) + user.user_permissions.add(permission) client = Client() client.force_login(user) return client +@pytest.fixture +def auth_process_client(django_user_model) -> Client: + """Return a authenticated client with modify process privilege.""" + return _privileged_user_client( + django_user_model, "process_user", "can_modify_processes" + ) + + +@pytest.fixture +def auth_logs_client(django_user_model) -> Client: + """Return a authenticated client with view logs privilege.""" + return _privileged_user_client( + django_user_model, "logs_user", "can_view_process_logs" + ) + + @pytest.fixture def dummy_session_data() -> dict[str, str | int]: """A dictionary of dummy data to populate a dummy session.""" return dict(session_name="sess_name", n_processes=1, sleep=5, n_sleeps=4) + + +@pytest.fixture(autouse=True) +def grpc_mock(mocker): + """Mock out the method that generates gRPC calls to external interfaces.""" + yield mocker.patch( + "process_manager.process_manager_interface.ProcessManagerDriver.send_command_aio" + ) diff --git a/tests/controller/__init__.py b/tests/controller/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/controller/test_views.py b/tests/controller/test_views.py new file mode 100644 index 00000000..a2ca970f --- /dev/null +++ b/tests/controller/test_views.py @@ -0,0 +1,18 @@ +from http import HTTPStatus + +from django.urls import reverse +from pytest_django.asserts import assertTemplateUsed + +from ..utils import LoginRequiredTest + + +class TestIndexView(LoginRequiredTest): + """Tests for the index view.""" + + endpoint = reverse("controller:index") + + def test_index_view_authenticated(self, auth_client, mocker): + """Test the index view for an authenticated user.""" + with assertTemplateUsed(template_name="controller/index.html"): + response = auth_client.get(self.endpoint) + assert response.status_code == HTTPStatus.OK diff --git a/tests/main/__init__.py b/tests/main/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/main/test_views.py b/tests/main/test_views.py new file mode 100644 index 00000000..a10f4ccb --- /dev/null +++ b/tests/main/test_views.py @@ -0,0 +1,48 @@ +from http import HTTPStatus + +from django.urls import reverse +from pytest_django.asserts import assertContains, assertNotContains, assertTemplateUsed + +from ..utils import LoginRequiredTest + + +class TestIndexView(LoginRequiredTest): + """Tests for the index view.""" + + endpoint = reverse("main:index") + + def test_no_nav_links(self, client): + """Test that the navbar does not have any nav links when not authenticated.""" + response = client.get(self.endpoint, follow=True) + assertNotContains(response, "nav-link") + + def test_index_view_authenticated(self, auth_client): + """Test the index view for an authenticated user.""" + with assertTemplateUsed(template_name="main/index.html"): + response = auth_client.get(self.endpoint) + assert response.status_code == HTTPStatus.OK + + assertContains( + response, + f'Process Manager', # noqa: E501 + ) + assertContains( + response, + f'Process Manager', # noqa: E501 + ) + assertContains( + response, + f'Controller', + ) + assertContains( + response, + f'Controller', # noqa: E501 + ) + + def test_help_view(self, auth_client): + """Test that the help view is rendered.""" + response = auth_client.get(reverse("main:help")) + assert response.status_code == HTTPStatus.OK + assertTemplateUsed(response, "main/help.html") + assertContains(response, "

    Help

    ") + assertContains(response, "This is the help page.") diff --git a/tests/process_manager/__init__.py b/tests/process_manager/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_forms.py b/tests/process_manager/test_forms.py similarity index 95% rename from tests/test_forms.py rename to tests/process_manager/test_forms.py index 2328fc0b..e6a01908 100644 --- a/tests/test_forms.py +++ b/tests/process_manager/test_forms.py @@ -1,6 +1,6 @@ from django import forms -from main.forms import BootProcessForm +from process_manager.forms import BootProcessForm def test_boot_form_empty(): diff --git a/tests/process_manager/views/__init__.py b/tests/process_manager/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/process_manager/views/test_action_views.py b/tests/process_manager/views/test_action_views.py new file mode 100644 index 00000000..1290888b --- /dev/null +++ b/tests/process_manager/views/test_action_views.py @@ -0,0 +1,42 @@ +from http import HTTPStatus +from uuid import uuid4 + +import pytest +from django.urls import reverse + +from process_manager.views.actions import ProcessAction + +from ...utils import PermissionRequiredTest + + +class TestProcessActionView(PermissionRequiredTest): + """Tests for the process_action view.""" + + endpoint = reverse("process_manager:process_action") + + def test_no_action(self, auth_process_client): + """Test process_action view with no action provided.""" + response = auth_process_client.post(self.endpoint, data={}) + assert response.status_code == HTTPStatus.FOUND + assert response.url == reverse("process_manager:index") + + def test_invalid_action(self, auth_process_client): + """Test process_action view with an invalid action.""" + response = auth_process_client.post( + self.endpoint, data={"action": "invalid_action"} + ) + assert response.status_code == HTTPStatus.FOUND + assert response.url == reverse("process_manager:index") + + @pytest.mark.parametrize("action", ["kill", "restart", "flush"]) + def test_valid_action(self, action, auth_process_client, mocker): + """Test process_action view with a valid action.""" + mock = mocker.patch("process_manager.views.actions.process_call") + uuids_ = [str(uuid4()), str(uuid4())] + response = auth_process_client.post( + self.endpoint, data={"action": action, "select": uuids_} + ) + assert response.status_code == HTTPStatus.FOUND + assert response.url == reverse("process_manager:index") + + mock.assert_called_once_with(uuids_, ProcessAction(action)) diff --git a/tests/process_manager/views/test_page_views.py b/tests/process_manager/views/test_page_views.py new file mode 100644 index 00000000..fed60314 --- /dev/null +++ b/tests/process_manager/views/test_page_views.py @@ -0,0 +1,76 @@ +from http import HTTPStatus +from uuid import uuid4 + +from django.urls import reverse +from pytest_django.asserts import assertContains, assertTemplateUsed + +from ...utils import LoginRequiredTest, PermissionRequiredTest + + +class TestIndexView(LoginRequiredTest): + """Tests for the index view.""" + + endpoint = reverse("process_manager:index") + + def test_authenticated(self, auth_client): + """Test the index view for an authenticated user.""" + with assertTemplateUsed(template_name="process_manager/index.html"): + response = auth_client.get(self.endpoint) + assert response.status_code == HTTPStatus.OK + + +class TestLogsView(PermissionRequiredTest): + """Tests for the logs view.""" + + uuid = uuid4() + endpoint = reverse("process_manager:logs", kwargs=dict(uuid=uuid)) + + def test_get(self, auth_logs_client, mocker): + """Test the logs view for a privileged user.""" + mock = mocker.patch("process_manager.views.pages.get_process_logs") + with assertTemplateUsed(template_name="process_manager/logs.html"): + response = auth_logs_client.get(self.endpoint) + assert response.status_code == HTTPStatus.OK + + mock.assert_called_once_with(str(self.uuid)) + assert "log_text" in response.context + + +class TestBootProcess(PermissionRequiredTest): + """Grouping the tests for the BootProcess view.""" + + template_name = "process_manager/boot_process.html" + endpoint = reverse("process_manager:boot_process") + + def test_get_privileged(self, auth_process_client): + """Test the GET request for the BootProcess view (privileged).""" + with assertTemplateUsed(template_name=self.template_name): + response = auth_process_client.get(reverse("process_manager:boot_process")) + assert response.status_code == HTTPStatus.OK + + assert "form" in response.context + assertContains( + response, f'form action="{reverse("process_manager:boot_process")}"' + ) + + def test_post_invalid(self, auth_process_client): + """Test the POST request for the BootProcess view with invalid data.""" + with assertTemplateUsed(template_name=self.template_name): + response = auth_process_client.post( + reverse("process_manager:boot_process"), data=dict() + ) + assert response.status_code == HTTPStatus.OK + + assert "form" in response.context + + def test_post_valid(self, auth_process_client, mocker, dummy_session_data): + """Test the POST request for the BootProcess view.""" + mock = mocker.patch("process_manager.views.pages.boot_process") + response = auth_process_client.post( + reverse("process_manager:boot_process"), data=dummy_session_data + ) + assert response.status_code == HTTPStatus.FOUND + + assert response.url == reverse("process_manager:index") + + mock.assert_called_once_with("root", dummy_session_data) diff --git a/tests/process_manager/views/test_partial_views.py b/tests/process_manager/views/test_partial_views.py new file mode 100644 index 00000000..9934a2ea --- /dev/null +++ b/tests/process_manager/views/test_partial_views.py @@ -0,0 +1,150 @@ +from http import HTTPStatus +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest +from django.test import Client +from django.urls import reverse +from pytest_django.asserts import assertTemplateUsed + +from process_manager.tables import ProcessTable + +from ...utils import LoginRequiredTest + + +class TestProcessTableView(LoginRequiredTest): + """Test the process_manager.views.process_table view function.""" + + endpoint = reverse("process_manager:process_table") + + def test_get(self, auth_client, mocker): + """Tests basic calls of view method.""" + uuids = [str(uuid4()) for _ in range(5)] + self._mock_session_info(mocker, uuids) + response = auth_client.get(self.endpoint) + assert response.status_code == HTTPStatus.OK + table = response.context["table"] + assert isinstance(table, ProcessTable) + assert all(row["uuid"] == uuid for row, uuid in zip(table.data.data, uuids)) + + def _mock_session_info(self, mocker, uuids, sessions: list[str] = []): + """Mocks views.get_session_info with ProcessInstanceList like data.""" + mock = mocker.patch("process_manager.views.partials.get_session_info") + instance_mocks = [MagicMock() for uuid in uuids] + sessions = sessions or [f"session{i}" for i in range(len(uuids))] + for instance_mock, uuid, session in zip(instance_mocks, uuids, sessions): + instance_mock.uuid.uuid = str(uuid) + instance_mock.session = session + instance_mock.status_code = 0 + mock().data.values.__iter__.return_value = instance_mocks + return mock + + def test_get_with_search(self, auth_client: Client, mocker): + """Tests basic calls of view method.""" + uuids = [str(uuid4()) for _ in range(5)] + sessions = ["session1", "session2", "session2", "session2", "session3"] + self._mock_session_info(mocker, uuids, sessions) + response = auth_client.get(self.endpoint, data={"search": "session2"}) + assert response.status_code == HTTPStatus.OK + table = response.context["table"] + assert isinstance(table, ProcessTable) + for row, uuid in zip(table.data.data, uuids): + assert row["uuid"] == uuid + assert row["session"] == "session2" + + +class TestMessagesView(LoginRequiredTest): + """Test the process_manager.views.messages view function.""" + + endpoint = reverse("process_manager:messages") + + def test_get(self, auth_client): + """Tests basic calls of view method.""" + from datetime import UTC, datetime, timedelta + + from main.models import DruncMessage + + t1 = datetime.now(tz=UTC) + t2 = t1 + timedelta(minutes=10) + DruncMessage.objects.bulk_create( + [ + DruncMessage(timestamp=t1, message="message 0"), + DruncMessage(timestamp=t2, message="message 1"), + ] + ) + + with assertTemplateUsed("process_manager/partials/message_items.html"): + response = auth_client.get(self.endpoint) + assert response.status_code == HTTPStatus.OK + + # messages have been added to the context in reverse order + t1_str = t1.strftime("%Y-%m-%d %H:%M:%S") + assert response.context["messages"][1] == f"{t1_str}: message 0" + t2_str = t2.strftime("%Y-%m-%d %H:%M:%S") + assert response.context["messages"][0] == f"{t2_str}: message 1" + + +process_1 = { + "uuid": "1", + "name": "Process1", + "user": "user1", + "session": "session1", + "status_code": "running", + "exit_code": 0, +} +process_2 = { + "uuid": "2", + "name": "Process2", + "user": "user2", + "session": "session2", + "status_code": "completed", + "exit_code": 0, +} + + +@pytest.mark.parametrize( + "search,table,expected", + [ + pytest.param( + "", + [process_1, process_2], + [process_1, process_2], + id="no search", + ), + pytest.param( + "Process1", + [process_1, process_2], + [process_1], + id="search all columns", + ), + pytest.param( + "name:Process1", + [process_1, process_2], + [process_1], + id="search specific column", + ), + pytest.param( + "nonexistent:Process1", + [process_1, process_2], + [process_1], + id="search non-existent column", + ), + pytest.param( + "Process1", + [], + [], + id="filter empty table", + ), + pytest.param( + "process1", + [process_1, process_2], + [process_1], + id="search case insensitive", + ), + ], +) +def test_filter_table(search, table, expected): + """Test filter_table function.""" + from process_manager.views.partials import filter_table + + assert filter_table(search, table) == expected diff --git a/tests/test_process_manager_interface.py b/tests/test_process_manager_interface.py new file mode 100644 index 00000000..ceb79558 --- /dev/null +++ b/tests/test_process_manager_interface.py @@ -0,0 +1,13 @@ +from process_manager.process_manager_interface import boot_process + + +def test_boot_process(mocker, dummy_session_data): + """Test the _boot_process function.""" + mock = mocker.patch( + "process_manager.process_manager_interface.get_process_manager_driver" + ) + boot_process("root", dummy_session_data) + mock.assert_called_once() + mock.return_value.dummy_boot.assert_called_once_with( + user="root", **dummy_session_data + ) diff --git a/tests/test_views.py b/tests/test_views.py deleted file mode 100644 index 7a8c874b..00000000 --- a/tests/test_views.py +++ /dev/null @@ -1,123 +0,0 @@ -from http import HTTPStatus -from uuid import uuid4 - -import pytest -from django.urls import reverse -from pytest_django.asserts import assertContains, assertRedirects, assertTemplateUsed - -from main.views import ProcessAction - - -def test_index(client, auth_client, admin_client, mocker): - """Test the index view.""" - mocker.patch("main.views.get_session_info") - - # Test with an anonymous client. - response = client.get(reverse("main:index")) - assert response.status_code == HTTPStatus.FOUND - assertRedirects(response, "/accounts/login/?next=/") - - # Test with an authenticated client. - with assertTemplateUsed(template_name="main/index.html"): - response = auth_client.get(reverse("main:index")) - assert response.status_code == HTTPStatus.OK - - # Test with an admin client. - with assertTemplateUsed(template_name="main/index.html"): - response = admin_client.get(reverse("main:index")) - assert response.status_code == HTTPStatus.OK - - assert "table" in response.context - assertContains(response, "Boot") - - -def test_logs(client, auth_client, mocker): - """Test the logs view.""" - mock = mocker.patch("main.views._get_process_logs") - - uuid = uuid4() - - # Test with an anonymous client. - response = client.get(reverse("main:logs", kwargs=dict(uuid=uuid))) - assert response.status_code == HTTPStatus.FOUND - assertRedirects(response, f"/accounts/login/?next=/logs/{uuid}") - - # Test with an authenticated client. - with assertTemplateUsed(template_name="main/logs.html"): - response = auth_client.get(reverse("main:logs", kwargs=dict(uuid=uuid))) - assert response.status_code == HTTPStatus.OK - - mock.assert_called_once_with(str(uuid)) - assert "log_text" in response.context - - -def test_process_flush(client, auth_client, mocker): - """Test the process_flush view.""" - mock = mocker.patch("main.views._process_call") - - uuid = uuid4() - - # Test with an anonymous client. - response = client.get(reverse("main:flush", kwargs=dict(uuid=uuid))) - assert response.status_code == HTTPStatus.FOUND - assertRedirects(response, f"/accounts/login/?next=/flush/{uuid}") - - # Test with an authenticated client. - response = auth_client.get(reverse("main:flush", kwargs=dict(uuid=uuid))) - assert response.status_code == HTTPStatus.FOUND - assert response.url == reverse("main:index") - mock.assert_called_once_with(str(uuid), ProcessAction.FLUSH) - - -class TestBootProcess: - """Grouping the tests for the BootProcess view.""" - - template_name = "main/boot_process.html" - - def test_boot_process_get(self, auth_client): - """Test the GET request for the BootProcess view.""" - with assertTemplateUsed(template_name=self.template_name): - response = auth_client.get(reverse("main:boot_process")) - assert response.status_code == HTTPStatus.OK - - assert "form" in response.context - assertContains(response, f'form action="{reverse("main:boot_process")}"') - - def test_boot_process_get_anon(self, client): - """Test the GET request for the BootProcess view with an anonymous client.""" - response = client.get(reverse("main:boot_process")) - assert response.status_code == HTTPStatus.FOUND - assertRedirects(response, "/accounts/login/?next=/boot_process/") - - def test_boot_process_post_invalid(self, auth_client): - """Test the POST request for the BootProcess view with invalid data.""" - with assertTemplateUsed(template_name=self.template_name): - response = auth_client.post(reverse("main:boot_process"), data=dict()) - assert response.status_code == HTTPStatus.OK - - assert "form" in response.context - - def test_boot_process_post_valid(self, auth_client, mocker, dummy_session_data): - """Test the POST request for the BootProcess view.""" - mock = mocker.patch("main.views._boot_process") - response = auth_client.post( - reverse("main:boot_process"), data=dummy_session_data - ) - assert response.status_code == HTTPStatus.FOUND - - assert response.url == reverse("main:index") - - mock.assert_called_once_with("root", dummy_session_data) - - -@pytest.mark.asyncio -async def test_boot_process(mocker, dummy_session_data): - """Test the _boot_process function.""" - from main.views import _boot_process - - mock = mocker.patch("main.views.get_process_manager_driver") - await _boot_process("root", dummy_session_data) - mock.assert_called_once() - mock.return_value.dummy_boot.assert_called_once_with( - user="root", **dummy_session_data - ) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..a164d64d --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,26 @@ +from http import HTTPStatus + +from django.urls import reverse +from pytest_django.asserts import assertRedirects + + +class LoginRequiredTest: + """Tests for views that require authentication.""" + + endpoint: str + + def test_login_redirect(self, client): + """Test that the view redirects to the login page.""" + response = client.get(self.endpoint) + assert response.status_code == HTTPStatus.FOUND + + assertRedirects(response, reverse("main:login") + f"?next={self.endpoint}") + + +class PermissionRequiredTest(LoginRequiredTest): + """Tests for views that require authentication and correct user permissions.""" + + def test_permission_deny(self, auth_client): + """Test that authenticated users missing permissions are blocked.""" + response = auth_client.get(self.endpoint) + assert response.status_code == HTTPStatus.FORBIDDEN