diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b6a1b77..5c93940 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,32 +1,43 @@ image: python:3 +stages: +- test +- deploy + # Change pip's cache directory to be inside the project directory since we can # only cache local items. variables: + POETRY_CACHE_DIR: "$CI_PROJECT_DIR/.cache/poetry" PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" -# Pip's cache doesn't store the python packages -# https://pip.pypa.io/en/stable/reference/pip_install/#caching -# -# If you want to also cache the installed packages, you have to install -# them in a virtualenv and cache it as well. cache: + key: ${CI_COMMIT_REF_SLUG} paths: - - .cache/pypoetry + - .cache/pip + - .cache/poetry before_script: - python -V - pip install poetry + - "poetry config settings.virtualenvs.path ${POETRY_CACHE_DIR}" - poetry install test: + stage: test script: - - poetry run pytest . + - poetry run pytest . --cov=jeeves.core --cov-report xml --cov-report term + artifacts: + paths: + - coverage.xml pages: + stage: deploy + dependencies: + - test script: - poetry run docs/build.sh - mv docs/_build/html/ public + - coverage html --directory public/htmlcov artifacts: paths: - public diff --git a/docs/Makefile b/docs/Makefile index f8e9408..d4bb2cb 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -12,9 +12,6 @@ BUILDDIR = _build help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -apidoc: - sphinx-apidoc -o api ../jeeves/core - .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new diff --git a/docs/build.sh b/docs/build.sh index f0fea6a..a2300b7 100755 --- a/docs/build.sh +++ b/docs/build.sh @@ -1,5 +1,5 @@ #!/bin/bash cd docs || exit 1 -make apidoc +sphinx-apidoc -o api ../jeeves/core make html diff --git a/docs/usage.rst b/docs/usage.rst index 6a03fc0..b18ba59 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -15,7 +15,7 @@ for example. given a test flow: "tasks": [ { "name": "Say hello", - "type": "jeeves.core.tasks.shell:ShellTask", + "type": "jeeves.core.actions.shell:ScriptAction", "parameters": { "script": "#!/bin/bash\necho HELLO WORLD!" } diff --git a/jeeves/core/actions/__init__.py b/jeeves/core/actions/__init__.py new file mode 100644 index 0000000..fbb127d --- /dev/null +++ b/jeeves/core/actions/__init__.py @@ -0,0 +1 @@ +PROVIDED_ACTIONS = ["jeeves.core.actions.shell:ScriptAction"] diff --git a/jeeves/core/tasks/base.py b/jeeves/core/actions/base.py similarity index 95% rename from jeeves/core/tasks/base.py rename to jeeves/core/actions/base.py index 6904d23..940bc14 100644 --- a/jeeves/core/tasks/base.py +++ b/jeeves/core/actions/base.py @@ -3,7 +3,7 @@ import logging import pydantic -class Task: +class Action: id = "" class Parameters(pydantic.BaseModel): diff --git a/jeeves/core/actions/shell.py b/jeeves/core/actions/shell.py new file mode 100644 index 0000000..379ad9a --- /dev/null +++ b/jeeves/core/actions/shell.py @@ -0,0 +1,69 @@ +import os +import tempfile +import subprocess +from typing import Text + +import pydantic + +from jeeves.core.objects import Result +from .base import Action + + +class ScriptAction(Action): + """ + Executes the provided script direcly on the system. + + The script is written into a temporary file that is then executed. + + If no shebang is provided, a default of ``#!/bin/bash -e`` will be used, if + the provided shebang interpreter is not found on the system the action will fail. + + .. automethod:: _get_script + """ + + DEFAULT_SHEBANG = "#!/bin/bash" + + id = "contrib/script" + verbose_name = "Execute script" + + class Parameters(pydantic.BaseModel): + """ + +----------------+------+-----------+---------------------------+ + | Parameter name | Type | Mandatory | Description | + +================+======+===========+===========================+ + | ``script`` | text | yes | The script to be executed | + +----------------+------+-----------+---------------------------+ + """ + + script: Text + + def _get_script(self): + """ + Returns the script defined in the parameters, checking for a shebang. + + If no shebang is defined, :any:`ScriptAction.DEFAULT_SHEBANG` with be used. + """ + if not self.parameters.script.startswith("#!"): + return f"{self.DEFAULT_SHEBANG}{os.linesep}{self.parameters.script}" + return self.parameters.script + + def execute(self): + script = self._get_script() + + # Write the script to a temporary file + script_file = tempfile.NamedTemporaryFile(mode="w", delete=False) + with script_file as handler: + handler.write(script) + + # Read/Execute only for owner + os.chmod(script_file.name, mode=0o500) + + process = subprocess.run( + script_file.name, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) + + os.unlink(script_file.name) + + return Result( + success=process.returncode == 0, output=process.stdout.decode("utf-8") + ) diff --git a/jeeves/core/tasks/stub.py b/jeeves/core/actions/stub.py similarity index 54% rename from jeeves/core/tasks/stub.py rename to jeeves/core/actions/stub.py index 43cc2bd..5783a61 100644 --- a/jeeves/core/tasks/stub.py +++ b/jeeves/core/actions/stub.py @@ -1,48 +1,48 @@ """ -This is a collection of Tasks provided for testing purposes only. +This is a collection of Actions provided for testing purposes only. """ from typing import Text, Optional from jeeves.core.objects import Result -from .base import Task +from .base import Action -class StubSuccessTask(Task): +class StubSuccessAction(Action): id = "stub/success" def execute(self, message=None): return Result(success=True) -class StubNonSuccessTask(Task): +class StubNonSuccessAction(Action): id = "stub/non-success" def execute(self): return Result(output="error!", success=False) -class StubUncaughtExceptionTask(Task): +class StubUncaughtExceptionAction(Action): id = "stub/uncaught-exception" def execute(self): raise Exception("Oh god...") -class StubNoParametersTask(StubSuccessTask): +class StubNoParametersAction(StubSuccessAction): """ - An empty task that provides no configurable parameters. + An empty Action that provides no configurable parameters. """ id = "stub/no-parameters" -class StubParametersTask(StubSuccessTask): +class StubParametersAction(StubSuccessAction): """ - An empty task that provide two configurable parameters. + An empty Action that provide two configurable parameters. """ id = "sub/parameters" - class Parameters(Task.Parameters): + class Parameters(Action.Parameters): mandatory: Text non_mandatory: Optional[Text] = None diff --git a/jeeves/core/executor.py b/jeeves/core/executor.py index 228ac8b..131b6f3 100644 --- a/jeeves/core/executor.py +++ b/jeeves/core/executor.py @@ -14,13 +14,13 @@ class Executor: for step in self._execution.steps: yield step - def _get_steps(self, flow): + def _get_steps(self, flow: Flow): for task in flow.tasks: yield ExecutionStep(task=task, result=Result()) - def execute_step(self, step): + def execute_step(self, step: ExecutionStep): try: - step.result = step.task.runner.execute() + step.result = step.task.action.execute() except Exception as error: # Catch unhandled exceptions, mark the result as unsuccessful # and append the error as output. diff --git a/jeeves/core/objects.py b/jeeves/core/objects.py index b044713..16e160d 100644 --- a/jeeves/core/objects.py +++ b/jeeves/core/objects.py @@ -3,7 +3,7 @@ from dataclasses import field from pydantic import BaseModel -from jeeves.core.registry import TaskRegistry +from jeeves.core.registry import ActionRegistry class BaseObject(BaseModel): @@ -21,8 +21,11 @@ class Task(BaseObject): parameters: Dict[Any, Any] @property - def runner(self): - return TaskRegistry.get_task_cls(self.type)(parameters=self.parameters) + def action(self): + """ + Returns the instanced :any:`jeeves.core.actions.base.Action` defined in ``type`` for this ``Task``. + """ + return ActionRegistry.get_action_cls(self.type)(parameters=self.parameters) class Flow(BaseObject): diff --git a/jeeves/core/parsers.py b/jeeves/core/parsers.py index 55a4050..7ba75c6 100644 --- a/jeeves/core/parsers.py +++ b/jeeves/core/parsers.py @@ -1,4 +1,4 @@ -from typing import Dict, Text +from typing import Any, Text, MutableMapping import toml @@ -6,14 +6,14 @@ from jeeves.core.objects import Flow, BaseObject class ObjectParser: - object = None + object: BaseObject = None @classmethod def from_json(cls, serialized: Text) -> BaseObject: return cls.object.parse_raw(serialized) @classmethod - def from_dict(cls, serialized: Dict) -> BaseObject: + def from_dict(cls, serialized: MutableMapping[str, Any]) -> BaseObject: return cls.object.parse_obj(serialized) @classmethod diff --git a/jeeves/core/registry.py b/jeeves/core/registry.py index 2841c0d..17c522e 100644 --- a/jeeves/core/registry.py +++ b/jeeves/core/registry.py @@ -1,6 +1,8 @@ import logging +from typing import Any, Dict, Text, Type, Optional -from jeeves.core.tasks import PROVIDED_TASKS +from jeeves.core.actions import PROVIDED_ACTIONS +from jeeves.core.actions.base import Action class Singleton(type): @@ -14,53 +16,62 @@ class Singleton(type): return cls._instance -class TaskRegistry(metaclass=Singleton): - tasks = {} +class ActionRegistry(metaclass=Singleton): + actions: Dict[str, str] = {} def __init__(self): self.logger = logging.getLogger(self.__class__.__name__) @classmethod def autodiscover(cls): - """Loads all provided tasks.""" + """Loads all provided actions.""" # TODO: Third party plugins registry = cls() - for task_namespace in PROVIDED_TASKS: - registry.register_task(cls.get_task_cls(task_namespace)) + for action_namespace in PROVIDED_ACTIONS: + registry.register_action(cls.get_action_cls(action_namespace)) @classmethod - def register_task(cls, task_cls): + def register_action(cls, action_cls): registry = cls() - # namespace = task_cls.id - namespace = f"{task_cls.__module__}:{task_cls.__name__}" + # namespace = action_cls.id + namespace = f"{action_cls.__module__}:{action_cls.__name__}" - if namespace in registry.tasks: - raise cls.TaskNamespaceConflict( + if namespace in registry.actions: + raise cls.ActionNamespaceConflict( f"Namespace {namespace} is already registered" ) - registry.tasks[namespace] = task_cls + registry.actions[namespace] = action_cls @classmethod - def get_task_cls(cls, namespace, **kwargs): - # Right now tasks are being imported and returned dinamically because it's easier, - # but we will need a way of autodiscover all tasks (or register them manually) and - # referencing them on a list so the user knows which tasks are available. + def get_action_cls(cls, namespace) -> Type[Action]: + """Returns the class for the provided action namespace""" + # Right now actions are being imported and returned dinamically because it's easier, + # but we will need a way of autodiscover all (or register them manually) and + # referencing them on a list so the user knows which actions are available. modulename, clsname = namespace.split(":") try: module = __import__(f"{modulename}", fromlist=(clsname,), level=0) - task_cls = getattr(module, clsname) - return task_cls + action_cls = getattr(module, clsname) + return action_cls except ModuleNotFoundError as error: - raise cls.TaskDoesNotExist(f"Error importing task {namespace}: {error.msg}") + raise cls.ActionDoesNotExist(f"Error importing action {namespace}: {error}") - class TaskNamespaceConflict(Exception): - pass + @classmethod + def get_action( + cls, namespace: Text, parameters: Optional[Dict[Any, Any]] = None + ) -> Action: + """Returns the instanced action for the provided namespace""" + action_cls: Type[Action] = cls.get_action_cls(namespace) + return action_cls(parameters=parameters or {}) - class TaskDoesNotExist(Exception): - """ - Used when there's a problem retrieving a task. More info will be available on the message. - """ + class ActionNamespaceConflict(Exception): + """Raised when an action is defined with an already registered namespace""" + + pass + + class ActionDoesNotExist(Exception): + """Raised when there's a problem retrieving an action. More info will be available on the message.""" pass diff --git a/jeeves/core/tasks/__init__.py b/jeeves/core/tasks/__init__.py deleted file mode 100644 index ad878ef..0000000 --- a/jeeves/core/tasks/__init__.py +++ /dev/null @@ -1 +0,0 @@ -PROVIDED_TASKS = ["jeeves.core.tasks.shell:ShellTask"] diff --git a/jeeves/core/tasks/shell.py b/jeeves/core/tasks/shell.py deleted file mode 100644 index 7e276a8..0000000 --- a/jeeves/core/tasks/shell.py +++ /dev/null @@ -1,26 +0,0 @@ -import subprocess -from typing import Text - -import pydantic - -from jeeves.core.objects import Result -from .base import Task - - -class ShellTask(Task): - id = "library/shell" - verbose_name = "Execute Shell script" - - class Parameters(pydantic.BaseModel): - script: Text - - def execute(self): - process = subprocess.run( - self.parameters.script, - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) - return Result( - success=process.returncode == 0, output=process.stdout.decode("utf-8") - ) diff --git a/jeeves/core/tests/conftest.py b/jeeves/core/tests/conftest.py index fd3e16f..18e60c9 100644 --- a/jeeves/core/tests/conftest.py +++ b/jeeves/core/tests/conftest.py @@ -1,8 +1,8 @@ import pytest -from jeeves.core.registry import TaskRegistry +from jeeves.core.registry import ActionRegistry @pytest.fixture(scope="session", autouse=True) -def autoregister_tasks(): - TaskRegistry.autodiscover() +def autoregister_actions(): + ActionRegistry.autodiscover() diff --git a/jeeves/core/tests/factories.py b/jeeves/core/tests/factories.py index c734d8c..d266062 100644 --- a/jeeves/core/tests/factories.py +++ b/jeeves/core/tests/factories.py @@ -1,15 +1,12 @@ import factory -from faker import Faker from jeeves.core.objects import Flow, Task -from jeeves.core.registry import TaskRegistry - -fake = Faker() +from jeeves.core.registry import ActionRegistry class TaskFactory(factory.Factory): - name = fake.sentence() - type = factory.Iterator(TaskRegistry.tasks) + name = factory.Faker("name") + type = factory.Iterator(ActionRegistry.actions) parameters = {"script": "#!/bin/bash\necho test"} class Meta: @@ -17,7 +14,7 @@ class TaskFactory(factory.Factory): class FlowFactory(factory.Factory): - name = fake.name() + name = factory.Faker("name") tasks = factory.LazyFunction(lambda: [TaskFactory() for _ in range(0, 2)]) class Meta: diff --git a/jeeves/core/tests/tasks/__init__.py b/jeeves/core/tests/tasks/__init__.py new file mode 100644 index 0000000..76878c5 --- /dev/null +++ b/jeeves/core/tests/tasks/__init__.py @@ -0,0 +1,3 @@ +""" +This module contains tests excluve to the tasks provided bundled with Jeeves +""" diff --git a/jeeves/core/tests/tasks/test_shell.py b/jeeves/core/tests/tasks/test_shell.py new file mode 100644 index 0000000..d0271ad --- /dev/null +++ b/jeeves/core/tests/tasks/test_shell.py @@ -0,0 +1,110 @@ +import sys +import os.path +import tempfile +from unittest import mock +from subprocess import CompletedProcess + +from jeeves.core.objects import Task + +MOCK_DEFINITION = { + "type": f"jeeves.core.actions.shell:ScriptAction", + "name": "Say hello world in bash", + "parameters": {"script": "#!/bin/bash\necho Hello World"}, +} + + +def get_completed_process(returncode=0, stdout=b"", **kwargs): + """Mocks a :class:`subprocess.CompletedProcess` object""" + return CompletedProcess("", returncode=returncode, stdout=stdout, **kwargs) + + +@mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process())) +def test_script_bash_task_ok(): + task = Task.parse_obj(MOCK_DEFINITION).action + result = task.execute() + assert result.success + + +@mock.patch( + "subprocess.run", mock.MagicMock(return_value=get_completed_process(returncode=1)) +) +def test_script_bash_task_ko(): + task = Task.parse_obj(MOCK_DEFINITION).action + result = task.execute() + assert not result.success + + +@mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process())) +def test_script_no_shebang_defaults_to_bash_ok(): + definition = MOCK_DEFINITION.copy() + definition["parameters"]["script"] = definition["parameters"]["script"].strip( + "#!/bin/bash" + ) + task = Task.parse_obj(definition).action + assert task._get_script().startswith(task.DEFAULT_SHEBANG) + result = task.execute() + assert result.success + + +@mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process())) +def test_script_with_other_shebang_ok(): + py_interpreter = sys.executable + expected_output = "Hello world! (from python)" + definition = MOCK_DEFINITION.copy() + py_script = f"#!{py_interpreter}\nprint('{expected_output}')" + definition["parameters"]["script"] = py_script + task = Task.parse_obj(definition).action + assert task._get_script().startswith(f"#!{py_interpreter}") + result = task.execute() + assert result.success + + +def test_script_stdout_and_stderr_is_sent_to_result_ok(): + """ + ..warning:: This test actually calls ``subprocess.run``. + + Not 100% sure this one is needed since we are just testing that subprocess.STDOUT works. + I'm leaving it for now since it's important to ensure we get the entire stdout/err in the + :any:`Result` object. + """ + script = "\n".join( + [ + f"#!{sys.executable}", + "import sys", + "sys.stdout.write('Hello')", + "sys.stderr.write('World')", + ] + ) + definition = MOCK_DEFINITION.copy() + definition["parameters"]["script"] = script + task = Task.parse_obj(definition).action + result = task.execute() + assert "Hello" in result.output + assert "World" in result.output + + +@mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process())) +def test_script_task_cleans_tempfile_ok(): + """Make sure that the script is removed from the system after execution""" + task = Task.parse_obj(MOCK_DEFINITION).action + temp = tempfile.NamedTemporaryFile(mode="w", delete=False) + with mock.patch( + "tempfile.NamedTemporaryFile", mock.MagicMock(return_value=temp) + ) as mocked: + task.execute() + assert not os.path.isfile(mocked.return_value.name) + + +@mock.patch("subprocess.run", mock.MagicMock(return_value=get_completed_process())) +def test_script_task_sets_permissions_for_owner_only_ok(): + """Make sure that the script have only read and execution permissions for owner""" + task = Task.parse_obj(MOCK_DEFINITION).action + temp = tempfile.NamedTemporaryFile(mode="w", delete=False) + with mock.patch( + "tempfile.NamedTemporaryFile", mock.MagicMock(return_value=temp) + ) as mocked: + with mock.patch("os.unlink"): + task.execute() + stat = os.stat(mocked.return_value.name) + assert oct(stat.st_mode).endswith("500") + os.unlink(mocked.return_value.name) diff --git a/jeeves/core/tests/test_executor.py b/jeeves/core/tests/test_executor.py index 2823079..6d96c4d 100644 --- a/jeeves/core/tests/test_executor.py +++ b/jeeves/core/tests/test_executor.py @@ -3,7 +3,7 @@ from .factories import FlowFactory, TaskFactory def test_executor_success_task_ok(): - task = TaskFactory(type="jeeves.core.tasks.stub:StubSuccessTask") + task = TaskFactory(type="jeeves.core.actions.stub:StubSuccessAction") flow = FlowFactory(tasks=[task]) runner = Executor(flow) runner.start() @@ -13,7 +13,7 @@ def test_executor_success_task_ok(): def test_executor_non_success_task_ok(): - task = TaskFactory(type="jeeves.core.tasks.stub:StubNonSuccessTask") + task = TaskFactory(type="jeeves.core.actions.stub:StubNonSuccessAction") flow = FlowFactory(tasks=[task]) runner = Executor(flow) runner.start() @@ -23,7 +23,7 @@ def test_executor_non_success_task_ok(): def test_executor_uncaught_exception_in_task_ok(): - task = TaskFactory(type="jeeves.core.tasks.stub:StubUncaughtExceptionTask") + task = TaskFactory(type="jeeves.core.actions.stub:StubUncaughtExceptionAction") flow = FlowFactory(tasks=[task]) runner = Executor(flow) runner.start() diff --git a/jeeves/core/tests/test_parsers.py b/jeeves/core/tests/test_parsers.py index 405484b..87a4c16 100644 --- a/jeeves/core/tests/test_parsers.py +++ b/jeeves/core/tests/test_parsers.py @@ -6,8 +6,8 @@ EXPORTED_FLOW = { "name": "Test", "tasks": [ { - "type": "jeeves.core.tasks.stub:StubSuccessfulTask", - "name": "Test task", + "type": "jeeves.core.actions.stub:StubSuccessfulAction", + "name": "Test Action", "parameters": {}, } ], diff --git a/jeeves/core/tests/test_registry.py b/jeeves/core/tests/test_registry.py index 0df9fed..844696e 100644 --- a/jeeves/core/tests/test_registry.py +++ b/jeeves/core/tests/test_registry.py @@ -1,14 +1,27 @@ import pytest -from jeeves.core.registry import TaskRegistry -from jeeves.core.tasks.base import Task +from jeeves.core.registry import ActionRegistry +from jeeves.core.actions.base import Action +from jeeves.core.actions.stub import StubSuccessAction -def test_registry_get_task_cls_ok(): - task = TaskRegistry.get_task_cls("jeeves.core.tasks.stub:StubSuccessTask") - assert issubclass(task, Task) and not isinstance(task, Task) +def test_registry_get_action_cls_ok(): + action = ActionRegistry.get_action_cls("jeeves.core.actions.stub:StubSuccessAction") + assert issubclass(action, Action) and not isinstance(action, Action) -def test_registry_get_task_ko(): - with pytest.raises(TaskRegistry.TaskDoesNotExist): - TaskRegistry.get_task_cls("non.existant:task") +def test_registry_get_action_cls_ko(): + with pytest.raises(ActionRegistry.ActionDoesNotExist): + ActionRegistry.get_action_cls("non.existant:action") + + +def test_registry_get_action_ok(): + action = ActionRegistry.get_action("jeeves.core.actions.stub:StubSuccessAction") + assert issubclass(action.__class__, Action) and isinstance(action, Action) + + +def test_registry_namespace_conflict_ok(): + + ActionRegistry.register_action(StubSuccessAction) + with pytest.raises(ActionRegistry.ActionNamespaceConflict): + ActionRegistry.register_action(StubSuccessAction) diff --git a/jeeves/core/tests/test_tasks.py b/jeeves/core/tests/test_tasks.py index 67a3f1e..b7ad5c0 100644 --- a/jeeves/core/tests/test_tasks.py +++ b/jeeves/core/tests/test_tasks.py @@ -1,22 +1,28 @@ import pytest import pydantic -from jeeves.core.registry import TaskRegistry +from jeeves.core.registry import ActionRegistry -def test_task_with_empty_parameters_ok(): - task = TaskRegistry.get_task_cls("jeeves.core.tasks.stub:StubNoParametersTask") - task() - task(parameters=None) - task(parameters={}) +def test_action_with_empty_parameters_ok(): + action = ActionRegistry.get_action_cls( + "jeeves.core.actions.stub:StubNoParametersAction" + ) + action() + action(parameters=None) + action(parameters={}) -def test_task_with_parameters_ok(): - task = TaskRegistry.get_task_cls("jeeves.core.tasks.stub:StubParametersTask") - task(parameters=dict(mandatory="text", non_mandatory="text")) +def test_action_with_parameters_ok(): + action = ActionRegistry.get_action_cls( + "jeeves.core.actions.stub:StubParametersAction" + ) + action(parameters=dict(mandatory="text", non_mandatory="text")) -def test_task_with_parameters_ko(): - task = TaskRegistry.get_task_cls("jeeves.core.tasks.stub:StubParametersTask") +def test_action_with_parameters_ko(): + action = ActionRegistry.get_action_cls( + "jeeves.core.actions.stub:StubParametersAction" + ) with pytest.raises(pydantic.ValidationError): - task(parameters=dict(thisshould="fail")) + action(parameters=dict(thisshould="fail")) diff --git a/jeeves/frontend/templates/flows/task-add.j2 b/jeeves/frontend/templates/flows/task-add.j2 index f87b591..f5d9614 100644 --- a/jeeves/frontend/templates/flows/task-add.j2 +++ b/jeeves/frontend/templates/flows/task-add.j2 @@ -27,7 +27,7 @@