Compare commits
16 Commits
72f5f30da6
...
e73709c859
| Author | SHA1 | Date | |
|---|---|---|---|
| e73709c859 | |||
| f0d98d23ff | |||
| 64e7c44a7d | |||
| 3a1870a160 | |||
| c2fcfbb2ab | |||
| e74639c042 | |||
| badc2e28b0 | |||
| 4ac3eb2dfa | |||
| 2bd998fe69 | |||
| c694f42c07 | |||
| 6949bb2814 | |||
| 14f079d5f9 | |||
| 3ca23449e4 | |||
| a6f765c624 | |||
| 43e7dd5f00 | |||
| 37c91d0d5d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,8 @@ tests/TestDBEngineRoot
|
|||||||
.sesskey
|
.sesskey
|
||||||
tools.db
|
tools.db
|
||||||
.mytools_db
|
.mytools_db
|
||||||
|
.idea/MyManagingTools.iml
|
||||||
|
.idea/misc.xml
|
||||||
|
|
||||||
# Created by .ignore support plugin (hsz.mobi)
|
# Created by .ignore support plugin (hsz.mobi)
|
||||||
### Python template
|
### Python template
|
||||||
|
|||||||
11
.idea/MyManagingTools.iml
generated
11
.idea/MyManagingTools.iml
generated
@@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="PYTHON_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager">
|
|
||||||
<content url="file://$MODULE_DIR$">
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
|
|
||||||
</content>
|
|
||||||
<orderEntry type="jdk" jdkName="Python 3.12 (MyManagingTools)" jdkType="Python SDK" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
7
.idea/misc.xml
generated
7
.idea/misc.xml
generated
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Black">
|
|
||||||
<option name="sdkName" value="Python 3.12 (MyManagingTools)" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (MyManagingTools)" project-jdk-type="Python SDK" />
|
|
||||||
</project>
|
|
||||||
@@ -14,7 +14,7 @@ python main.py
|
|||||||
```shell
|
```shell
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
The application will be accessible on port 8000 (or whatever port you configured).
|
The application will be accessible on port 8001 (if the docker compose file was not changed !).
|
||||||
|
|
||||||
2. **Initialize the Mistral model** (first run):
|
2. **Initialize the Mistral model** (first run):
|
||||||
```shell
|
```shell
|
||||||
|
|||||||
@@ -152,6 +152,14 @@ class MyTabs(BaseComponent):
|
|||||||
def get_tab_content_by_key(self, key):
|
def get_tab_content_by_key(self, key):
|
||||||
return self.tabs_by_key[key].content if key in self.tabs_by_key else None
|
return self.tabs_by_key[key].content if key in self.tabs_by_key else None
|
||||||
|
|
||||||
|
def show_tab(self, tab_key, updated_content=None):
|
||||||
|
if updated_content:
|
||||||
|
tab_id = self._get_tab_id_from_tab_key(tab_key)
|
||||||
|
self.set_tab_content(tab_id, updated_content)
|
||||||
|
|
||||||
|
self.select_tab_by_key(tab_key)
|
||||||
|
return self.refresh()
|
||||||
|
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
return self.render(oob=True)
|
return self.render(oob=True)
|
||||||
|
|
||||||
@@ -188,6 +196,13 @@ class MyTabs(BaseComponent):
|
|||||||
active_tab = next(filter(lambda t: t.active, self.tabs), None)
|
active_tab = next(filter(lambda t: t.active, self.tabs), None)
|
||||||
return active_tab.content if active_tab else None
|
return active_tab.content if active_tab else None
|
||||||
|
|
||||||
|
def get_active_tab_key(self):
|
||||||
|
active_tab = next(filter(lambda t: t.active, self.tabs), None)
|
||||||
|
return active_tab.key if active_tab else None
|
||||||
|
|
||||||
|
def _get_tab_id_from_tab_key(self, tab_key):
|
||||||
|
return self.tabs_by_key[tab_key].id if tab_key in self.tabs_by_key else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_component_id(session):
|
def create_component_id(session):
|
||||||
prefix = f"{MY_TABS_INSTANCE_ID}{session['user_id']}"
|
prefix = f"{MY_TABS_INSTANCE_ID}{session['user_id']}"
|
||||||
|
|||||||
@@ -1,30 +1,27 @@
|
|||||||
import logging
|
import logging
|
||||||
from abc import ABC, abstractmethod
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from fastcore.xml import FT
|
||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from components.BaseComponent import BaseComponentSingleton
|
from components.BaseComponent import BaseComponentSingleton
|
||||||
from components.undo_redo.assets.icons import icon_redo, icon_undo
|
from components.undo_redo.assets.icons import icon_redo, icon_undo
|
||||||
from components.undo_redo.commands import UndoRedoCommandManager
|
from components.undo_redo.commands import UndoRedoCommandManager
|
||||||
from components.undo_redo.constants import UNDO_REDO_INSTANCE_ID
|
from components.undo_redo.constants import UNDO_REDO_INSTANCE_ID, UndoRedoAttrs
|
||||||
from components_helpers import mk_icon, mk_tooltip
|
from components_helpers import mk_icon, mk_tooltip
|
||||||
|
from core.settings_management import NoDefault
|
||||||
|
|
||||||
logger = logging.getLogger("UndoRedoApp")
|
logger = logging.getLogger("UndoRedoApp")
|
||||||
|
|
||||||
|
|
||||||
class CommandHistory(ABC):
|
@dataclass
|
||||||
def __init__(self, name, desc, owner):
|
class CommandHistory:
|
||||||
self.name = name
|
attrs: UndoRedoAttrs
|
||||||
self.desc = desc
|
tab_key: str | None
|
||||||
self.owner = owner
|
digest: str | None # digest to remember
|
||||||
|
entry: str # digest to remember
|
||||||
@abstractmethod
|
key: str # key
|
||||||
def undo(self):
|
path: str # path within the key if only on subitem needs to be updated
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def redo(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class UndoRedo(BaseComponentSingleton):
|
class UndoRedo(BaseComponentSingleton):
|
||||||
@@ -35,35 +32,91 @@ class UndoRedo(BaseComponentSingleton):
|
|||||||
self.index = -1
|
self.index = -1
|
||||||
self.history = []
|
self.history = []
|
||||||
self._commands = UndoRedoCommandManager(self)
|
self._commands = UndoRedoCommandManager(self)
|
||||||
|
self._db_engine = settings_manager.get_db_engine()
|
||||||
|
|
||||||
def push(self, command: CommandHistory):
|
def snapshot(self, undo_redo_attrs: UndoRedoAttrs, entry, key, path=None):
|
||||||
self.history = self.history[:self.index + 1]
|
digest = self._settings_manager.get_digest(self._session, entry) # get the current digest (the last one)
|
||||||
|
active_tab_key = self.tabs_manager.get_active_tab_key()
|
||||||
|
|
||||||
|
# init the history if this is the first call
|
||||||
|
if len(self.history) == 0:
|
||||||
|
digest_history = self._settings_manager.history(self._session, entry, digest, 2)
|
||||||
|
command = CommandHistory(undo_redo_attrs,
|
||||||
|
active_tab_key,
|
||||||
|
digest_history[1] if len(digest_history) > 1 else None,
|
||||||
|
entry,
|
||||||
|
key,
|
||||||
|
path)
|
||||||
self.history.append(command)
|
self.history.append(command)
|
||||||
self.index += 1
|
self.index = 0
|
||||||
|
|
||||||
|
command = CommandHistory(undo_redo_attrs, active_tab_key, digest, entry, key, path)
|
||||||
|
|
||||||
|
self.history = self.history[:self.index + 1] #
|
||||||
|
self.history.append(command)
|
||||||
|
self.index = len(self.history) - 1
|
||||||
|
|
||||||
def undo(self):
|
def undo(self):
|
||||||
logger.debug(f"Undo command")
|
logger.debug(f"Undo command")
|
||||||
if self.index < 0 :
|
if self.index < 1:
|
||||||
logger.debug(f" No command to undo.")
|
logger.debug(f" No command to undo.")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
command = self.history[self.index]
|
current = self.history[self.index]
|
||||||
logger.debug(f" Undoing command {command.name} ({command.desc})")
|
current_state = self._settings_manager.load(self._session, None, digest=current.digest)
|
||||||
res = command.undo()
|
|
||||||
self.index -= 1
|
|
||||||
return self, res
|
|
||||||
|
|
||||||
def redo(self):
|
previous = self.history[self.index - 1]
|
||||||
logger.debug("Redo command")
|
previous_state = self._settings_manager.load(self._session, None, digest=previous.digest)
|
||||||
if self.index == len(self.history) - 1:
|
|
||||||
logger.debug(f" No command to redo.")
|
# reapply the state
|
||||||
|
if previous_state is not NoDefault:
|
||||||
|
current_state[current.key] = previous_state[current.key]
|
||||||
|
else:
|
||||||
|
del current_state[current.key]
|
||||||
|
self._settings_manager.save(self._session, current.entry, current_state)
|
||||||
|
|
||||||
|
self.index -= 1
|
||||||
|
|
||||||
|
if current.attrs.on_undo is not None:
|
||||||
|
ret = current.attrs.on_undo()
|
||||||
|
if current.attrs.update_tab and current.tab_key is not None and current.tab_key != self.tabs_manager.get_active_tab_key():
|
||||||
|
ret = self.tabs_manager.show_tab(current.tab_key)
|
||||||
|
elif isinstance(ret, FT) and 'id' in ret.attrs:
|
||||||
|
ret.attrs["hx-swap-oob"] = "true"
|
||||||
|
return self, ret
|
||||||
|
else:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def redo(self):
|
||||||
|
logger.debug(f"Redo command")
|
||||||
|
if self.index >= len(self.history) - 1:
|
||||||
|
logger.debug(f" No command to undo.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
current = self.history[self.index]
|
||||||
|
current_state = self._settings_manager.load(self._session, None, digest=current.digest)
|
||||||
|
|
||||||
|
next_ = self.history[self.index + 1]
|
||||||
|
next_state = self._settings_manager.load(self._session, None, digest=next_.digest)
|
||||||
|
|
||||||
|
# reapply the state
|
||||||
|
if current_state is not NoDefault:
|
||||||
|
current_state[current.key] = next_state[current.key]
|
||||||
|
else:
|
||||||
|
current_state = {current.key: next_state[current.key]}
|
||||||
|
self._settings_manager.save(self._session, current.entry, current_state)
|
||||||
|
|
||||||
self.index += 1
|
self.index += 1
|
||||||
command = self.history[self.index]
|
|
||||||
logger.debug(f" Redoing command {command.name} ({command.desc})")
|
if current.attrs.on_redo is not None:
|
||||||
res = command.redo()
|
ret = current.attrs.on_undo()
|
||||||
return self, res
|
if current.attrs.update_tab and current.tab_key is not None and current.tab_key != self.tabs_manager.get_active_tab_key():
|
||||||
|
ret = self.tabs_manager.show_tab(current.tab_key)
|
||||||
|
elif isinstance(ret, FT) and 'id' in ret.attrs:
|
||||||
|
ret.attrs["hx-swap-oob"] = "true"
|
||||||
|
return self, ret
|
||||||
|
else:
|
||||||
|
return self
|
||||||
|
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
return self.__ft__(oob=True)
|
return self.__ft__(oob=True)
|
||||||
@@ -83,7 +136,7 @@ class UndoRedo(BaseComponentSingleton):
|
|||||||
return mk_tooltip(mk_icon(icon_undo,
|
return mk_tooltip(mk_icon(icon_undo,
|
||||||
size=24,
|
size=24,
|
||||||
**self._commands.undo()),
|
**self._commands.undo()),
|
||||||
f"Undo '{command.name}'.")
|
f"Undo '{command.attrs.name}'.")
|
||||||
else:
|
else:
|
||||||
return mk_tooltip(mk_icon(icon_undo,
|
return mk_tooltip(mk_icon(icon_undo,
|
||||||
size=24,
|
size=24,
|
||||||
@@ -93,11 +146,11 @@ class UndoRedo(BaseComponentSingleton):
|
|||||||
|
|
||||||
def _mk_redo(self):
|
def _mk_redo(self):
|
||||||
if self._can_redo():
|
if self._can_redo():
|
||||||
command = self.history[self.index]
|
command = self.history[self.index + 1]
|
||||||
return mk_tooltip(mk_icon(icon_redo,
|
return mk_tooltip(mk_icon(icon_redo,
|
||||||
size=24,
|
size=24,
|
||||||
**self._commands.redo()),
|
**self._commands.redo()),
|
||||||
f"Redo '{command.name}'.")
|
f"Redo '{command.attrs.name}'.")
|
||||||
else:
|
else:
|
||||||
return mk_tooltip(mk_icon(icon_redo,
|
return mk_tooltip(mk_icon(icon_redo,
|
||||||
size=24,
|
size=24,
|
||||||
@@ -106,7 +159,7 @@ class UndoRedo(BaseComponentSingleton):
|
|||||||
"Nothing to redo.")
|
"Nothing to redo.")
|
||||||
|
|
||||||
def _can_undo(self):
|
def _can_undo(self):
|
||||||
return self.index >= 0
|
return self.index >= 1
|
||||||
|
|
||||||
def _can_redo(self):
|
def _can_redo(self):
|
||||||
return self.index < len(self.history) - 1
|
return self.index < len(self.history) - 1
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
UNDO_REDO_INSTANCE_ID = "__UndoRedo__"
|
UNDO_REDO_INSTANCE_ID = "__UndoRedo__"
|
||||||
|
|
||||||
ROUTE_ROOT = "/undo"
|
ROUTE_ROOT = "/undo"
|
||||||
@@ -6,3 +9,16 @@ ROUTE_ROOT = "/undo"
|
|||||||
class Routes:
|
class Routes:
|
||||||
Undo = "/undo"
|
Undo = "/undo"
|
||||||
Redo = "/redo"
|
Redo = "/redo"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UndoRedoAttrs:
|
||||||
|
name: str
|
||||||
|
desc: str = None
|
||||||
|
update_tab: bool = True
|
||||||
|
on_undo: Callable = None
|
||||||
|
on_redo: Callable = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.on_redo is None:
|
||||||
|
self.on_redo = self.on_undo
|
||||||
@@ -5,11 +5,20 @@
|
|||||||
using `_id={WORKFLOW_DESIGNER_INSTANCE_ID}{session['user_id']}{get_unique_id()}`
|
using `_id={WORKFLOW_DESIGNER_INSTANCE_ID}{session['user_id']}{get_unique_id()}`
|
||||||
|
|
||||||
| Name | value |
|
| Name | value |
|
||||||
|-----------------|--------------------|
|
|----------------------------------|--------------------------------|
|
||||||
| Canvas | `c_{self._id}` |
|
| Canvas | `c_{self._id}` |
|
||||||
| Designer | `d_{self._id}` |
|
| Designer | `d_{self._id}` |
|
||||||
| Error Message | `err_{self._id}` |
|
| Error Message | `err_{self._id}` |
|
||||||
| Properties | `p_{self._id}` |
|
| Properties | `p_{self._id}` |
|
||||||
|
| Properties Input Section | `pi_{self._id}` |
|
||||||
|
| Properties Output Section | `po_{self._id}` |
|
||||||
|
| Properties Properties Section | `pp_{self._id}` |
|
||||||
|
| Properties Properties drag top | `ppt_{self._id}` |
|
||||||
|
| Properties Properties drag left | `ppl_{self._id}` |
|
||||||
|
| Properties Properties drag right | `ppr_{self._id}` |
|
||||||
|
| Properties Properties content | `ppc_{self._id}` |
|
||||||
| Spliter | `s_{self._id}` |
|
| Spliter | `s_{self._id}` |
|
||||||
| Top element | `t_{self._id}` |
|
| Top element | `t_{self._id}` |
|
||||||
|
| Form for properties | `f_{self._id}_{component_id}` |
|
||||||
|
| Form for output properties | `fo_{self._id}_{component_id}` |
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ def post(session, _id: str, designer_height: int):
|
|||||||
return instance.set_designer_height(designer_height)
|
return instance.set_designer_height(designer_height)
|
||||||
|
|
||||||
|
|
||||||
|
@rt(Routes.UpdatePropertiesLayout)
|
||||||
|
def post(session, _id: str, input_width: int, properties_width: int, output_width: int):
|
||||||
|
logger.debug(
|
||||||
|
f"Entering {Routes.UpdatePropertiesLayout} with args {debug_session(session)}, {_id=}, {input_width=}, {properties_width=}, {output_width=}")
|
||||||
|
instance = InstanceManager.get(session, _id)
|
||||||
|
return instance.update_properties_layout(input_width, properties_width, output_width)
|
||||||
|
|
||||||
|
|
||||||
@rt(Routes.SelectComponent)
|
@rt(Routes.SelectComponent)
|
||||||
def post(session, _id: str, component_id: str):
|
def post(session, _id: str, component_id: str):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -129,14 +137,20 @@ def post(session, _id: str, component_id: str, event_name: str, details: dict):
|
|||||||
|
|
||||||
@rt(Routes.PlayWorkflow)
|
@rt(Routes.PlayWorkflow)
|
||||||
def post(session, _id: str, tab_boundaries: str):
|
def post(session, _id: str, tab_boundaries: str):
|
||||||
logger.debug(
|
logger.debug(f"Entering {Routes.PlayWorkflow} with args {debug_session(session)}, {_id=}")
|
||||||
f"Entering {Routes.PlayWorkflow} with args {debug_session(session)}, {_id=}")
|
|
||||||
instance = InstanceManager.get(session, _id)
|
instance = InstanceManager.get(session, _id)
|
||||||
return instance.play_workflow(json.loads(tab_boundaries))
|
return instance.play_workflow(json.loads(tab_boundaries))
|
||||||
|
|
||||||
|
|
||||||
@rt(Routes.StopWorkflow)
|
@rt(Routes.StopWorkflow)
|
||||||
def post(session, _id: str):
|
def post(session, _id: str):
|
||||||
logger.debug(
|
logger.debug(f"Entering {Routes.StopWorkflow} with args {debug_session(session)}, {_id=}")
|
||||||
f"Entering {Routes.StopWorkflow} with args {debug_session(session)}, {_id=}")
|
|
||||||
instance = InstanceManager.get(session, _id)
|
instance = InstanceManager.get(session, _id)
|
||||||
return instance.stop_workflow()
|
return instance.stop_workflow()
|
||||||
|
|
||||||
|
|
||||||
|
@rt(Routes.Refresh)
|
||||||
|
def post(session, _id: str):
|
||||||
|
logger.debug(f"Entering {Routes.Refresh} with args {debug_session(session)}, {_id=}")
|
||||||
|
instance = InstanceManager.get(session, _id)
|
||||||
|
return instance.refresh()
|
||||||
|
|||||||
@@ -47,8 +47,105 @@
|
|||||||
|
|
||||||
.wkf-properties {
|
.wkf-properties {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: var(--color-base-100); /* bg-base-100 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wkf-properties-input, .wkf-properties-output {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: var(--color-base-100); /* bg-base-100 */
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-properties-input {
|
||||||
|
border-width: 1px;
|
||||||
|
border-top-left-radius: 0.5rem; /* rounded on left side */
|
||||||
|
border-bottom-left-radius: 0.5rem;
|
||||||
|
border-top-right-radius: 0; /* not rounded on right side */
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-properties-output {
|
||||||
|
border-width: 1px;
|
||||||
|
border-top-right-radius: 0.5rem; /* rounded on right side */
|
||||||
|
border-bottom-right-radius: 0.5rem;
|
||||||
|
border-top-left-radius: 0; /* not rounded on left side */
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-properties-properties {
|
||||||
|
vertical-align: top;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-properties-handle-left {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 5px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: ew-resize;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-properties-handle-right {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 5px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: ew-resize;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-properties-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: move;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-properties-handle-top {
|
||||||
|
background-image: radial-gradient(var(--color-splitter) 40%, transparent 0);
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 4px 4px;
|
||||||
|
cursor: move;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 8px;
|
||||||
|
width: 20px;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-properties-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%; /* or inherit from a fixed-height parent */
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-properties-content-header {
|
||||||
|
flex-shrink: 0; /* optional: prevent it from shrinking */
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-properties-content-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden; /* prevent double scrollbars if needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.wkf-canvas {
|
.wkf-canvas {
|
||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -193,5 +290,3 @@
|
|||||||
.wkf-connection-path-arrowhead-selected {
|
.wkf-connection-path-arrowhead-selected {
|
||||||
fill:#ef4444 !important;;
|
fill:#ef4444 !important;;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
function bindWorkflowDesigner(elementId) {
|
function bindWorkflowDesigner(elementId) {
|
||||||
bindWorkflowDesignerToolbox(elementId)
|
bindWorkflowDesignerToolbox(elementId)
|
||||||
bindWorkflowDesignerSplitter(elementId)
|
bindWorkflowDesignerSplitter(elementId)
|
||||||
|
bindWorkflowProperties(elementId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindWorkflowDesignerToolbox(elementId) {
|
function bindWorkflowDesignerToolbox(elementId) {
|
||||||
@@ -204,7 +205,7 @@ function bindWorkflowDesignerToolbox(elementId) {
|
|||||||
// Also trigger server-side selection
|
// Also trigger server-side selection
|
||||||
utils.makeRequest('/workflows/select-component', {
|
utils.makeRequest('/workflows/select-component', {
|
||||||
component_id: designer.selectedComponent
|
component_id: designer.selectedComponent
|
||||||
}, `#p_${elementId}`, "outerHTML");
|
}, `#ppc_${elementId}`, "outerHTML");
|
||||||
},
|
},
|
||||||
|
|
||||||
// Deselect all components
|
// Deselect all components
|
||||||
@@ -612,3 +613,153 @@ function bindWorkflowDesignerSplitter(elementId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindWorkflowProperties(elementId) {
|
||||||
|
let isDragging = false;
|
||||||
|
let isResizing = false;
|
||||||
|
let startX = 0;
|
||||||
|
let startWidths = {};
|
||||||
|
let resizeType = '';
|
||||||
|
|
||||||
|
console.debug("Binding Properties component for "+ elementId)
|
||||||
|
properties_component = document.getElementById(`p_${elementId}`);
|
||||||
|
if (properties_component == null) {
|
||||||
|
console.error(`'Component ' p_${elementId}' is not found !' `)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const totalWidth = properties_component.getBoundingClientRect().width
|
||||||
|
console.debug("totalWidth", totalWidth)
|
||||||
|
|
||||||
|
const minPropertiesWidth = 352; // this value avoid scroll bars
|
||||||
|
|
||||||
|
const inputSection = document.getElementById(`pi_${elementId}`);
|
||||||
|
const propertiesSection = document.getElementById(`pp_${elementId}`);
|
||||||
|
const outputSection = document.getElementById(`po_${elementId}`);
|
||||||
|
const dragHandle = document.getElementById(`ppt_${elementId}`);
|
||||||
|
const leftHandle = document.getElementById(`ppl_${elementId}`);
|
||||||
|
const rightHandle = document.getElementById(`ppr_${elementId}`);
|
||||||
|
|
||||||
|
// Drag and drop for moving properties section
|
||||||
|
dragHandle.addEventListener('mousedown', (e) => {
|
||||||
|
isDragging = true;
|
||||||
|
startX = e.clientX;
|
||||||
|
startWidths = {
|
||||||
|
input: parseInt(inputSection.style.width),
|
||||||
|
properties: parseInt(propertiesSection.style.width),
|
||||||
|
output: parseInt(outputSection.style.width)
|
||||||
|
};
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Left resize handle
|
||||||
|
leftHandle.addEventListener('mousedown', (e) => {
|
||||||
|
isResizing = true;
|
||||||
|
resizeType = 'left';
|
||||||
|
startX = e.clientX;
|
||||||
|
startWidths = {
|
||||||
|
input: parseInt(inputSection.style.width),
|
||||||
|
properties: parseInt(propertiesSection.style.width),
|
||||||
|
output: parseInt(outputSection.style.width)
|
||||||
|
};
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Right resize handle
|
||||||
|
rightHandle.addEventListener('mousedown', (e) => {
|
||||||
|
isResizing = true;
|
||||||
|
resizeType = 'right';
|
||||||
|
startX = e.clientX;
|
||||||
|
startWidths = {
|
||||||
|
input: parseInt(inputSection.style.width),
|
||||||
|
properties: parseInt(propertiesSection.style.width),
|
||||||
|
output: parseInt(outputSection.style.width)
|
||||||
|
};
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse move
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (isDragging) {
|
||||||
|
const deltaX = e.clientX - startX;
|
||||||
|
let newInputWidth = startWidths.input + deltaX;
|
||||||
|
let newOutputWidth = startWidths.output - deltaX;
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
if (newInputWidth < 0) {
|
||||||
|
newInputWidth = 0;
|
||||||
|
newOutputWidth = totalWidth - startWidths.properties;
|
||||||
|
}
|
||||||
|
if (newOutputWidth < 0) {
|
||||||
|
newOutputWidth = 0;
|
||||||
|
newInputWidth = totalWidth - startWidths.properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputSection.style.width = newInputWidth + 'px';
|
||||||
|
outputSection.style.width = newOutputWidth + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isResizing) {
|
||||||
|
const deltaX = e.clientX - startX;
|
||||||
|
let newInputWidth = startWidths.input;
|
||||||
|
let newPropertiesWidth = startWidths.properties;
|
||||||
|
let newOutputWidth = startWidths.output;
|
||||||
|
|
||||||
|
if (resizeType === 'left') {
|
||||||
|
newInputWidth = startWidths.input + deltaX;
|
||||||
|
newPropertiesWidth = startWidths.properties - deltaX;
|
||||||
|
|
||||||
|
if (newInputWidth < 0) {
|
||||||
|
newInputWidth = 0;
|
||||||
|
newPropertiesWidth = startWidths.input + startWidths.properties;
|
||||||
|
}
|
||||||
|
if (newPropertiesWidth < minPropertiesWidth) {
|
||||||
|
newPropertiesWidth = minPropertiesWidth;
|
||||||
|
newInputWidth = totalWidth - minPropertiesWidth - startWidths.output;
|
||||||
|
}
|
||||||
|
} else if (resizeType === 'right') {
|
||||||
|
newPropertiesWidth = startWidths.properties + deltaX;
|
||||||
|
newOutputWidth = startWidths.output - deltaX;
|
||||||
|
|
||||||
|
if (newOutputWidth < 0) {
|
||||||
|
newOutputWidth = 0;
|
||||||
|
newPropertiesWidth = startWidths.properties + startWidths.output;
|
||||||
|
}
|
||||||
|
if (newPropertiesWidth < minPropertiesWidth) {
|
||||||
|
newPropertiesWidth = minPropertiesWidth;
|
||||||
|
newOutputWidth = totalWidth - startWidths.input - minPropertiesWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inputSection.style.width = newInputWidth + 'px';
|
||||||
|
propertiesSection.style.width = newPropertiesWidth + 'px';
|
||||||
|
outputSection.style.width = newOutputWidth + 'px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse up
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
if (isDragging || isResizing) {
|
||||||
|
// Send HTMX request with new dimensions
|
||||||
|
const currentWidths = {
|
||||||
|
input_width: parseInt(inputSection.style.width),
|
||||||
|
properties_width: parseInt(propertiesSection.style.width),
|
||||||
|
output_width: parseInt(outputSection.style.width)
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
htmx.ajax('POST', '/workflows/update-properties-layout', {
|
||||||
|
target: `#${elementId}`,
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
swap: "outerHTML",
|
||||||
|
values: { _id: elementId, ...currentWidths }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('HTMX request failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDragging = false;
|
||||||
|
isResizing = false;
|
||||||
|
resizeType = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -23,3 +23,6 @@ icon_pause_circle = NotStr(
|
|||||||
# fluent RecordStop20Regular
|
# fluent RecordStop20Regular
|
||||||
icon_stop_circle = NotStr(
|
icon_stop_circle = NotStr(
|
||||||
"""<svg name="stop" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20"><g fill="none"><path d="M10 3a7 7 0 1 0 0 14a7 7 0 0 0 0-14zm-8 7a8 8 0 1 1 16 0a8 8 0 0 1-16 0zm5-2a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V8z" fill="currentColor"></path></g></svg>""")
|
"""<svg name="stop" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20"><g fill="none"><path d="M10 3a7 7 0 1 0 0 14a7 7 0 0 0 0-14zm-8 7a8 8 0 1 1 16 0a8 8 0 0 1-16 0zm5-2a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V8z" fill="currentColor"></path></g></svg>""")
|
||||||
|
|
||||||
|
# fluent ArrowClockwise20Regular
|
||||||
|
icon_refresh = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20"><g fill="none"><path d="M3.066 9.05a7 7 0 0 1 12.557-3.22l.126.17H12.5a.5.5 0 1 0 0 1h4a.5.5 0 0 0 .5-.5V2.502a.5.5 0 0 0-1 0v2.207a8 8 0 1 0 1.986 4.775a.5.5 0 0 0-.998.064A7 7 0 1 1 3.066 9.05z" fill="currentColor"></path></g></svg>""")
|
||||||
@@ -1,23 +1,6 @@
|
|||||||
from components.BaseCommandManager import BaseCommandManager
|
from components.BaseCommandManager import BaseCommandManager
|
||||||
from components.undo_redo.components.UndoRedo import CommandHistory
|
|
||||||
from components.workflows.constants import Routes, ROUTE_ROOT
|
from components.workflows.constants import Routes, ROUTE_ROOT
|
||||||
|
|
||||||
class AddConnectorCommand(CommandHistory):
|
|
||||||
|
|
||||||
def __init__(self, owner, connector):
|
|
||||||
super().__init__("Add connector", "Add connector", owner)
|
|
||||||
self.connector = connector
|
|
||||||
|
|
||||||
def undo(self):
|
|
||||||
del self.owner.get_state().components[self.connector.id]
|
|
||||||
self.owner.get_db().save_state(self.owner.get_key(), self.owner.get_state()) # update db
|
|
||||||
return self.owner.refresh_designer(True)
|
|
||||||
|
|
||||||
def redo(self, oob=True):
|
|
||||||
self.owner.get_state().components[self.connector.id] = self.connector
|
|
||||||
self.owner.get_db().save_state(self.owner.get_key(), self.owner.get_state()) # update db
|
|
||||||
return self.owner.refresh_designer(oob)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowsCommandManager(BaseCommandManager):
|
class WorkflowsCommandManager(BaseCommandManager):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
@@ -54,7 +37,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
|
|||||||
def select_processor(self, component_id: str):
|
def select_processor(self, component_id: str):
|
||||||
return {
|
return {
|
||||||
"hx_post": f"{ROUTE_ROOT}{Routes.SelectProcessor}",
|
"hx_post": f"{ROUTE_ROOT}{Routes.SelectProcessor}",
|
||||||
"hx-target": f"#p_{self._id}",
|
"hx-target": f"#ppc_{self._id}",
|
||||||
"hx-swap": "outerHTML",
|
"hx-swap": "outerHTML",
|
||||||
"hx-trigger": "change",
|
"hx-trigger": "change",
|
||||||
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
|
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
|
||||||
@@ -63,7 +46,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
|
|||||||
def save_properties(self, component_id: str):
|
def save_properties(self, component_id: str):
|
||||||
return {
|
return {
|
||||||
"hx_post": f"{ROUTE_ROOT}{Routes.SaveProperties}",
|
"hx_post": f"{ROUTE_ROOT}{Routes.SaveProperties}",
|
||||||
"hx-target": f"#p_{self._id}",
|
"hx-target": f"#ppc_{self._id}",
|
||||||
"hx-swap": "outerHTML",
|
"hx-swap": "outerHTML",
|
||||||
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
|
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
|
||||||
}
|
}
|
||||||
@@ -71,7 +54,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
|
|||||||
def cancel_properties(self, component_id: str):
|
def cancel_properties(self, component_id: str):
|
||||||
return {
|
return {
|
||||||
"hx_post": f"{ROUTE_ROOT}{Routes.CancelProperties}",
|
"hx_post": f"{ROUTE_ROOT}{Routes.CancelProperties}",
|
||||||
"hx-target": f"#p_{self._id}",
|
"hx-target": f"#ppc_{self._id}",
|
||||||
"hx-swap": "outerHTML",
|
"hx-swap": "outerHTML",
|
||||||
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
|
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
|
||||||
}
|
}
|
||||||
@@ -79,7 +62,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
|
|||||||
def on_processor_details_event(self, component_id: str, event_name: str):
|
def on_processor_details_event(self, component_id: str, event_name: str):
|
||||||
return {
|
return {
|
||||||
"hx_post": f"{ROUTE_ROOT}{Routes.OnProcessorDetailsEvent}",
|
"hx_post": f"{ROUTE_ROOT}{Routes.OnProcessorDetailsEvent}",
|
||||||
"hx-target": f"#p_{self._id}",
|
"hx-target": f"#ppc_{self._id}",
|
||||||
"hx-trigger": "change",
|
"hx-trigger": "change",
|
||||||
"hx-swap": "outerHTML",
|
"hx-swap": "outerHTML",
|
||||||
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}", "event_name": "{event_name}"}}',
|
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}", "event_name": "{event_name}"}}',
|
||||||
@@ -109,6 +92,13 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
|
|||||||
"hx-vals": f'js:{{"_id": "{self._id}"}}',
|
"hx-vals": f'js:{{"_id": "{self._id}"}}',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
return {
|
||||||
|
"hx_post": f"{ROUTE_ROOT}{Routes.Refresh}",
|
||||||
|
"hx-swap": "none",
|
||||||
|
"hx-vals": f'js:{{"_id": "{self._id}"}}',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class WorkflowPlayerCommandManager(BaseCommandManager):
|
class WorkflowPlayerCommandManager(BaseCommandManager):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ from fasthtml.xtend import Script
|
|||||||
|
|
||||||
from assets.icons import icon_error
|
from assets.icons import icon_error
|
||||||
from components.BaseComponent import BaseComponent
|
from components.BaseComponent import BaseComponent
|
||||||
from components.workflows.assets.icons import icon_play, icon_pause, icon_stop
|
from components.undo_redo.constants import UndoRedoAttrs
|
||||||
from components.workflows.commands import WorkflowDesignerCommandManager, AddConnectorCommand
|
from components.workflows.assets.icons import icon_play, icon_pause, icon_stop, icon_refresh
|
||||||
|
from components.workflows.commands import WorkflowDesignerCommandManager
|
||||||
|
from components.workflows.components.WorkflowDesignerProperties import WorkflowDesignerProperties
|
||||||
from components.workflows.components.WorkflowPlayer import WorkflowPlayer
|
from components.workflows.components.WorkflowPlayer import WorkflowPlayer
|
||||||
from components.workflows.constants import WORKFLOW_DESIGNER_INSTANCE_ID, ProcessorTypes
|
from components.workflows.constants import WORKFLOW_DESIGNER_INSTANCE_ID, ProcessorTypes, COMPONENT_TYPES, \
|
||||||
|
PROCESSOR_TYPES
|
||||||
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, \
|
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, \
|
||||||
Connection, WorkflowsDesignerDbManager, ComponentState
|
Connection, WorkflowsDesignerDbManager, ComponentState, WorkflowsDesignerState
|
||||||
from components_helpers import apply_boundaries, mk_tooltip, mk_dialog_buttons, mk_icon
|
from components_helpers import apply_boundaries, mk_tooltip, mk_dialog_buttons, mk_icon
|
||||||
from core.instance_manager import InstanceManager
|
from core.instance_manager import InstanceManager
|
||||||
from core.jira import JiraRequestTypes, DEFAULT_SEARCH_FIELDS
|
from core.jira import JiraRequestTypes, DEFAULT_SEARCH_FIELDS
|
||||||
@@ -21,33 +24,6 @@ from utils.DbManagementHelper import DbManagementHelper
|
|||||||
|
|
||||||
logger = logging.getLogger("WorkflowDesigner")
|
logger = logging.getLogger("WorkflowDesigner")
|
||||||
|
|
||||||
# Component templates
|
|
||||||
COMPONENT_TYPES = {
|
|
||||||
ProcessorTypes.Producer: {
|
|
||||||
"title": "Data Producer",
|
|
||||||
"description": "Generates or loads data",
|
|
||||||
"icon": "📊",
|
|
||||||
"color": "bg-green-100 border-green-300 text-neutral"
|
|
||||||
},
|
|
||||||
ProcessorTypes.Filter: {
|
|
||||||
"title": "Data Filter",
|
|
||||||
"description": "Filters and transforms data",
|
|
||||||
"icon": "🔍",
|
|
||||||
"color": "bg-blue-100 border-blue-300 text-neutral"
|
|
||||||
},
|
|
||||||
ProcessorTypes.Presenter: {
|
|
||||||
"title": "Data Presenter",
|
|
||||||
"description": "Displays or exports data",
|
|
||||||
"icon": "📋",
|
|
||||||
"color": "bg-purple-100 border-purple-300 text-neutral"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PROCESSOR_TYPES = {
|
|
||||||
ProcessorTypes.Producer: ["Repository", "Jira"],
|
|
||||||
ProcessorTypes.Filter: ["Default"],
|
|
||||||
ProcessorTypes.Presenter: ["Default"]}
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowDesigner(BaseComponent):
|
class WorkflowDesigner(BaseComponent):
|
||||||
def __init__(self, session,
|
def __init__(self, session,
|
||||||
@@ -63,9 +39,11 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
self._key = key
|
self._key = key
|
||||||
self._designer_settings = designer_settings
|
self._designer_settings = designer_settings
|
||||||
self._db = WorkflowsDesignerDbManager(session, settings_manager)
|
self._db = WorkflowsDesignerDbManager(session, settings_manager)
|
||||||
self._state = self._db.load_state(key)
|
self._undo_redo = ComponentsInstancesHelper.get_undo_redo(session)
|
||||||
|
self._state: WorkflowsDesignerState = self._db.load_state(key)
|
||||||
self._boundaries = boundaries
|
self._boundaries = boundaries
|
||||||
self.commands = WorkflowDesignerCommandManager(self)
|
self.commands = WorkflowDesignerCommandManager(self)
|
||||||
|
self.properties = WorkflowDesignerProperties(self._session, f"{self._id}", self)
|
||||||
|
|
||||||
workflow_name = self._designer_settings.workflow_name
|
workflow_name = self._designer_settings.workflow_name
|
||||||
self._player = InstanceManager.get(self._session,
|
self._player = InstanceManager.get(self._session,
|
||||||
@@ -81,7 +59,10 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
def set_boundaries(self, boundaries: dict):
|
def set_boundaries(self, boundaries: dict):
|
||||||
self._boundaries = boundaries
|
self._boundaries = boundaries
|
||||||
|
|
||||||
def get_state(self):
|
def get_boundaries(self):
|
||||||
|
return self._boundaries
|
||||||
|
|
||||||
|
def get_state(self) -> WorkflowsDesignerState:
|
||||||
return self._state
|
return self._state
|
||||||
|
|
||||||
def get_db(self):
|
def get_db(self):
|
||||||
@@ -91,11 +72,23 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
return self._key
|
return self._key
|
||||||
|
|
||||||
def refresh_designer(self, oob=False):
|
def refresh_designer(self, oob=False):
|
||||||
|
if oob:
|
||||||
return self._mk_canvas(oob)
|
return self._mk_canvas(oob)
|
||||||
|
else:
|
||||||
|
return self._mk_elements()
|
||||||
|
|
||||||
def refresh_properties(self, oob=False):
|
def refresh_properties(self, oob=False):
|
||||||
return self._mk_properties(oob)
|
return self._mk_properties(oob)
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
return self.__ft__(oob=True)
|
||||||
|
|
||||||
|
def refresh_state(self):
|
||||||
|
self._state = self._db.load_state(self._key)
|
||||||
|
self.properties.update_layout()
|
||||||
|
self.properties.update_component(self._state.selected_component_id)
|
||||||
|
return self.__ft__(oob=True)
|
||||||
|
|
||||||
def add_component(self, component_type, x, y):
|
def add_component(self, component_type, x, y):
|
||||||
self._state.component_counter += 1
|
self._state.component_counter += 1
|
||||||
|
|
||||||
@@ -111,39 +104,40 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
description=info["description"],
|
description=info["description"],
|
||||||
properties={"processor_name": PROCESSOR_TYPES[component_type][0]}
|
properties={"processor_name": PROCESSOR_TYPES[component_type][0]}
|
||||||
)
|
)
|
||||||
|
|
||||||
command = AddConnectorCommand(self, component)
|
|
||||||
undo_redo = ComponentsInstancesHelper.get_undo_redo(self._session)
|
|
||||||
#undo_redo.push(command)
|
|
||||||
self._state.components[component_id] = component
|
self._state.components[component_id] = component
|
||||||
self._db.save_state(self._key, self._state) # update db
|
|
||||||
undo_redo.snapshot("add_component")
|
undo_redo_attrs = UndoRedoAttrs(f"Add Component '{component_type}'", on_undo=self.refresh_state)
|
||||||
return command.redo(), undo_redo.refresh()
|
self._db.save_state(self._key, self._state, undo_redo_attrs) # update db
|
||||||
# self._state.components[component_id] = component
|
|
||||||
# self._db.save_state(self._key, self._state) # update db
|
return self.refresh_designer(), self._undo_redo.refresh()
|
||||||
# return self.refresh_designer()
|
|
||||||
|
|
||||||
def move_component(self, component_id, x, y):
|
def move_component(self, component_id, x, y):
|
||||||
if component_id in self._state.components:
|
if component_id in self._state.components:
|
||||||
|
component = self._state.components[component_id]
|
||||||
self._state.selected_component_id = component_id
|
self._state.selected_component_id = component_id
|
||||||
self._state.components[component_id].x = int(x)
|
component.x = int(x)
|
||||||
self._state.components[component_id].y = int(y)
|
component.y = int(y)
|
||||||
self._db.save_state(self._key, self._state) # update db
|
|
||||||
|
|
||||||
return self.refresh_designer(), self.refresh_properties(True)
|
undo_redo_attrs = UndoRedoAttrs(f"Move Component '{component.title}'", on_undo=self.refresh_state)
|
||||||
|
self._db.save_state(self._key, self._state, undo_redo_attrs) # update db
|
||||||
|
|
||||||
|
return self.refresh_designer(), self.properties.refresh(mode="form", oob=True), self._undo_redo.refresh()
|
||||||
|
|
||||||
def delete_component(self, component_id):
|
def delete_component(self, component_id):
|
||||||
# Remove component
|
# Remove component
|
||||||
if component_id in self._state.components:
|
if component_id in self._state.components:
|
||||||
|
component = self._state.components[component_id]
|
||||||
del self._state.components[component_id]
|
del self._state.components[component_id]
|
||||||
|
|
||||||
# Remove related connections
|
# Remove related connections
|
||||||
self._state.connections = [connection for connection in self._state.connections
|
self._state.connections = [connection for connection in self._state.connections
|
||||||
if connection.from_id != component_id and connection.to_id != component_id]
|
if connection.from_id != component_id and connection.to_id != component_id]
|
||||||
# update db
|
|
||||||
self._db.save_state(self._key, self._state)
|
|
||||||
|
|
||||||
return self.refresh_designer()
|
# update db
|
||||||
|
undo_redo_attrs = UndoRedoAttrs(f"Remove Component '{component.title}'", on_undo=self.refresh_state)
|
||||||
|
self._db.save_state(self._key, self._state, undo_redo_attrs)
|
||||||
|
|
||||||
|
return self.refresh_designer(), self._undo_redo.refresh()
|
||||||
|
|
||||||
def add_connection(self, from_id, to_id):
|
def add_connection(self, from_id, to_id):
|
||||||
# Check if connection already exists
|
# Check if connection already exists
|
||||||
@@ -156,9 +150,10 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
self._state.connections.append(connection)
|
self._state.connections.append(connection)
|
||||||
|
|
||||||
# update db
|
# update db
|
||||||
self._db.save_state(self._key, self._state)
|
undo_redo_attrs = UndoRedoAttrs(f"Add Connection", on_undo=self.refresh_state)
|
||||||
|
self._db.save_state(self._key, self._state, undo_redo_attrs)
|
||||||
|
|
||||||
return self.refresh_designer()
|
return self.refresh_designer(), self._undo_redo.refresh()
|
||||||
|
|
||||||
def delete_connection(self, from_id, to_id):
|
def delete_connection(self, from_id, to_id):
|
||||||
for connection in self._state.connections:
|
for connection in self._state.connections:
|
||||||
@@ -166,44 +161,63 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
self._state.connections.remove(connection)
|
self._state.connections.remove(connection)
|
||||||
|
|
||||||
# update db
|
# update db
|
||||||
self._db.save_state(self._key, self._state)
|
undo_redo_attrs = UndoRedoAttrs(f"Delete Connection", on_undo=self.refresh_state)
|
||||||
|
self._db.save_state(self._key, self._state, undo_redo_attrs)
|
||||||
|
|
||||||
return self.refresh_designer()
|
return self.refresh_designer(), self._undo_redo.refresh()
|
||||||
|
|
||||||
def set_designer_height(self, height):
|
def set_designer_height(self, height):
|
||||||
self._state.designer_height = height
|
self._state.designer_height = height
|
||||||
self._db.save_state(self._key, self._state)
|
|
||||||
return self.__ft__() # refresh the whole component
|
undo_redo_attrs = UndoRedoAttrs(f"Resize Designer", on_undo=lambda: self.refresh_state())
|
||||||
|
self._db.save_state(self._key, self._state, undo_redo_attrs)
|
||||||
|
return self.__ft__(), self._undo_redo.refresh() # refresh the whole component
|
||||||
|
|
||||||
|
def update_properties_layout(self, input_width, properties_width, output_width):
|
||||||
|
self._state.properties_input_width = input_width
|
||||||
|
self._state.properties_properties_width = properties_width
|
||||||
|
self._state.properties_output_width = output_width
|
||||||
|
self.properties.update_layout()
|
||||||
|
|
||||||
|
undo_redo_attrs = UndoRedoAttrs(f"Resize Properties", on_undo=lambda: self.refresh_state())
|
||||||
|
self._db.save_state(self._key, self._state, undo_redo_attrs)
|
||||||
|
return self.__ft__(), self._undo_redo.refresh() # refresh the whole component
|
||||||
|
|
||||||
def select_component(self, component_id):
|
def select_component(self, component_id):
|
||||||
if component_id in self._state.components:
|
if component_id in self._state.components:
|
||||||
self._state.selected_component_id = component_id
|
self._state.selected_component_id = component_id
|
||||||
self._db.save_state(self._key, self._state)
|
|
||||||
|
|
||||||
return self.refresh_properties()
|
component = self._state.components[component_id]
|
||||||
|
undo_redo_attrs = UndoRedoAttrs(f"Select Component {component.title}", on_undo=self.refresh_state)
|
||||||
|
self._db.save_state(self._key, self._state, undo_redo_attrs)
|
||||||
|
|
||||||
|
return self.properties.refresh(mode="form"), self._undo_redo.refresh()
|
||||||
|
|
||||||
def save_properties(self, component_id: str, details: dict):
|
def save_properties(self, component_id: str, details: dict):
|
||||||
if component_id in self._state.components:
|
if component_id in self._state.components:
|
||||||
component = self._state.components[component_id]
|
component = self._state.components[component_id]
|
||||||
component.properties = details
|
component.properties |= details
|
||||||
self._db.save_state(self._key, self._state)
|
|
||||||
|
undo_redo_attrs = UndoRedoAttrs(f"Set properties for {component.title}", on_undo=self.refresh_state)
|
||||||
|
self._db.save_state(self._key, self._state, undo_redo_attrs)
|
||||||
logger.debug(f"Saved properties for component {component_id}: {details}")
|
logger.debug(f"Saved properties for component {component_id}: {details}")
|
||||||
|
|
||||||
return self.refresh_properties()
|
return self.properties.refresh(mode="form"), self._undo_redo.refresh()
|
||||||
|
|
||||||
def cancel_properties(self, component_id: str):
|
def cancel_properties(self, component_id: str):
|
||||||
if component_id in self._state.components:
|
if component_id in self._state.components:
|
||||||
logger.debug(f"Cancel saving properties for component {component_id}")
|
logger.debug(f"Cancel saving properties for component {component_id}")
|
||||||
|
|
||||||
return self.refresh_properties()
|
return self.properties.refresh(mode="form")
|
||||||
|
|
||||||
def set_selected_processor(self, component_id: str, processor_name: str):
|
def set_selected_processor(self, component_id: str, processor_name: str):
|
||||||
if component_id in self._state.components:
|
if component_id in self._state.components:
|
||||||
component = self._state.components[component_id]
|
component = self._state.components[component_id]
|
||||||
component.properties = {"processor_name": processor_name}
|
component.properties = {"processor_name": processor_name}
|
||||||
|
|
||||||
self._db.save_state(self._key, self._state)
|
undo_redo_attrs = UndoRedoAttrs(f"Set Processor for {component.title}", on_undo=self.refresh_state)
|
||||||
return self.refresh_properties()
|
self._db.save_state(self._key, self._state, undo_redo_attrs)
|
||||||
|
return self.properties.refresh(mode="form"), self._undo_redo.refresh()
|
||||||
|
|
||||||
def play_workflow(self, boundaries: dict):
|
def play_workflow(self, boundaries: dict):
|
||||||
self._error_message = None
|
self._error_message = None
|
||||||
@@ -236,7 +250,7 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
elif event_name == "OnJiraRequestTypeChanged":
|
elif event_name == "OnJiraRequestTypeChanged":
|
||||||
component.properties["request_type"] = details["request_type"]
|
component.properties["request_type"] = details["request_type"]
|
||||||
|
|
||||||
return self.refresh_properties()
|
return self.properties.refresh(mode="form")
|
||||||
|
|
||||||
def get_workflow_name(self):
|
def get_workflow_name(self):
|
||||||
return self._designer_settings.workflow_name
|
return self._designer_settings.workflow_name
|
||||||
@@ -247,12 +261,13 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
def get_workflow_connections(self):
|
def get_workflow_connections(self):
|
||||||
return self._state.connections
|
return self._state.connections
|
||||||
|
|
||||||
def __ft__(self):
|
def __ft__(self, oob=False):
|
||||||
return Div(
|
return Div(
|
||||||
H1(f"{self._designer_settings.workflow_name}", cls="text-xl font-bold"),
|
H1(f"{self._designer_settings.workflow_name}", cls="text-xl font-bold"),
|
||||||
P("Drag components from the toolbox to the canvas to create your workflow.", cls="text-sm mb-6"),
|
# P("Drag components from the toolbox to the canvas to create your workflow.", cls="text-sm"),
|
||||||
Div(
|
Div(
|
||||||
self._mk_media(),
|
self._mk_media(),
|
||||||
|
# self._mk_refresh_button(),
|
||||||
self._mk_error_message(),
|
self._mk_error_message(),
|
||||||
cls="flex mb-2",
|
cls="flex mb-2",
|
||||||
id=f"t_{self._id}"
|
id=f"t_{self._id}"
|
||||||
@@ -263,6 +278,7 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
Script(f"bindWorkflowDesigner('{self._id}');"),
|
Script(f"bindWorkflowDesigner('{self._id}');"),
|
||||||
**apply_boundaries(self._boundaries),
|
**apply_boundaries(self._boundaries),
|
||||||
id=f"{self._id}",
|
id=f"{self._id}",
|
||||||
|
hx_swap_oob='true' if oob else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mk_connection_svg(self, conn: Connection):
|
def _mk_connection_svg(self, conn: Connection):
|
||||||
@@ -340,6 +356,11 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _mk_elements(self):
|
def _mk_elements(self):
|
||||||
|
if len(self._state.components) == 0:
|
||||||
|
return Div("Drag components from the toolbox to the canvas to create your workflow.",
|
||||||
|
cls="flex items-center justify-center h-full w-full"
|
||||||
|
)
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
# Render connections
|
# Render connections
|
||||||
*[NotStr(self._mk_connection_svg(conn)) for conn in self._state.connections],
|
*[NotStr(self._mk_connection_svg(conn)) for conn in self._state.connections],
|
||||||
@@ -384,6 +405,9 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
cls=f"media-controls flex m-2"
|
cls=f"media-controls flex m-2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _mk_refresh_button(self):
|
||||||
|
return mk_icon(icon_refresh, **self.commands.refresh())
|
||||||
|
|
||||||
def _mk_error_message(self):
|
def _mk_error_message(self):
|
||||||
if not self._error_message:
|
if not self._error_message:
|
||||||
return Div()
|
return Div()
|
||||||
@@ -408,6 +432,17 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
|
|
||||||
return Div('Not defined yet !')
|
return Div('Not defined yet !')
|
||||||
|
|
||||||
|
def _mk_properties_output(self, component):
|
||||||
|
return Div(
|
||||||
|
"Output name",
|
||||||
|
Input(type="input",
|
||||||
|
name="output_name",
|
||||||
|
placeholder="data",
|
||||||
|
value=component.properties.get("output_name", None),
|
||||||
|
cls="input w-xs"),
|
||||||
|
cls="join"
|
||||||
|
)
|
||||||
|
|
||||||
def _mk_properties_details(self, component_id, allow_component_selection=False):
|
def _mk_properties_details(self, component_id, allow_component_selection=False):
|
||||||
def _mk_header():
|
def _mk_header():
|
||||||
return Div(
|
return Div(
|
||||||
@@ -441,11 +476,32 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
return Div(
|
return Div(
|
||||||
Form(
|
Form(
|
||||||
_mk_header(),
|
_mk_header(),
|
||||||
|
Div(
|
||||||
|
Input(type="radio", name=f"pt_{self._id}", cls="tab", aria_label="Properties", checked="checked"),
|
||||||
|
Div(
|
||||||
_mk_select(),
|
_mk_select(),
|
||||||
self._mk_processor_properties(component, selected_processor_name),
|
self._mk_processor_properties(component, selected_processor_name),
|
||||||
|
cls="tab-content"
|
||||||
|
),
|
||||||
|
|
||||||
|
Input(type="radio", name=f"pt_{self._id}", cls="tab", aria_label="Inputs"),
|
||||||
|
Div(
|
||||||
|
"Inputs",
|
||||||
|
cls="tab-content"
|
||||||
|
),
|
||||||
|
|
||||||
|
Input(type="radio", name=f"pt_{self._id}", cls="tab", aria_label="Output"),
|
||||||
|
Div(
|
||||||
|
self._mk_properties_output(component),
|
||||||
|
cls="tab-content"
|
||||||
|
),
|
||||||
|
|
||||||
|
cls="tabs tabs-border"
|
||||||
|
),
|
||||||
mk_dialog_buttons(cls="mt-4",
|
mk_dialog_buttons(cls="mt-4",
|
||||||
on_ok=self.commands.save_properties(component_id),
|
on_ok=self.commands.save_properties(component_id),
|
||||||
on_cancel=self.commands.cancel_properties(component_id)),
|
on_cancel=self.commands.cancel_properties(component_id)),
|
||||||
|
|
||||||
cls="font-mono text-sm",
|
cls="font-mono text-sm",
|
||||||
id=f"f_{self._id}_{component_id}",
|
id=f"f_{self._id}_{component_id}",
|
||||||
),
|
),
|
||||||
@@ -453,13 +509,7 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _mk_properties(self, oob=False):
|
def _mk_properties(self, oob=False):
|
||||||
return Div(
|
return self.properties
|
||||||
self._mk_properties_details(self._state.selected_component_id),
|
|
||||||
cls="p-2 bg-base-100 rounded-lg border",
|
|
||||||
style=f"height:{self._get_properties_height()}px;",
|
|
||||||
hx_swap_oob='true' if oob else None,
|
|
||||||
id=f"p_{self._id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _mk_jira_processor_details(self, component):
|
def _mk_jira_processor_details(self, component):
|
||||||
def _mk_option(name):
|
def _mk_option(name):
|
||||||
@@ -468,7 +518,7 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
selected="selected" if name.value == request_type else None)
|
selected="selected" if name.value == request_type else None)
|
||||||
|
|
||||||
def _mk_input_group():
|
def _mk_input_group():
|
||||||
if request_type == JiraRequestTypes.Issues.value:
|
if request_type == JiraRequestTypes.Search.value:
|
||||||
return Div(
|
return Div(
|
||||||
Input(type="text",
|
Input(type="text",
|
||||||
name="request",
|
name="request",
|
||||||
@@ -488,7 +538,7 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _mk_extra_parameters():
|
def _mk_extra_parameters():
|
||||||
if request_type == JiraRequestTypes.Issues.value:
|
if request_type == JiraRequestTypes.Search.value:
|
||||||
return Input(type="text",
|
return Input(type="text",
|
||||||
name="fields",
|
name="fields",
|
||||||
value=component.properties.get("fields", DEFAULT_SEARCH_FIELDS),
|
value=component.properties.get("fields", DEFAULT_SEARCH_FIELDS),
|
||||||
@@ -497,7 +547,7 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
request_type = component.properties.get("request_type", JiraRequestTypes.Issues.value)
|
request_type = component.properties.get("request_type", JiraRequestTypes.Search.value)
|
||||||
return Div(
|
return Div(
|
||||||
Fieldset(
|
Fieldset(
|
||||||
Legend("JQL", cls="fieldset-legend"),
|
Legend("JQL", cls="fieldset-legend"),
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
from fasthtml.common import *
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from components.BaseComponent import BaseComponent
|
||||||
|
from components.workflows.constants import COMPONENT_TYPES, PROCESSOR_TYPES
|
||||||
|
from components_helpers import mk_dialog_buttons
|
||||||
|
from core.jira import JiraRequestTypes, DEFAULT_SEARCH_FIELDS
|
||||||
|
from utils.DbManagementHelper import DbManagementHelper
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DesignerLayout:
|
||||||
|
input_width: int
|
||||||
|
properties_width: int
|
||||||
|
output_width: int
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowDesignerProperties(BaseComponent):
|
||||||
|
def __init__(self, session, instance_id, owner):
|
||||||
|
super().__init__(session, instance_id)
|
||||||
|
self._owner = owner
|
||||||
|
self._boundaries = self._owner.get_boundaries()
|
||||||
|
self._commands = self._owner.commands
|
||||||
|
self.layout = None
|
||||||
|
self._component = None
|
||||||
|
self.update_layout()
|
||||||
|
self.update_component(self._owner.get_state().selected_component_id)
|
||||||
|
|
||||||
|
def update_layout(self):
|
||||||
|
if self._owner.get_state().properties_input_width is None:
|
||||||
|
input_width = self._boundaries["width"] // 3
|
||||||
|
properties_width = self._boundaries["width"] // 3
|
||||||
|
output_width = self._boundaries["width"] - input_width - properties_width
|
||||||
|
else:
|
||||||
|
input_width = self._owner.get_state().properties_input_width
|
||||||
|
properties_width = self._owner.get_state().properties_properties_width
|
||||||
|
output_width = self._owner.get_state().properties_output_width
|
||||||
|
|
||||||
|
self.layout = DesignerLayout(
|
||||||
|
input_width=input_width,
|
||||||
|
properties_width=properties_width,
|
||||||
|
output_width=output_width
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_component(self, component_id):
|
||||||
|
if component_id is None or component_id not in self._owner.get_state().components:
|
||||||
|
self._component = None
|
||||||
|
else:
|
||||||
|
self._component = self._owner.get_state().components[component_id]
|
||||||
|
|
||||||
|
def refresh(self, mode="all", oob=False):
|
||||||
|
self.update_component(self._owner.get_state().selected_component_id)
|
||||||
|
if mode == "form":
|
||||||
|
return self._mk_content(oob=oob)
|
||||||
|
|
||||||
|
return self.__ft__(oob=oob)
|
||||||
|
|
||||||
|
def _mk_layout(self):
|
||||||
|
return Div(
|
||||||
|
self._mk_input(),
|
||||||
|
self._mk_properties(),
|
||||||
|
self._mk_output(),
|
||||||
|
cls="flex",
|
||||||
|
style="height: 100%; width: 100%; flex: 1;"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_input(self):
|
||||||
|
return Div(
|
||||||
|
"Input",
|
||||||
|
id=f"pi_{self._id}",
|
||||||
|
style=f"width: {self.layout.input_width}px;",
|
||||||
|
cls="wkf-properties-input"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_output(self):
|
||||||
|
return Div(
|
||||||
|
"Output",
|
||||||
|
id=f"po_{self._id}",
|
||||||
|
style=f"width: {self.layout.output_width}px;",
|
||||||
|
cls="wkf-properties-output"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_properties(self):
|
||||||
|
return Div(
|
||||||
|
# Drag handle (20px height)
|
||||||
|
Div(
|
||||||
|
A(cls="wkf-properties-handle-top"),
|
||||||
|
cls="wkf-properties-top",
|
||||||
|
id=f"ppt_{self._id}",
|
||||||
|
),
|
||||||
|
|
||||||
|
# Properties content
|
||||||
|
self._mk_content(),
|
||||||
|
|
||||||
|
# Left resize handle
|
||||||
|
Div(
|
||||||
|
id=f"ppl_{self._id}",
|
||||||
|
cls="wkf-properties-handle-left"
|
||||||
|
),
|
||||||
|
|
||||||
|
# Right resize handle
|
||||||
|
Div(
|
||||||
|
id=f"ppr_{self._id}",
|
||||||
|
cls="wkf-properties-handle-right"
|
||||||
|
),
|
||||||
|
|
||||||
|
id=f"pp_{self._id}",
|
||||||
|
style=f"width: {self.layout.properties_width}px; height: 100%;",
|
||||||
|
cls="wkf-properties-properties flex flex-col",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_content(self, oob=False):
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
self._header(),
|
||||||
|
self._form(),
|
||||||
|
cls="wkf-properties-content",
|
||||||
|
id=f"ppc_{self._id}",
|
||||||
|
hx_swap_oob=f'true' if oob else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _header(self):
|
||||||
|
if self._component is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
icon = COMPONENT_TYPES[self._component.type]["icon"]
|
||||||
|
color = COMPONENT_TYPES[self._component.type]["color"]
|
||||||
|
return Div(
|
||||||
|
Div(
|
||||||
|
Span(icon),
|
||||||
|
H4(self._component.title, cls="font-semibold text-xs"),
|
||||||
|
cls=f"rounded-lg border-2 {color} flex text-center px-2"
|
||||||
|
),
|
||||||
|
Div(self._component.id, cls="ml-2"),
|
||||||
|
cls="flex wkf-properties-content-header",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _form(self):
|
||||||
|
if self._component is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
component_id = self._component.id
|
||||||
|
return Form(
|
||||||
|
Div(
|
||||||
|
self._mk_select_processor(),
|
||||||
|
self._content_details(),
|
||||||
|
style="flex-grow: 1; overflow-y: auto;"
|
||||||
|
),
|
||||||
|
mk_dialog_buttons(cls="pb-2",
|
||||||
|
on_ok=self._commands.save_properties(component_id),
|
||||||
|
on_cancel=self._commands.cancel_properties(component_id)
|
||||||
|
),
|
||||||
|
id=f"ppf_{self._id}",
|
||||||
|
cls="wkf-properties-content-form",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_select_processor(self):
|
||||||
|
selected_processor_name = self._component.properties.get("processor_name", None)
|
||||||
|
return Select(
|
||||||
|
*[Option(processor_name, selected="selected" if processor_name == selected_processor_name else None)
|
||||||
|
for processor_name in PROCESSOR_TYPES[self._component.type]],
|
||||||
|
cls="select select-sm m-2",
|
||||||
|
id="processor_name",
|
||||||
|
name="processor_name",
|
||||||
|
**self._commands.select_processor(self._component.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _content_details(self):
|
||||||
|
component_type = self._component.type
|
||||||
|
processor_name = self._component.properties.get("processor_name", None)
|
||||||
|
key = f"_mk_details_{component_type}_{processor_name}".lower()
|
||||||
|
if hasattr(self, key):
|
||||||
|
return getattr(self, key)()
|
||||||
|
else:
|
||||||
|
return Div(f"Component '{key}' not found")
|
||||||
|
|
||||||
|
def _mk_details_producer_jira(self):
|
||||||
|
def _mk_option(name):
|
||||||
|
"""
|
||||||
|
Generic helper to create options
|
||||||
|
:param name:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return Option(name.name,
|
||||||
|
value=name.value,
|
||||||
|
selected="selected" if name.value == request_type else None)
|
||||||
|
|
||||||
|
def _mk_input_group():
|
||||||
|
if request_type == JiraRequestTypes.Search.value or request_type == "issues": # remove issues at some point
|
||||||
|
return [
|
||||||
|
Div(
|
||||||
|
Input(type="text",
|
||||||
|
name=f"{request_type}_fields",
|
||||||
|
value=self._component.properties.get(f"{request_type}_fields", DEFAULT_SEARCH_FIELDS),
|
||||||
|
placeholder="default fields",
|
||||||
|
cls="input w-full"),
|
||||||
|
P("Jira fields to retrieve"),
|
||||||
|
),
|
||||||
|
Div(
|
||||||
|
Input(type="text",
|
||||||
|
name=f"{request_type}_request",
|
||||||
|
value=self._component.properties.get(f"{request_type}_request", ""),
|
||||||
|
placeholder="Enter JQL",
|
||||||
|
cls="input w-full"),
|
||||||
|
P("Write your jql code"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
elif request_type in (JiraRequestTypes.Issue.value, JiraRequestTypes.Comments.value):
|
||||||
|
return [
|
||||||
|
Div(
|
||||||
|
Input(type="text",
|
||||||
|
name=f"{request_type}_request",
|
||||||
|
value=self._component.properties.get(f"{request_type}_request", ""),
|
||||||
|
placeholder="Issue id",
|
||||||
|
cls="input w-full"),
|
||||||
|
P("Put the issue id here"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
elif request_type == JiraRequestTypes.Versions.value:
|
||||||
|
return [
|
||||||
|
Div(
|
||||||
|
Input(type="text",
|
||||||
|
name=f"{request_type}_request",
|
||||||
|
value=self._component.properties.get(f"{request_type}_request", ""),
|
||||||
|
placeholder="Project key",
|
||||||
|
cls="input w-full"),
|
||||||
|
P("Enter the project key"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
return [Div(f"** Not Implemented ** ('{request_type}' not supported yet)")]
|
||||||
|
|
||||||
|
request_type = self._component.properties.get("request_type", JiraRequestTypes.Search.value)
|
||||||
|
return Div(
|
||||||
|
Fieldset(
|
||||||
|
Legend("Jira", cls="fieldset-legend"),
|
||||||
|
Div(
|
||||||
|
Select(
|
||||||
|
*[_mk_option(enum) for enum in JiraRequestTypes],
|
||||||
|
cls="select w-xs",
|
||||||
|
name="request_type",
|
||||||
|
**self._commands.on_processor_details_event(self._component.id, "OnJiraRequestTypeChanged"),
|
||||||
|
),
|
||||||
|
P("Jira ressource type"),
|
||||||
|
cls="mb-4"
|
||||||
|
),
|
||||||
|
*_mk_input_group(),
|
||||||
|
cls="fieldset bg-base-200 border-base-300 rounded-box border p-4"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_details_producer_repository(self):
|
||||||
|
selected_repo = self._component.properties.get("repository", None)
|
||||||
|
selected_table = self._component.properties.get("table", None)
|
||||||
|
|
||||||
|
def _mk_repositories_options():
|
||||||
|
repositories = DbManagementHelper.list_repositories(self._session)
|
||||||
|
if len(repositories) == 0:
|
||||||
|
return [Option("No repository available", disabled=True)]
|
||||||
|
|
||||||
|
return ([Option("Choose a repository", disabled=True, selected="selected" if selected_repo is None else None)] +
|
||||||
|
[Option(repo.name, selected="selected" if repo.name == selected_repo else None)
|
||||||
|
for repo in DbManagementHelper.list_repositories(self._session)])
|
||||||
|
|
||||||
|
def _mk_tables_options():
|
||||||
|
if selected_repo is None:
|
||||||
|
return [Option("No repository selected", disabled=True, selected="selected")]
|
||||||
|
|
||||||
|
tables = DbManagementHelper.list_tables(self._session, selected_repo)
|
||||||
|
if len(tables) == 0:
|
||||||
|
return [Option("No table available", disabled=True)]
|
||||||
|
|
||||||
|
return ([Option("Choose a table", disabled=True, selected="selected" if selected_table is None else None)] +
|
||||||
|
[Option(table, selected="selected" if table == selected_table else None)
|
||||||
|
for table in DbManagementHelper.list_tables(self._session, selected_repo)])
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
Fieldset(
|
||||||
|
Legend("Repository", cls="fieldset-legend"),
|
||||||
|
Div(
|
||||||
|
Select(
|
||||||
|
*_mk_repositories_options(),
|
||||||
|
cls="select w-64",
|
||||||
|
id=f"repository_{self._id}",
|
||||||
|
name="repository",
|
||||||
|
**self._commands.on_processor_details_event(self._component.id, "OnRepositoryChanged"),
|
||||||
|
),
|
||||||
|
P("Select the repository"),
|
||||||
|
),
|
||||||
|
Div(
|
||||||
|
Select(
|
||||||
|
*_mk_tables_options(),
|
||||||
|
cls="select w-64",
|
||||||
|
id=f"table_{self._id}",
|
||||||
|
name="table",
|
||||||
|
),
|
||||||
|
P("Select the table"),
|
||||||
|
),
|
||||||
|
|
||||||
|
cls="fieldset bg-base-200 border-base-300 rounded-box border p-4"
|
||||||
|
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_details_filter_default(self):
|
||||||
|
return Div(
|
||||||
|
Fieldset(
|
||||||
|
Legend("Filter", cls="fieldset-legend"),
|
||||||
|
Input(type="text",
|
||||||
|
name="filter",
|
||||||
|
value=self._component.properties.get("filter", ""),
|
||||||
|
placeholder="Filter expression",
|
||||||
|
cls="input w-full"),
|
||||||
|
P("Filter expression"),
|
||||||
|
cls="fieldset bg-base-200 border-base-300 rounded-box border p-4"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_details_presenter_default(self):
|
||||||
|
return Div(
|
||||||
|
Fieldset(
|
||||||
|
Legend("Presenter", cls="fieldset-legend"),
|
||||||
|
Input(type="text",
|
||||||
|
name="columns",
|
||||||
|
value=self._component.properties.get("columns", ""),
|
||||||
|
placeholder="Columns to display, separated by comma",
|
||||||
|
cls="input w-full"),
|
||||||
|
P("Comma separated list of columns to display. Use '*' to display all columns, 'source=dest' to rename columns."),
|
||||||
|
P("Use 'parent.*=*' to display all columns from object 'parent' and rename them removing the 'parent' prefix."),
|
||||||
|
cls="fieldset bg-base-200 border-base-300 rounded-box border p-4"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __ft__(self, oob=False):
|
||||||
|
# return self.render()
|
||||||
|
return Div(
|
||||||
|
self._mk_layout(),
|
||||||
|
style=f"height: {self._get_height()}px;",
|
||||||
|
id=f"p_{self._id}",
|
||||||
|
hx_swap_oob=f'innerHTML' if oob else None,
|
||||||
|
cls="wkf-properties"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_height(self):
|
||||||
|
return self._boundaries["height"] - self._owner.get_state().designer_height - 86
|
||||||
@@ -191,13 +191,14 @@ class WorkflowPlayer(BaseComponent):
|
|||||||
component.properties["repository"],
|
component.properties["repository"],
|
||||||
component.properties["table"]))
|
component.properties["table"]))
|
||||||
elif key == (ProcessorTypes.Producer, "Jira"):
|
elif key == (ProcessorTypes.Producer, "Jira"):
|
||||||
|
request_type = component.properties["request_type"]
|
||||||
engine.add_processor(
|
engine.add_processor(
|
||||||
JiraDataProducer(self._session,
|
JiraDataProducer(self._session,
|
||||||
self._settings_manager,
|
self._settings_manager,
|
||||||
component.id,
|
component.id,
|
||||||
component.properties["request_type"],
|
component.properties["request_type"],
|
||||||
component.properties["request"],
|
component.properties[f"{request_type}_request"],
|
||||||
component.properties["fields"]))
|
component.properties.get(f"{request_type}_fields", None)))
|
||||||
elif key == (ProcessorTypes.Filter, "Default"):
|
elif key == (ProcessorTypes.Filter, "Default"):
|
||||||
engine.add_processor(DefaultDataFilter(component.id, component.properties["filter"]))
|
engine.add_processor(DefaultDataFilter(component.id, component.properties["filter"]))
|
||||||
elif key == (ProcessorTypes.Presenter, "Default"):
|
elif key == (ProcessorTypes.Presenter, "Default"):
|
||||||
|
|||||||
@@ -6,11 +6,39 @@ WORKFLOW_DESIGNER_DB_ENTRY = "WorkflowDesigner"
|
|||||||
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY = "Settings"
|
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY = "Settings"
|
||||||
WORKFLOW_DESIGNER_DB_STATE_ENTRY = "State"
|
WORKFLOW_DESIGNER_DB_STATE_ENTRY = "State"
|
||||||
|
|
||||||
|
|
||||||
class ProcessorTypes:
|
class ProcessorTypes:
|
||||||
Producer = "producer"
|
Producer = "producer"
|
||||||
Filter = "filter"
|
Filter = "filter"
|
||||||
Presenter = "presenter"
|
Presenter = "presenter"
|
||||||
|
|
||||||
|
|
||||||
|
COMPONENT_TYPES = {
|
||||||
|
ProcessorTypes.Producer: {
|
||||||
|
"title": "Data Producer",
|
||||||
|
"description": "Generates or loads data",
|
||||||
|
"icon": "📊",
|
||||||
|
"color": "bg-green-100 border-green-300 text-neutral"
|
||||||
|
},
|
||||||
|
ProcessorTypes.Filter: {
|
||||||
|
"title": "Data Filter",
|
||||||
|
"description": "Filters and transforms data",
|
||||||
|
"icon": "🔍",
|
||||||
|
"color": "bg-blue-100 border-blue-300 text-neutral"
|
||||||
|
},
|
||||||
|
ProcessorTypes.Presenter: {
|
||||||
|
"title": "Data Presenter",
|
||||||
|
"description": "Displays or exports data",
|
||||||
|
"icon": "📋",
|
||||||
|
"color": "bg-purple-100 border-purple-300 text-neutral"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PROCESSOR_TYPES = {
|
||||||
|
ProcessorTypes.Producer: ["Repository", "Jira"],
|
||||||
|
ProcessorTypes.Filter: ["Default"],
|
||||||
|
ProcessorTypes.Presenter: ["Default"]}
|
||||||
|
|
||||||
ROUTE_ROOT = "/workflows"
|
ROUTE_ROOT = "/workflows"
|
||||||
|
|
||||||
|
|
||||||
@@ -25,6 +53,7 @@ class Routes:
|
|||||||
AddConnection = "/add-connection"
|
AddConnection = "/add-connection"
|
||||||
DeleteConnection = "/delete-connection"
|
DeleteConnection = "/delete-connection"
|
||||||
ResizeDesigner = "/resize-designer"
|
ResizeDesigner = "/resize-designer"
|
||||||
|
UpdatePropertiesLayout = "/update-properties-layout"
|
||||||
SaveProperties = "/save-properties"
|
SaveProperties = "/save-properties"
|
||||||
CancelProperties = "/cancel-properties"
|
CancelProperties = "/cancel-properties"
|
||||||
SelectProcessor = "/select-processor"
|
SelectProcessor = "/select-processor"
|
||||||
@@ -32,4 +61,4 @@ class Routes:
|
|||||||
PlayWorkflow = "/play-workflow"
|
PlayWorkflow = "/play-workflow"
|
||||||
PauseWorkflow = "/pause-workflow"
|
PauseWorkflow = "/pause-workflow"
|
||||||
StopWorkflow = "/stop-workflow"
|
StopWorkflow = "/stop-workflow"
|
||||||
|
Refresh = "/refresh"
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import enum
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from components.undo_redo.constants import UndoRedoAttrs
|
||||||
from components.workflows.constants import WORKFLOWS_DB_ENTRY, WORKFLOW_DESIGNER_DB_ENTRY, \
|
from components.workflows.constants import WORKFLOWS_DB_ENTRY, WORKFLOW_DESIGNER_DB_ENTRY, \
|
||||||
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY, WORKFLOW_DESIGNER_DB_STATE_ENTRY
|
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY, WORKFLOW_DESIGNER_DB_STATE_ENTRY
|
||||||
from core.settings_management import SettingsManager
|
from core.settings_management import SettingsManager
|
||||||
from core.utils import make_safe_id
|
from core.utils import make_safe_id
|
||||||
|
from utils.ComponentsInstancesHelper import ComponentsInstancesHelper
|
||||||
|
|
||||||
logger = logging.getLogger("WorkflowsSettings")
|
logger = logging.getLogger("WorkflowsSettings")
|
||||||
|
|
||||||
@@ -57,9 +59,12 @@ class WorkflowsDesignerSettings:
|
|||||||
class WorkflowsDesignerState:
|
class WorkflowsDesignerState:
|
||||||
components: dict[str, WorkflowComponent] = field(default_factory=dict)
|
components: dict[str, WorkflowComponent] = field(default_factory=dict)
|
||||||
connections: list[Connection] = field(default_factory=list)
|
connections: list[Connection] = field(default_factory=list)
|
||||||
component_counter = 0
|
component_counter: int = 0
|
||||||
designer_height = 230
|
designer_height: int = 230
|
||||||
selected_component_id = None
|
properties_input_width: int = None
|
||||||
|
properties_properties_width : int = None
|
||||||
|
properties_output_width: int = None
|
||||||
|
selected_component_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -158,6 +163,7 @@ class WorkflowsDesignerDbManager:
|
|||||||
def __init__(self, session: dict, settings_manager: SettingsManager):
|
def __init__(self, session: dict, settings_manager: SettingsManager):
|
||||||
self._session = session
|
self._session = session
|
||||||
self._settings_manager = settings_manager
|
self._settings_manager = settings_manager
|
||||||
|
self._undo_redo = ComponentsInstancesHelper.get_undo_redo(session)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_db_entry(key):
|
def _get_db_entry(key):
|
||||||
@@ -169,12 +175,18 @@ class WorkflowsDesignerDbManager:
|
|||||||
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY,
|
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY,
|
||||||
settings)
|
settings)
|
||||||
|
|
||||||
def save_state(self, key: str, state: WorkflowsDesignerState):
|
def save_state(self, key: str, state: WorkflowsDesignerState, undo_redo_attrs: UndoRedoAttrs = None):
|
||||||
|
db_entry = self._get_db_entry(key)
|
||||||
self._settings_manager.put(self._session,
|
self._settings_manager.put(self._session,
|
||||||
self._get_db_entry(key),
|
db_entry,
|
||||||
WORKFLOW_DESIGNER_DB_STATE_ENTRY,
|
WORKFLOW_DESIGNER_DB_STATE_ENTRY,
|
||||||
state)
|
state)
|
||||||
|
|
||||||
|
if undo_redo_attrs is not None:
|
||||||
|
self._undo_redo.snapshot(undo_redo_attrs,
|
||||||
|
db_entry,
|
||||||
|
WORKFLOW_DESIGNER_DB_STATE_ENTRY)
|
||||||
|
|
||||||
def save_all(self, key: str, settings: WorkflowsDesignerSettings = None, state: WorkflowsDesignerState = None):
|
def save_all(self, key: str, settings: WorkflowsDesignerSettings = None, state: WorkflowsDesignerState = None):
|
||||||
items = {}
|
items = {}
|
||||||
if settings is not None:
|
if settings is not None:
|
||||||
|
|||||||
@@ -271,6 +271,42 @@ class DbEngine:
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
raise DbException(f"Key '{key}' not found in entry '{entry}'")
|
raise DbException(f"Key '{key}' not found in entry '{entry}'")
|
||||||
|
|
||||||
|
def history(self, user_id, entry, digest=None, max_items=1000):
|
||||||
|
"""
|
||||||
|
Gives the current digest and all its ancestors
|
||||||
|
:param user_id:
|
||||||
|
:param entry:
|
||||||
|
:param digest:
|
||||||
|
:param max_items:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
with self.lock:
|
||||||
|
logger.info(f"History for {user_id=}, {entry=}, {digest=}")
|
||||||
|
|
||||||
|
digest_to_use = digest or self._get_entry_digest(user_id, entry)
|
||||||
|
logger.debug(f"Using digest {digest_to_use}.")
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
history = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if count >= max_items or digest_to_use is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
history.append(digest_to_use)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_file = self._get_obj_path(user_id, digest_to_use)
|
||||||
|
with open(target_file, 'r', encoding='utf-8') as file:
|
||||||
|
as_dict = json.load(file)
|
||||||
|
|
||||||
|
digest_to_use = as_dict[TAG_PARENT][0]
|
||||||
|
except FileNotFoundError:
|
||||||
|
break
|
||||||
|
|
||||||
|
return history
|
||||||
|
|
||||||
def debug_root(self):
|
def debug_root(self):
|
||||||
"""
|
"""
|
||||||
Lists all folders in the root directory
|
Lists all folders in the root directory
|
||||||
@@ -312,7 +348,7 @@ class DbEngine:
|
|||||||
return []
|
return []
|
||||||
return [f for f in os.listdir(self.root) if os.path.isdir(os.path.join(self.root, f)) and f != 'refs']
|
return [f for f in os.listdir(self.root) if os.path.isdir(os.path.join(self.root, f)) and f != 'refs']
|
||||||
|
|
||||||
def debug_get_digest(self, user_id, entry):
|
def get_digest(self, user_id, entry):
|
||||||
return self._get_entry_digest(user_id, entry)
|
return self._get_entry_digest(user_id, entry)
|
||||||
|
|
||||||
def _serialize(self, obj):
|
def _serialize(self, obj):
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from core.Expando import Expando
|
|||||||
JIRA_ROOT = "https://altares.atlassian.net/rest/api/3"
|
JIRA_ROOT = "https://altares.atlassian.net/rest/api/3"
|
||||||
DEFAULT_HEADERS = {"Accept": "application/json"}
|
DEFAULT_HEADERS = {"Accept": "application/json"}
|
||||||
DEFAULT_SEARCH_FIELDS = "summary,status,assignee"
|
DEFAULT_SEARCH_FIELDS = "summary,status,assignee"
|
||||||
logger = logging.getLogger("jql")
|
logger = logging.getLogger("Jira")
|
||||||
|
|
||||||
|
|
||||||
class NotFound(Exception):
|
class NotFound(Exception):
|
||||||
@@ -18,8 +18,10 @@ class NotFound(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class JiraRequestTypes(Enum):
|
class JiraRequestTypes(Enum):
|
||||||
Issues = "issues"
|
Search = "search"
|
||||||
|
Issue = "issue"
|
||||||
Comments = "comments"
|
Comments = "comments"
|
||||||
|
Versions = "versions"
|
||||||
|
|
||||||
|
|
||||||
class Jira:
|
class Jira:
|
||||||
@@ -41,7 +43,10 @@ class Jira:
|
|||||||
self.fields = fields
|
self.fields = fields
|
||||||
|
|
||||||
def test(self):
|
def test(self):
|
||||||
|
logger.debug(f"test with no parameters")
|
||||||
|
|
||||||
url = f"{JIRA_ROOT}/myself"
|
url = f"{JIRA_ROOT}/myself"
|
||||||
|
logger.debug(f" url: {url}")
|
||||||
|
|
||||||
response = requests.request(
|
response = requests.request(
|
||||||
"GET",
|
"GET",
|
||||||
@@ -49,16 +54,21 @@ class Jira:
|
|||||||
headers=DEFAULT_HEADERS,
|
headers=DEFAULT_HEADERS,
|
||||||
auth=self.auth
|
auth=self.auth
|
||||||
)
|
)
|
||||||
|
logger.debug(f" response: {response}")
|
||||||
|
logger.debug(f" response.text: {response.text}")
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def issue(self, issue_id: str) -> Expando:
|
def issue(self, issue_id: str) -> list[Expando]:
|
||||||
"""
|
"""
|
||||||
Retrieve an issue
|
Retrieve an issue
|
||||||
:param issue_id:
|
:param issue_id:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
logger.debug(f"comments with {issue_id=}")
|
||||||
|
|
||||||
url = f"{JIRA_ROOT}/issue/{issue_id}"
|
url = f"{JIRA_ROOT}/issue/{issue_id}"
|
||||||
|
logger.debug(f" url: {url}")
|
||||||
|
|
||||||
response = requests.request(
|
response = requests.request(
|
||||||
"GET",
|
"GET",
|
||||||
@@ -66,8 +76,10 @@ class Jira:
|
|||||||
headers=DEFAULT_HEADERS,
|
headers=DEFAULT_HEADERS,
|
||||||
auth=self.auth
|
auth=self.auth
|
||||||
)
|
)
|
||||||
|
logger.debug(f" response: {response}")
|
||||||
|
logger.debug(f" response.text: {response.text}")
|
||||||
|
|
||||||
return Expando(json.loads(response.text))
|
return [Expando(json.loads(response.text))]
|
||||||
|
|
||||||
def fields(self) -> list[Expando]:
|
def fields(self) -> list[Expando]:
|
||||||
"""
|
"""
|
||||||
@@ -86,14 +98,14 @@ class Jira:
|
|||||||
as_dict = json.loads(response.text)
|
as_dict = json.loads(response.text)
|
||||||
return [Expando(field) for field in as_dict]
|
return [Expando(field) for field in as_dict]
|
||||||
|
|
||||||
def issues(self, jql: str, fields=None) -> list[Expando]:
|
def search(self, jql: str, fields=None) -> list[Expando]:
|
||||||
"""
|
"""
|
||||||
Executes a JQL and returns the list of issues
|
Executes a JQL and returns the list of issues
|
||||||
:param jql:
|
:param jql:
|
||||||
:param fields: list of fields to retrieve
|
:param fields: list of fields to retrieve
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
logger.debug(f"Processing jql '{jql}'")
|
logger.debug(f"search with {jql=}, {fields=}")
|
||||||
|
|
||||||
if not jql:
|
if not jql:
|
||||||
raise ValueError("Jql cannot be empty.")
|
raise ValueError("Jql cannot be empty.")
|
||||||
@@ -102,6 +114,7 @@ class Jira:
|
|||||||
fields = self.fields
|
fields = self.fields
|
||||||
|
|
||||||
url = f"{JIRA_ROOT}/search"
|
url = f"{JIRA_ROOT}/search"
|
||||||
|
logger.debug(f" url: {url}")
|
||||||
|
|
||||||
headers = DEFAULT_HEADERS.copy()
|
headers = DEFAULT_HEADERS.copy()
|
||||||
headers["Content-Type"] = "application/json"
|
headers["Content-Type"] = "application/json"
|
||||||
@@ -113,15 +126,19 @@ class Jira:
|
|||||||
"maxResults": 500, # Does not seem to be used. It's always 100 !
|
"maxResults": 500, # Does not seem to be used. It's always 100 !
|
||||||
"startAt": 0
|
"startAt": 0
|
||||||
}
|
}
|
||||||
|
logger.debug(f" payload: {payload}")
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
while True:
|
while True:
|
||||||
logger.debug(f"Request startAt '{payload['startAt']}'")
|
logger.debug(f" Request startAt '{payload['startAt']}'")
|
||||||
response = requests.request("POST",
|
response = requests.request("POST",
|
||||||
url,
|
url,
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
auth=self.auth)
|
auth=self.auth)
|
||||||
|
logger.debug(f" response: {response}")
|
||||||
|
logger.debug(f" response.text: {response.text}")
|
||||||
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise Exception(self._format_error(response))
|
raise Exception(self._format_error(response))
|
||||||
@@ -130,6 +147,7 @@ class Jira:
|
|||||||
result += as_dict["issues"]
|
result += as_dict["issues"]
|
||||||
|
|
||||||
if as_dict["startAt"] + as_dict["maxResults"] >= as_dict["total"]:
|
if as_dict["startAt"] + as_dict["maxResults"] >= as_dict["total"]:
|
||||||
|
logger.debug(f" response: {response}")
|
||||||
# We retrieve more than the total nuber of items
|
# We retrieve more than the total nuber of items
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -143,12 +161,18 @@ class Jira:
|
|||||||
:param issue_id:
|
:param issue_id:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
logger.debug(f"comments with {issue_id=}")
|
||||||
|
|
||||||
url = f"{JIRA_ROOT}/issue/{issue_id}/comment"
|
url = f"{JIRA_ROOT}/issue/{issue_id}/comment"
|
||||||
|
logger.debug(f" url: {url}")
|
||||||
|
|
||||||
response = requests.request("GET",
|
response = requests.request("GET",
|
||||||
url,
|
url,
|
||||||
headers=DEFAULT_HEADERS,
|
headers=DEFAULT_HEADERS,
|
||||||
auth=self.auth)
|
auth=self.auth)
|
||||||
|
logger.debug(f" response: {response}")
|
||||||
|
logger.debug(f" response.text: {response.text}")
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise Exception(self._format_error(response))
|
raise Exception(self._format_error(response))
|
||||||
|
|
||||||
@@ -156,6 +180,34 @@ class Jira:
|
|||||||
result = as_dict["comments"]
|
result = as_dict["comments"]
|
||||||
return [Expando(issue) for issue in result]
|
return [Expando(issue) for issue in result]
|
||||||
|
|
||||||
|
def versions(self, project_key):
|
||||||
|
"""
|
||||||
|
Given a project name and a version name
|
||||||
|
returns fixVersion number in JIRA
|
||||||
|
:param project_key:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
logger.debug(f"versions with {project_key=}")
|
||||||
|
|
||||||
|
url = f"{JIRA_ROOT}/project/{project_key}/versions"
|
||||||
|
logger.debug(f" url: {url}")
|
||||||
|
|
||||||
|
response = requests.request(
|
||||||
|
"GET",
|
||||||
|
url,
|
||||||
|
headers=DEFAULT_HEADERS,
|
||||||
|
auth=self.auth
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f" response: {response}")
|
||||||
|
logger.debug(f" response.text: {response.text}")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise NotFound()
|
||||||
|
|
||||||
|
as_list = json.loads(response.text)
|
||||||
|
return [Expando(version) for version in as_list]
|
||||||
|
|
||||||
def extract(self, jql, mappings, updates=None) -> list[dict]:
|
def extract(self, jql, mappings, updates=None) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Executes a jql and returns list of dict
|
Executes a jql and returns list of dict
|
||||||
@@ -188,30 +240,6 @@ class Jira:
|
|||||||
row = {cvs_col: issue.get(jira_path) for jira_path, cvs_col in mappings.items() if cvs_col is not None}
|
row = {cvs_col: issue.get(jira_path) for jira_path, cvs_col in mappings.items() if cvs_col is not None}
|
||||||
yield row
|
yield row
|
||||||
|
|
||||||
def get_versions(self, project_key):
|
|
||||||
"""
|
|
||||||
Given a project name and a version name
|
|
||||||
returns fixVersion number in JIRA
|
|
||||||
:param project_key:
|
|
||||||
:param version_name:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = f"{JIRA_ROOT}/project/{project_key}/versions"
|
|
||||||
|
|
||||||
response = requests.request(
|
|
||||||
"GET",
|
|
||||||
url,
|
|
||||||
headers=DEFAULT_HEADERS,
|
|
||||||
auth=self.auth
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise NotFound()
|
|
||||||
|
|
||||||
as_list = json.loads(response.text)
|
|
||||||
return [Expando(version) for version in as_list]
|
|
||||||
|
|
||||||
def get_version(self, project_key, version_name):
|
def get_version(self, project_key, version_name):
|
||||||
"""
|
"""
|
||||||
Given a project name and a version name
|
Given a project name and a version name
|
||||||
@@ -221,7 +249,7 @@ class Jira:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for version in self.get_versions(project_key):
|
for version in self.versions(project_key):
|
||||||
if version.name == version_name:
|
if version.name == version_name:
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|||||||
189
src/core/preprocessor.py
Normal file
189
src/core/preprocessor.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
from arpeggio import RegExMatch, ZeroOrMore, OneOrMore, ParserPython, EOF, NoMatch
|
||||||
|
|
||||||
|
|
||||||
|
class VariableParsingError(Exception):
|
||||||
|
"""Custom exception for variable parsing errors"""
|
||||||
|
|
||||||
|
def __init__(self, message, position):
|
||||||
|
self.message = message
|
||||||
|
self.position = position
|
||||||
|
super().__init__(f"Variable parsing error at position {position}: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
class VariableProcessingError(Exception):
|
||||||
|
"""Custom exception for variable parsing errors"""
|
||||||
|
|
||||||
|
def __init__(self, message, position):
|
||||||
|
self.message = message
|
||||||
|
self.position = position
|
||||||
|
super().__init__(f"Variable processing error at position {position}: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def variable_name():
|
||||||
|
"""Variable name: alphanumeric characters and underscores"""
|
||||||
|
return RegExMatch(r'[a-zA-Z_][a-zA-Z0-9_]*')
|
||||||
|
|
||||||
|
|
||||||
|
def property_name():
|
||||||
|
"""Property name: same rules as variable name"""
|
||||||
|
return RegExMatch(r'[a-zA-Z_][a-zA-Z0-9_]*')
|
||||||
|
|
||||||
|
|
||||||
|
def variable_property():
|
||||||
|
"""A property access: .property_name"""
|
||||||
|
return ".", property_name
|
||||||
|
|
||||||
|
|
||||||
|
def variable():
|
||||||
|
"""A complete variable: $variable_name(.property)*"""
|
||||||
|
return "$", variable_name, ZeroOrMore(variable_property)
|
||||||
|
|
||||||
|
|
||||||
|
def text_char():
|
||||||
|
"""Any character that is not the start of a variable"""
|
||||||
|
return RegExMatch(r'[^$]')
|
||||||
|
|
||||||
|
|
||||||
|
def text_segment():
|
||||||
|
"""One or more non-variable characters"""
|
||||||
|
return OneOrMore(text_char)
|
||||||
|
|
||||||
|
|
||||||
|
def element():
|
||||||
|
"""Either a variable or a text segment"""
|
||||||
|
return [variable, text_segment]
|
||||||
|
|
||||||
|
|
||||||
|
def expression():
|
||||||
|
"""Complete expression: sequence of elements"""
|
||||||
|
return ZeroOrMore(element), EOF
|
||||||
|
|
||||||
|
|
||||||
|
class PlainTextPreprocessor:
|
||||||
|
def __init__(self):
|
||||||
|
self.parser = ParserPython(expression, debug=False, skipws=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _post_validation(elements):
|
||||||
|
if len(elements) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
for element, next_element in [(element, elements[i + 1]) for i, element in enumerate(elements[:-1])]:
|
||||||
|
if element['type'] == 'variable' and next_element['type'] == 'variable':
|
||||||
|
raise VariableParsingError("Invalid syntax.", next_element['start'])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_elements_from_tree(parse_tree, original_text):
|
||||||
|
"""Extract elements with positions from the parse tree"""
|
||||||
|
elements = []
|
||||||
|
|
||||||
|
def process_node(node, current_pos=0):
|
||||||
|
nonlocal elements
|
||||||
|
|
||||||
|
if hasattr(node, 'rule_name'):
|
||||||
|
if node.rule_name == 'variable':
|
||||||
|
# Extract variable information
|
||||||
|
var_start = node.position
|
||||||
|
var_end = node.position_end
|
||||||
|
var_text = original_text[var_start:var_end]
|
||||||
|
|
||||||
|
parts = var_text[1:].split('.') # Remove $ and split by .
|
||||||
|
var_name = parts[0]
|
||||||
|
properties = parts[1:] if len(parts) > 1 else []
|
||||||
|
|
||||||
|
elements.append({
|
||||||
|
"type": "variable",
|
||||||
|
"name": var_name,
|
||||||
|
"properties": properties,
|
||||||
|
"start": var_start,
|
||||||
|
"end": var_end
|
||||||
|
})
|
||||||
|
|
||||||
|
elif node.rule_name == 'text_segment':
|
||||||
|
# Extract text segment
|
||||||
|
text_start = node.position
|
||||||
|
text_end = node.position_end
|
||||||
|
content = original_text[text_start:text_end]
|
||||||
|
|
||||||
|
stripped = content.strip()
|
||||||
|
if len(stripped) > 0 and stripped[0] == '.':
|
||||||
|
raise VariableParsingError("Invalid syntax in property name.", text_start)
|
||||||
|
|
||||||
|
elements.append({
|
||||||
|
"type": "text",
|
||||||
|
"content": content,
|
||||||
|
"start": text_start,
|
||||||
|
"end": text_end
|
||||||
|
})
|
||||||
|
|
||||||
|
elif node.rule_name in ('expression', 'element'):
|
||||||
|
for child in node:
|
||||||
|
process_node(child, current_pos)
|
||||||
|
|
||||||
|
# Process children
|
||||||
|
if hasattr(node, '_tx_children') and node._tx_children:
|
||||||
|
for child in node._tx_children:
|
||||||
|
process_node(child, current_pos)
|
||||||
|
|
||||||
|
process_node(parse_tree)
|
||||||
|
return elements
|
||||||
|
|
||||||
|
def parse(self, text):
|
||||||
|
"""
|
||||||
|
Parse text and return structure with text segments and variables with positions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[
|
||||||
|
{"type": "text", "content": "...", "start": int, "end": int},
|
||||||
|
{"type": "variable", "name": "...", "properties": [...], "start": int, "end": int}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse the text
|
||||||
|
parse_tree = self.parser.parse(text)
|
||||||
|
|
||||||
|
# Extract elements from parse tree
|
||||||
|
elements = self._extract_elements_from_tree(parse_tree, text)
|
||||||
|
|
||||||
|
# Extra validations
|
||||||
|
self._post_validation(elements)
|
||||||
|
|
||||||
|
# Sort elements by start position
|
||||||
|
elements.sort(key=lambda x: x['start'])
|
||||||
|
|
||||||
|
return elements
|
||||||
|
|
||||||
|
except NoMatch as e:
|
||||||
|
# Convert Arpeggio parsing errors to our custom error
|
||||||
|
raise VariableParsingError(f"Invalid syntax", e.position)
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, VariableParsingError):
|
||||||
|
raise
|
||||||
|
raise VariableParsingError(f"Parsing failed: {str(e)}", 0)
|
||||||
|
|
||||||
|
def preprocess(self, text, namepace):
|
||||||
|
result = ""
|
||||||
|
elements = self.parse(text)
|
||||||
|
for element in elements:
|
||||||
|
if element['type'] == 'text':
|
||||||
|
result += element['content']
|
||||||
|
elif element['type'] == 'variable':
|
||||||
|
value = namepace.get(element['name'])
|
||||||
|
if value is None:
|
||||||
|
raise VariableProcessingError(f"Variable '{element['name']}' is not defined.", element['start'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
pos = element['start'] + len(element['name']) + 1 # +1 for the starting '$'
|
||||||
|
for property_name in element['properties']:
|
||||||
|
value = getattr(value, property_name)
|
||||||
|
pos += len(property_name) + 1 # +1 for the dot '.'
|
||||||
|
except AttributeError as e:
|
||||||
|
raise VariableProcessingError(f"Invalid property '{property_name}' for variable '{element['name']}'.",
|
||||||
|
pos) from e
|
||||||
|
|
||||||
|
result += str(value)
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -98,10 +98,10 @@ class SettingsManager:
|
|||||||
user_id, user_email = self._get_user(session)
|
user_id, user_email = self._get_user(session)
|
||||||
return self._db_engine.save(user_id, user_email, entry, obj)
|
return self._db_engine.save(user_id, user_email, entry, obj)
|
||||||
|
|
||||||
def load(self, session: dict, entry: str, default=NoDefault):
|
def load(self, session: dict, entry: str, digest=None, default=NoDefault):
|
||||||
user_id, _ = self._get_user(session)
|
user_id, _ = self._get_user(session)
|
||||||
try:
|
try:
|
||||||
return self._db_engine.load(user_id, entry)
|
return self._db_engine.load(user_id, entry, digest)
|
||||||
except DbException:
|
except DbException:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
@@ -128,6 +128,14 @@ class SettingsManager:
|
|||||||
|
|
||||||
return self._db_engine.exists(user_id, entry)
|
return self._db_engine.exists(user_id, entry)
|
||||||
|
|
||||||
|
def get_digest(self, session: dict, entry: str):
|
||||||
|
user_id, _ = self._get_user(session)
|
||||||
|
return self._db_engine.get_digest(user_id, entry)
|
||||||
|
|
||||||
|
def history(self, session, entry, digest=None, max_items=1000):
|
||||||
|
user_id, _ = self._get_user(session)
|
||||||
|
return self._db_engine.history(user_id, entry, digest, max_items)
|
||||||
|
|
||||||
def get_db_engine(self):
|
def get_db_engine(self):
|
||||||
return self._db_engine
|
return self._db_engine
|
||||||
|
|
||||||
@@ -177,7 +185,7 @@ class GenericDbManager:
|
|||||||
if key.startswith("_"):
|
if key.startswith("_"):
|
||||||
super().__setattr__(key, value)
|
super().__setattr__(key, value)
|
||||||
|
|
||||||
settings = self._settings_manager.load(self._session, self._obj_entry, self._obj_type())
|
settings = self._settings_manager.load(self._session, self._obj_entry, default=self._obj_type())
|
||||||
if not (hasattr(settings, key)):
|
if not (hasattr(settings, key)):
|
||||||
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{key}'.")
|
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{key}'.")
|
||||||
|
|
||||||
@@ -188,7 +196,7 @@ class GenericDbManager:
|
|||||||
if item.startswith("_"):
|
if item.startswith("_"):
|
||||||
return super().__getattribute__(item)
|
return super().__getattribute__(item)
|
||||||
|
|
||||||
settings = self._settings_manager.load(self._session, self._obj_entry, self._obj_type())
|
settings = self._settings_manager.load(self._session, self._obj_entry, default=self._obj_type())
|
||||||
if not (hasattr(settings, item)):
|
if not (hasattr(settings, item)):
|
||||||
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{item}'.")
|
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{item}'.")
|
||||||
|
|
||||||
@@ -250,7 +258,7 @@ class NestedSettingsManager:
|
|||||||
self._settings_manager.save(self._session, self._obj_entry, settings)
|
self._settings_manager.save(self._session, self._obj_entry, settings)
|
||||||
|
|
||||||
def _get_settings_and_object(self):
|
def _get_settings_and_object(self):
|
||||||
settings = self._settings_manager.load(self._session, self._obj_entry, self._obj_type())
|
settings = self._settings_manager.load(self._session, self._obj_entry, default=self._obj_type())
|
||||||
if not hasattr(settings, self._obj_attribute):
|
if not hasattr(settings, self._obj_attribute):
|
||||||
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{self._obj_attribute}'.")
|
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{self._obj_attribute}'.")
|
||||||
|
|
||||||
|
|||||||
@@ -48,3 +48,8 @@ loggers:
|
|||||||
level: INFO
|
level: INFO
|
||||||
handlers: [ console ]
|
handlers: [ console ]
|
||||||
propagate: False
|
propagate: False
|
||||||
|
|
||||||
|
Jira:
|
||||||
|
level: DEBUG
|
||||||
|
handlers: [ console ]
|
||||||
|
propagate: False
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import ast
|
import ast
|
||||||
|
import logging
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Generator
|
from typing import Any, Generator
|
||||||
|
|
||||||
from components.admin.admin_db_manager import AdminDbManager
|
from components.admin.admin_db_manager import AdminDbManager
|
||||||
from core.Expando import Expando
|
from core.Expando import Expando
|
||||||
from core.jira import Jira
|
from core.jira import Jira, JiraRequestTypes
|
||||||
|
from core.preprocessor import PlainTextPreprocessor
|
||||||
from core.utils import UnreferencedNamesVisitor
|
from core.utils import UnreferencedNamesVisitor
|
||||||
from utils.Datahelper import DataHelper
|
from utils.Datahelper import DataHelper
|
||||||
|
|
||||||
@@ -92,21 +94,32 @@ class TableDataProducer(DataProducer):
|
|||||||
class JiraDataProducer(DataProducer):
|
class JiraDataProducer(DataProducer):
|
||||||
"""Base class for data producers that emit data from Jira."""
|
"""Base class for data producers that emit data from Jira."""
|
||||||
|
|
||||||
def __init__(self, session, settings_manager, component_id, request_type='issues', request='', fields=None):
|
logger = logging.getLogger("DataProcessor.Producer.Jira")
|
||||||
|
|
||||||
|
def __init__(self, session, settings_manager, component_id, request_type='search', request='', fields=None):
|
||||||
super().__init__(component_id)
|
super().__init__(component_id)
|
||||||
self._session = session
|
self._session = session
|
||||||
self.settings_manager = settings_manager
|
self.settings_manager = settings_manager
|
||||||
self.request_type = request_type
|
self.request_type = request_type.value if isinstance(request_type, JiraRequestTypes) else request_type
|
||||||
self.request = request
|
self.request = request
|
||||||
self.fields = fields
|
self.fields = fields
|
||||||
self.db = AdminDbManager(session, settings_manager).jira
|
self.db = AdminDbManager(session, settings_manager).jira
|
||||||
|
|
||||||
def emit(self, data: Any = None) -> Generator[Any, None, None]:
|
def emit(self, data: Any = None) -> Generator[Any, None, None]:
|
||||||
jira = Jira(self.db.user_name, self.db.api_token, fields=self.fields)
|
self.logger.debug(f"Emitting data from Jira: {self.request_type} {self.request} {self.fields}")
|
||||||
|
|
||||||
|
preprocessor = PlainTextPreprocessor()
|
||||||
|
preprocessed_fields = preprocessor.preprocess(self.fields, {"data": data})
|
||||||
|
self.logger.debug(f" {preprocessed_fields=}")
|
||||||
|
|
||||||
|
jira = Jira(self.db.user_name, self.db.api_token, fields=preprocessed_fields)
|
||||||
if not hasattr(jira, self.request_type):
|
if not hasattr(jira, self.request_type):
|
||||||
raise ValueError(f"Invalid request type: {self.request_type}")
|
raise ValueError(f"Invalid request type: {self.request_type}")
|
||||||
|
|
||||||
yield from getattr(jira, self.request_type)(self.request)
|
preprocessed_request = preprocessor.preprocess(self.request, {"data": data})
|
||||||
|
self.logger.debug(f" {preprocessed_request=}")
|
||||||
|
|
||||||
|
yield from getattr(jira, self.request_type)(preprocessed_request)
|
||||||
|
|
||||||
|
|
||||||
class DefaultDataFilter(DataFilter):
|
class DefaultDataFilter(DataFilter):
|
||||||
|
|||||||
@@ -235,3 +235,40 @@ def test_put_many_save_only_if_necessary(engine):
|
|||||||
|
|
||||||
entry_content = engine.load(FAKE_USER_ID, "MyEntry")
|
entry_content = engine.load(FAKE_USER_ID, "MyEntry")
|
||||||
assert entry_content[TAG_PARENT] == [None] # Still None, nothing was save
|
assert entry_content[TAG_PARENT] == [None] # Still None, nothing was save
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_retrieve_history_using_put(engine):
|
||||||
|
engine.put(FAKE_USER_ID, FAKE_USER_EMAIL, "MyEntry", "key1", DummyObj(1, "a", False))
|
||||||
|
engine.put(FAKE_USER_ID, FAKE_USER_EMAIL, "MyEntry", "key1", DummyObj(2, "a", False))
|
||||||
|
engine.put(FAKE_USER_ID, FAKE_USER_EMAIL, "MyEntry", "key1", DummyObj(3, "a", False))
|
||||||
|
|
||||||
|
history = engine.history(FAKE_USER_ID, "MyEntry")
|
||||||
|
assert len(history) == 3
|
||||||
|
|
||||||
|
v0 = engine.load(FAKE_USER_ID, "MyEntry", history[0])
|
||||||
|
v1 = engine.load(FAKE_USER_ID, "MyEntry", history[1])
|
||||||
|
v2 = engine.load(FAKE_USER_ID, "MyEntry", history[2])
|
||||||
|
|
||||||
|
assert v0["key1"] == DummyObj(3, "a", False)
|
||||||
|
assert v1["key1"] == DummyObj(2, "a", False)
|
||||||
|
assert v2["key1"] == DummyObj(1, "a", False)
|
||||||
|
|
||||||
|
assert v2[TAG_PARENT] == [None]
|
||||||
|
|
||||||
|
def test_i_can_retrieve_history_using_save(engine):
|
||||||
|
engine.save(FAKE_USER_ID, FAKE_USER_EMAIL, "MyEntry", {"key1" : DummyObj(1, "a", False)})
|
||||||
|
engine.save(FAKE_USER_ID, FAKE_USER_EMAIL, "MyEntry", {"key1" : DummyObj(2, "a", False)})
|
||||||
|
engine.save(FAKE_USER_ID, FAKE_USER_EMAIL, "MyEntry", {"key1" : DummyObj(3, "a", False)})
|
||||||
|
|
||||||
|
history = engine.history(FAKE_USER_ID, "MyEntry")
|
||||||
|
assert len(history) == 3
|
||||||
|
|
||||||
|
v0 = engine.load(FAKE_USER_ID, "MyEntry", history[0])
|
||||||
|
v1 = engine.load(FAKE_USER_ID, "MyEntry", history[1])
|
||||||
|
v2 = engine.load(FAKE_USER_ID, "MyEntry", history[2])
|
||||||
|
|
||||||
|
assert v0["key1"] == DummyObj(3, "a", False)
|
||||||
|
assert v1["key1"] == DummyObj(2, "a", False)
|
||||||
|
assert v2["key1"] == DummyObj(1, "a", False)
|
||||||
|
|
||||||
|
assert v2[TAG_PARENT] == [None]
|
||||||
491
tests/test_preprocessor.py
Normal file
491
tests/test_preprocessor.py
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.preprocessor import PlainTextPreprocessor, VariableParsingError, VariableProcessingError
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_parse_empty_text():
|
||||||
|
"""Test that I can parse empty text input"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
result = processor.parse("")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_parse_text_without_variables():
|
||||||
|
"""Test that I can parse text without any variables"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "This is just plain text with no variables"
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
expected = [{
|
||||||
|
"type": "text",
|
||||||
|
"content": text,
|
||||||
|
"start": 0,
|
||||||
|
"end": len(text)
|
||||||
|
}]
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_parse_simple_variable():
|
||||||
|
"""Test that I can parse text with only a simple variable"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "$variable"
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
expected = [{
|
||||||
|
"type": "variable",
|
||||||
|
"name": "variable",
|
||||||
|
"properties": [],
|
||||||
|
"start": 0,
|
||||||
|
"end": 9
|
||||||
|
}]
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_parse_variable_with_underscores():
|
||||||
|
"""Test that I can parse variable with underscores in name"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "$my_variable_name"
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
expected = [{
|
||||||
|
"type": "variable",
|
||||||
|
"name": "my_variable_name",
|
||||||
|
"properties": [],
|
||||||
|
"start": 0,
|
||||||
|
"end": 17
|
||||||
|
}]
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_parse_variable_with_numbers():
|
||||||
|
"""Test that I can parse variable with numbers in name"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "$var123"
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
expected = [{
|
||||||
|
"type": "variable",
|
||||||
|
"name": "var123",
|
||||||
|
"properties": [],
|
||||||
|
"start": 0,
|
||||||
|
"end": 7
|
||||||
|
}]
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_parse_properties_with_underscores_and_numbers():
|
||||||
|
"""Test that I can parse property names with underscores and numbers"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "$var._prop123.sub_prop_456"
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
expected = [{
|
||||||
|
"type": "variable",
|
||||||
|
"name": "var",
|
||||||
|
"properties": ["_prop123", "sub_prop_456"],
|
||||||
|
"start": 0,
|
||||||
|
"end": 26
|
||||||
|
}]
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_parse_variable_starting_with_underscore():
|
||||||
|
"""Test that I can parse variable name starting with underscore"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "$_private_var"
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
{
|
||||||
|
"type": "variable",
|
||||||
|
"name": "_private_var",
|
||||||
|
"properties": [],
|
||||||
|
"start": 0,
|
||||||
|
"end": 13
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_parse_variable_with_single_property():
|
||||||
|
"""Test that I can parse variable with one property"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "$variable.prop"
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
expected = [{
|
||||||
|
"type": "variable",
|
||||||
|
"name": "variable",
|
||||||
|
"properties": ["prop"],
|
||||||
|
"start": 0,
|
||||||
|
"end": 14
|
||||||
|
}]
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_parse_variable_with_multiple_properties():
|
||||||
|
"""Test that I can parse variable with multiple properties"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "$variable.prop.subprop.deep"
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
expected = [{
|
||||||
|
"type": "variable",
|
||||||
|
"name": "variable",
|
||||||
|
"properties": ["prop", "subprop", "deep"],
|
||||||
|
"start": 0,
|
||||||
|
"end": 27
|
||||||
|
}]
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_parse_text_with_variable_in_middle():
|
||||||
|
"""Test that I can parse text with variable in the middle"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "project > $project_id and more"
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"content": "project > ",
|
||||||
|
"start": 0,
|
||||||
|
"end": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "variable",
|
||||||
|
"name": "project_id",
|
||||||
|
"properties": [],
|
||||||
|
"start": 10,
|
||||||
|
"end": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"content": " and more",
|
||||||
|
"start": 21,
|
||||||
|
"end": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_parse_multiple_variables():
|
||||||
|
"""Test that I can parse text with multiple variables"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "value == $variable.prop and $other_var"
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"content": "value == ",
|
||||||
|
"start": 0,
|
||||||
|
"end": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "variable",
|
||||||
|
"name": "variable",
|
||||||
|
"properties": ["prop"],
|
||||||
|
"start": 9,
|
||||||
|
"end": 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"content": " and ",
|
||||||
|
"start": 23,
|
||||||
|
"end": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "variable",
|
||||||
|
"name": "other_var",
|
||||||
|
"properties": [],
|
||||||
|
"start": 28,
|
||||||
|
"end": 38
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_preserve_all_whitespace():
|
||||||
|
"""Test that I can preserve all whitespace including tabs and newlines"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = " $var \t\n $other.prop "
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"content": " ",
|
||||||
|
"start": 0,
|
||||||
|
"end": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "variable",
|
||||||
|
"name": "var",
|
||||||
|
"properties": [],
|
||||||
|
"start": 2,
|
||||||
|
"end": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"content": " \t\n ",
|
||||||
|
"start": 6,
|
||||||
|
"end": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "variable",
|
||||||
|
"name": "other",
|
||||||
|
"properties": ["prop"],
|
||||||
|
"start": 12,
|
||||||
|
"end": 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"content": " ",
|
||||||
|
"start": 23,
|
||||||
|
"end": 25
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_parse_text_with_special_characters():
|
||||||
|
"""Test that I can parse text with special characters"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "Hello $user! @#%^&*()+={}[]|\\:;\"'<>?,./~`"
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"content": "Hello ",
|
||||||
|
"start": 0,
|
||||||
|
"end": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "variable",
|
||||||
|
"name": "user",
|
||||||
|
"properties": [],
|
||||||
|
"start": 6,
|
||||||
|
"end": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"content": "! @#%^&*()+={}[]|\\:;\"'<>?,./~`",
|
||||||
|
"start": 11,
|
||||||
|
"end": 41
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_parse_complex_expression():
|
||||||
|
"""Test that I can parse complex but valid expression"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "if ($user.profile.age > 18 && $user.status == 'active') { $action.execute(); }"
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
# Should parse successfully and find all variables
|
||||||
|
variables = [elem for elem in result if elem["type"] == "variable"]
|
||||||
|
assert len(variables) == 3
|
||||||
|
|
||||||
|
# Check variable details
|
||||||
|
assert variables[0]["name"] == "user"
|
||||||
|
assert variables[0]["properties"] == ["profile", "age"]
|
||||||
|
|
||||||
|
assert variables[1]["name"] == "user"
|
||||||
|
assert variables[1]["properties"] == ["status"]
|
||||||
|
|
||||||
|
assert variables[2]["name"] == "action"
|
||||||
|
assert variables[2]["properties"] == ["execute"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_are_accurate():
|
||||||
|
"""Test that element positions are accurate"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "abc$var123*def"
|
||||||
|
result = processor.parse(text)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
|
||||||
|
# Text before
|
||||||
|
assert result[0]["start"] == 0
|
||||||
|
assert result[0]["end"] == 3
|
||||||
|
assert result[0]["content"] == "abc"
|
||||||
|
|
||||||
|
# Variable
|
||||||
|
assert result[1]["start"] == 3
|
||||||
|
assert result[1]["end"] == 10
|
||||||
|
assert result[1]["name"] == "var123"
|
||||||
|
|
||||||
|
# Text after
|
||||||
|
assert result[2]["start"] == 10
|
||||||
|
assert result[2]["end"] == 14
|
||||||
|
assert result[2]["content"] == "*def"
|
||||||
|
|
||||||
|
|
||||||
|
# Error cases
|
||||||
|
def test_i_cannot_parse_dollar_alone_at_end():
|
||||||
|
"""Test that I cannot parse $ at the end of text"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "Hello $"
|
||||||
|
with pytest.raises(VariableParsingError) as exc_info:
|
||||||
|
processor.parse(text)
|
||||||
|
|
||||||
|
assert exc_info.value.position == 7
|
||||||
|
assert "Invalid syntax" in str(exc_info.value)
|
||||||
|
# assert "Variable name missing after '$'" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_parse_dollar_alone_in_middle():
|
||||||
|
"""Test that I cannot parse $ alone in middle of text"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "Hello $ world"
|
||||||
|
with pytest.raises(VariableParsingError) as exc_info:
|
||||||
|
processor.parse(text)
|
||||||
|
|
||||||
|
assert exc_info.value.position == 7
|
||||||
|
assert "Invalid syntax" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_parse_dot_immediately_after_dollar():
|
||||||
|
"""Test that I cannot parse $.property (dot immediately after $)"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "$.property"
|
||||||
|
with pytest.raises(VariableParsingError) as exc_info:
|
||||||
|
processor.parse(text)
|
||||||
|
|
||||||
|
assert exc_info.value.position == 1
|
||||||
|
assert "Invalid syntax" in str(exc_info.value)
|
||||||
|
# assert "Variable name missing before '.'" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_parse_variable_ending_with_dot():
|
||||||
|
"""Test that I cannot parse $variable. (dot at the end)"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "$variable."
|
||||||
|
with pytest.raises(VariableParsingError) as exc_info:
|
||||||
|
processor.parse(text)
|
||||||
|
|
||||||
|
assert exc_info.value.position == 9
|
||||||
|
assert "Invalid syntax in property name." in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", ["$variable. prop", "$variable .prop", "$variable . prop"])
|
||||||
|
def test_i_cannot_parse_variable_when_space_in_variable_name(text):
|
||||||
|
"""Test that I cannot parse $variable. (dot at the end)"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
# text = "$variable. "
|
||||||
|
with pytest.raises(VariableParsingError) as exc_info:
|
||||||
|
processor.parse(text)
|
||||||
|
|
||||||
|
assert exc_info.value.position == 9
|
||||||
|
assert "Invalid syntax in property name." in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_parse_variable_with_empty_property():
|
||||||
|
"""Test that I cannot parse $variable..property (empty property between dots)"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "$variable..property"
|
||||||
|
with pytest.raises(VariableParsingError) as exc_info:
|
||||||
|
processor.parse(text)
|
||||||
|
|
||||||
|
assert exc_info.value.position == 9
|
||||||
|
assert "Invalid syntax in property name." in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_parse_variable_ending_with_multiple_dots():
|
||||||
|
"""Test that I cannot parse $variable... (multiple dots at end)"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "$variable..."
|
||||||
|
with pytest.raises(VariableParsingError) as exc_info:
|
||||||
|
processor.parse(text)
|
||||||
|
|
||||||
|
assert exc_info.value.position == 9
|
||||||
|
assert "Invalid syntax in property name." in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_parse_when_consecutive_variables():
|
||||||
|
"""Test that I can parse consecutive variables without text between"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "$var1$var2"
|
||||||
|
|
||||||
|
with pytest.raises(VariableParsingError) as exc_info:
|
||||||
|
processor.parse(text)
|
||||||
|
|
||||||
|
assert exc_info.value.position == 5
|
||||||
|
assert "Invalid syntax." in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_error_is_reported_with_multiple_errors():
|
||||||
|
"""Test that first error is reported when multiple $ errors exist"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
text = "$ and $. and $var."
|
||||||
|
with pytest.raises(VariableParsingError) as exc_info:
|
||||||
|
processor.parse(text)
|
||||||
|
|
||||||
|
# Should report the first error ($ alone)
|
||||||
|
assert exc_info.value.position == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_preprocess_simple_variable():
|
||||||
|
"""Test preprocessing text with a simple variable"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
namespace = {"name": "John"}
|
||||||
|
result = processor.preprocess("Hello $name!", namespace)
|
||||||
|
assert result == "Hello John!"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_preprocess_with_properties():
|
||||||
|
"""Test preprocessing text with variable properties"""
|
||||||
|
|
||||||
|
class User:
|
||||||
|
def __init__(self):
|
||||||
|
self.profile = type('Profile', (), {'age': 25})()
|
||||||
|
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
namespace = {"user": User()}
|
||||||
|
result = processor.preprocess("Age: $user.profile.age", namespace)
|
||||||
|
assert result == "Age: 25"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_preprocess_multiple_variables():
|
||||||
|
"""Test preprocessing text with multiple variables"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
namespace = {"first": "Hello", "second": "World"}
|
||||||
|
result = processor.preprocess("$first $second!", namespace)
|
||||||
|
assert result == "Hello World!"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_preprocess_empty_text():
|
||||||
|
"""Test preprocessing empty text"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
namespace = {}
|
||||||
|
result = processor.preprocess("", namespace)
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_preprocess_undefined_variable():
|
||||||
|
"""Test preprocessing with undefined variable raises error"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
namespace = {}
|
||||||
|
with pytest.raises(VariableProcessingError) as exc_info:
|
||||||
|
processor.preprocess("$undefined_var", namespace)
|
||||||
|
assert "Variable 'undefined_var' is not defined" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_preprocess_invalid_property():
|
||||||
|
"""Test preprocessing with invalid property access"""
|
||||||
|
processor = PlainTextPreprocessor()
|
||||||
|
namespace = {"obj": object()}
|
||||||
|
with pytest.raises(VariableProcessingError) as exc_info:
|
||||||
|
processor.preprocess("some text $obj.invalid_prop", namespace)
|
||||||
|
|
||||||
|
assert "Invalid property 'invalid_prop' for variable 'obj'" in str(exc_info.value)
|
||||||
|
assert exc_info.value.position == 14
|
||||||
@@ -1,33 +1,66 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fasthtml.components import Div
|
from fasthtml.components import Div
|
||||||
|
|
||||||
from components.undo_redo.components.UndoRedo import UndoRedo, CommandHistory
|
from components.undo_redo.components.UndoRedo import UndoRedo
|
||||||
|
from components.undo_redo.constants import UndoRedoAttrs
|
||||||
|
from core.dbengine import DbEngine
|
||||||
from core.settings_management import SettingsManager, MemoryDbEngine
|
from core.settings_management import SettingsManager, MemoryDbEngine
|
||||||
from helpers import matches, div_icon, Contains, DoesNotContain
|
from helpers import matches, div_icon, Contains, DoesNotContain
|
||||||
from my_mocks import tabs_manager
|
from my_mocks import tabs_manager
|
||||||
|
|
||||||
|
DB_ENGINE_ROOT = "undo_redo_test_db"
|
||||||
|
TEST_DB_ENTRY = "TestDbEntry"
|
||||||
|
TEST_DB_KEY = "TestDbKey"
|
||||||
|
|
||||||
class UndoableCommand(CommandHistory):
|
|
||||||
def __init__(self, old_value=0, new_value=0):
|
|
||||||
super().__init__("Set new value", lambda value: f"Setting new value to {value}", None)
|
|
||||||
self.old_value = old_value
|
|
||||||
self.new_value = new_value
|
|
||||||
|
|
||||||
def undo(self):
|
class TestCommand:
|
||||||
return Div(self.old_value, hx_swap_oob="true")
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
def redo(self):
|
def __eq__(self, other):
|
||||||
return Div(self.new_value, hx_swap_oob="true")
|
if not isinstance(other, TestCommand):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self.value == other.value
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def engine(session):
|
||||||
|
if os.path.exists(DB_ENGINE_ROOT):
|
||||||
|
shutil.rmtree(DB_ENGINE_ROOT)
|
||||||
|
|
||||||
|
engine = DbEngine(DB_ENGINE_ROOT)
|
||||||
|
engine.init(session["user_id"])
|
||||||
|
|
||||||
|
yield engine
|
||||||
|
|
||||||
|
shutil.rmtree(DB_ENGINE_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def settings_manager(engine):
|
||||||
|
return SettingsManager(engine=engine)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def undo_redo(session, tabs_manager):
|
def undo_redo(session, tabs_manager, settings_manager):
|
||||||
return UndoRedo(session,
|
return UndoRedo(session,
|
||||||
UndoRedo.create_component_id(session),
|
UndoRedo.create_component_id(session),
|
||||||
settings_manager=SettingsManager(engine=MemoryDbEngine()),
|
settings_manager=settings_manager,
|
||||||
tabs_manager=tabs_manager)
|
tabs_manager=tabs_manager)
|
||||||
|
|
||||||
|
|
||||||
|
def init_command(session, settings_manager, undo_redo, value, on_undo=None):
|
||||||
|
settings_manager.save(session, TEST_DB_ENTRY, {TEST_DB_KEY: TestCommand(value)})
|
||||||
|
undo_redo.snapshot(UndoRedoAttrs(f"Set value to {value}", on_undo=on_undo), TEST_DB_ENTRY, TEST_DB_KEY)
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_render(undo_redo):
|
def test_i_can_render(undo_redo):
|
||||||
actual = undo_redo.__ft__()
|
actual = undo_redo.__ft__()
|
||||||
expected = Div(
|
expected = Div(
|
||||||
@@ -39,13 +72,13 @@ def test_i_can_render(undo_redo):
|
|||||||
assert matches(actual, expected)
|
assert matches(actual, expected)
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_render_when_undoing_and_redoing(undo_redo):
|
def test_i_can_render_when_undoing_and_redoing(session, settings_manager, undo_redo):
|
||||||
undo_redo.push(UndoableCommand(0, 1))
|
init_command(session, settings_manager, undo_redo, "1")
|
||||||
undo_redo.push(UndoableCommand(1, 2))
|
init_command(session, settings_manager, undo_redo, "2")
|
||||||
|
|
||||||
actual = undo_redo.__ft__()
|
actual = undo_redo.__ft__()
|
||||||
expected = Div(
|
expected = Div(
|
||||||
Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Undo 'Set new value'."),
|
Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Undo 'Set value to 2'."),
|
||||||
Div(div_icon("redo", cls=Contains("mmt-btn-disabled")), data_tooltip="Nothing to redo."),
|
Div(div_icon("redo", cls=Contains("mmt-btn-disabled")), data_tooltip="Nothing to redo."),
|
||||||
id=undo_redo.get_id(),
|
id=undo_redo.get_id(),
|
||||||
)
|
)
|
||||||
@@ -54,8 +87,8 @@ def test_i_can_render_when_undoing_and_redoing(undo_redo):
|
|||||||
undo_redo.undo() # The command is now undone. We can redo it and undo the first command.
|
undo_redo.undo() # The command is now undone. We can redo it and undo the first command.
|
||||||
actual = undo_redo.__ft__()
|
actual = undo_redo.__ft__()
|
||||||
expected = Div(
|
expected = Div(
|
||||||
Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Undo 'Set new value'."),
|
Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Undo 'Set value to 1'."),
|
||||||
Div(div_icon("redo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Redo 'Set new value'."),
|
Div(div_icon("redo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Redo 'Set value to 2'."),
|
||||||
id=undo_redo.get_id(),
|
id=undo_redo.get_id(),
|
||||||
)
|
)
|
||||||
assert matches(actual, expected)
|
assert matches(actual, expected)
|
||||||
@@ -88,25 +121,48 @@ def test_i_can_render_when_undoing_and_redoing(undo_redo):
|
|||||||
assert matches(actual, expected)
|
assert matches(actual, expected)
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_undo_and_redo(undo_redo):
|
def test_values_are_correctly_reset(session, settings_manager, undo_redo):
|
||||||
undo_redo.push(UndoableCommand(0, 1))
|
# checks that the values are correctly returned
|
||||||
undo_redo.push(UndoableCommand(1, 2))
|
# Only checks that hx_swap_oob="true" is automatically put when id is present in the return
|
||||||
|
|
||||||
|
def on_undo():
|
||||||
|
current = settings_manager.get(session, TEST_DB_ENTRY, TEST_DB_KEY)
|
||||||
|
return Div(current.value, id='an_id')
|
||||||
|
|
||||||
|
init_command(session, settings_manager, undo_redo, "1", on_undo=on_undo)
|
||||||
|
init_command(session, settings_manager, undo_redo, "2", on_undo=on_undo)
|
||||||
|
|
||||||
self, res = undo_redo.undo()
|
self, res = undo_redo.undo()
|
||||||
expected = Div(1, hx_swap_oob="true")
|
expected = Div("1", id='an_id', hx_swap_oob="true")
|
||||||
assert matches(res, expected)
|
assert matches(res, expected)
|
||||||
|
|
||||||
self, res = undo_redo.redo()
|
self, res = undo_redo.redo()
|
||||||
expected = Div(2, hx_swap_oob="true")
|
expected = Div("2", id='an_id', hx_swap_oob="true")
|
||||||
assert matches(res, expected)
|
assert matches(res, expected)
|
||||||
|
|
||||||
def test_history_is_rewritten_when_pushing_a_command(undo_redo):
|
|
||||||
undo_redo.push(UndoableCommand(0, 1))
|
def test_i_can_manage_when_the_entry_was_not_present(session, settings_manager, undo_redo):
|
||||||
undo_redo.push(UndoableCommand(1, 2))
|
def on_undo():
|
||||||
undo_redo.push(UndoableCommand(2, 3))
|
snapshot = settings_manager.load(session, TEST_DB_ENTRY)
|
||||||
|
if TEST_DB_KEY in snapshot:
|
||||||
|
return Div(snapshot[TEST_DB_KEY].value, id='an_id')
|
||||||
|
else:
|
||||||
|
return Div("**Not Found**", id='an_id')
|
||||||
|
|
||||||
|
init_command(session, settings_manager, undo_redo, "1", on_undo=on_undo)
|
||||||
|
|
||||||
|
self, res = undo_redo.undo()
|
||||||
|
expected = Div("**Not Found**", id='an_id', hx_swap_oob="true")
|
||||||
|
assert matches(res, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_is_rewritten_when_pushing_a_command_after_undo(session, settings_manager, undo_redo):
|
||||||
|
init_command(session, settings_manager, undo_redo, "1")
|
||||||
|
init_command(session, settings_manager, undo_redo, "2")
|
||||||
|
init_command(session, settings_manager, undo_redo, "3")
|
||||||
|
|
||||||
undo_redo.undo()
|
undo_redo.undo()
|
||||||
undo_redo.undo()
|
undo_redo.undo()
|
||||||
undo_redo.push(UndoableCommand(1, 5))
|
init_command(session, settings_manager, undo_redo, "5")
|
||||||
|
|
||||||
assert len(undo_redo.history) == 2
|
assert len(undo_redo.history) == 3 # do not forget that history always has a default command with digest = None
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastcore.basics import NotStr
|
from fastcore.basics import NotStr
|
||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
from fasthtml.xtend import Script
|
from fasthtml.xtend import Script
|
||||||
|
|
||||||
|
from components.undo_redo.components.UndoRedo import UndoRedo
|
||||||
from components.workflows.components.WorkflowDesigner import WorkflowDesigner, COMPONENT_TYPES
|
from components.workflows.components.WorkflowDesigner import WorkflowDesigner, COMPONENT_TYPES
|
||||||
from components.workflows.constants import ProcessorTypes
|
from components.workflows.constants import ProcessorTypes
|
||||||
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, Connection
|
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, Connection
|
||||||
|
from core.instance_manager import InstanceManager
|
||||||
from core.settings_management import SettingsManager, MemoryDbEngine
|
from core.settings_management import SettingsManager, MemoryDbEngine
|
||||||
from helpers import matches, Contains
|
from helpers import matches, Contains
|
||||||
from my_mocks import tabs_manager
|
from my_mocks import tabs_manager
|
||||||
@@ -13,6 +17,27 @@ from my_mocks import tabs_manager
|
|||||||
TEST_WORKFLOW_DESIGNER_ID = "workflow_designer_id"
|
TEST_WORKFLOW_DESIGNER_ID = "workflow_designer_id"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_undo_redo(session):
|
||||||
|
# Create a mock UndoRedo instance
|
||||||
|
undo_redo = MagicMock(spec=UndoRedo)
|
||||||
|
|
||||||
|
# Store original get method
|
||||||
|
original_get = InstanceManager.get
|
||||||
|
|
||||||
|
def mock_get(sess, instance_id, *args, **kwargs):
|
||||||
|
if instance_id == UndoRedo.create_component_id(sess):
|
||||||
|
return undo_redo
|
||||||
|
return original_get(sess, instance_id, *args, **kwargs)
|
||||||
|
|
||||||
|
# Replace get method with our mock
|
||||||
|
InstanceManager.get = mock_get
|
||||||
|
|
||||||
|
yield undo_redo
|
||||||
|
|
||||||
|
# Restore original get method after test
|
||||||
|
InstanceManager.get = original_get
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def designer(session, tabs_manager):
|
def designer(session, tabs_manager):
|
||||||
return WorkflowDesigner(session=session, _id=TEST_WORKFLOW_DESIGNER_ID,
|
return WorkflowDesigner(session=session, _id=TEST_WORKFLOW_DESIGNER_ID,
|
||||||
@@ -72,7 +97,7 @@ def test_i_can_render_no_component(designer):
|
|||||||
actual = designer.__ft__()
|
actual = designer.__ft__()
|
||||||
expected = Div(
|
expected = Div(
|
||||||
H1("Workflow Name"),
|
H1("Workflow Name"),
|
||||||
P("Drag components from the toolbox to the canvas to create your workflow."),
|
# P("Drag components from the toolbox to the canvas to create your workflow."),
|
||||||
Div(id=f"t_{designer.get_id()}"), # media + error message
|
Div(id=f"t_{designer.get_id()}"), # media + error message
|
||||||
Div(id=f"d_{designer.get_id()}"), # designer container
|
Div(id=f"d_{designer.get_id()}"), # designer container
|
||||||
Div(cls="wkf-splitter"),
|
Div(cls="wkf-splitter"),
|
||||||
|
|||||||
78
tests/test_workflow_engine_integration.py
Normal file
78
tests/test_workflow_engine_integration.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
from core.Expando import Expando
|
||||||
|
|
||||||
|
from core.jira import JiraRequestTypes
|
||||||
|
from core.settings_management import SettingsManager, MemoryDbEngine
|
||||||
|
from workflow.engine import JiraDataProducer, TableDataProducer
|
||||||
|
|
||||||
|
JIRA_IMPORT_PATH = "workflow.engine.Jira"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_jira_search_1():
|
||||||
|
with patch(JIRA_IMPORT_PATH) as mock_jira_class:
|
||||||
|
mock_jira_instance = Mock()
|
||||||
|
mock_jira_instance.search.return_value = [
|
||||||
|
Expando({
|
||||||
|
"key": "TEST-1",
|
||||||
|
"fields": {
|
||||||
|
"summary": "Test Issue",
|
||||||
|
"status": {"name": "Open"},
|
||||||
|
"assignee": {"displayName": "Test User"}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
mock_jira_class.return_value = mock_jira_instance
|
||||||
|
|
||||||
|
yield mock_jira_instance # This allows us to access the mock instance in our tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_jira_error():
|
||||||
|
with patch(JIRA_IMPORT_PATH) as mock_jira_class:
|
||||||
|
mock_jira_instance = Mock()
|
||||||
|
mock_jira_instance.search.side_effect = Exception("Jira API Error")
|
||||||
|
mock_jira_class.return_value = mock_jira_instance
|
||||||
|
|
||||||
|
yield mock_jira_instance
|
||||||
|
|
||||||
|
|
||||||
|
def get_jira_patch(jp: JiraDataProducer):
|
||||||
|
# Create and configure the mock instance
|
||||||
|
mock_jira_instance = Mock()
|
||||||
|
if jp.request_type == JiraRequestTypes.Search.value:
|
||||||
|
mock_jira_instance.search.return_value = [
|
||||||
|
Expando({
|
||||||
|
"key": "TEST-1",
|
||||||
|
"fields": {
|
||||||
|
"summary": "Test Issue",
|
||||||
|
"status": {"name": "Open"},
|
||||||
|
"assignee": {"displayName": "Test User"}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
raise ValueError("Unsupported request type")
|
||||||
|
|
||||||
|
return patch(JIRA_IMPORT_PATH, return_value=mock_jira_instance)
|
||||||
|
|
||||||
|
|
||||||
|
def jira_producer(session, request_type, request, fields=None):
|
||||||
|
return JiraDataProducer(session,
|
||||||
|
SettingsManager(MemoryDbEngine()),
|
||||||
|
"component_id",
|
||||||
|
request_type=request_type,
|
||||||
|
request=request,
|
||||||
|
fields=fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_produce_jira_search(session):
|
||||||
|
data = {}
|
||||||
|
jp = jira_producer(session, JiraRequestTypes.Search, "project=key1")
|
||||||
|
|
||||||
|
with get_jira_patch(jp):
|
||||||
|
res = list(jp.process(data))
|
||||||
|
|
||||||
|
assert len(res) == 1
|
||||||
|
assert res[0].key == "TEST-1"
|
||||||
@@ -4,10 +4,12 @@ import pandas as pd
|
|||||||
import pytest
|
import pytest
|
||||||
from pandas.testing import assert_frame_equal
|
from pandas.testing import assert_frame_equal
|
||||||
|
|
||||||
|
from components.undo_redo.components.UndoRedo import UndoRedo
|
||||||
from components.workflows.components.WorkflowDesigner import COMPONENT_TYPES, WorkflowDesigner
|
from components.workflows.components.WorkflowDesigner import COMPONENT_TYPES, WorkflowDesigner
|
||||||
from components.workflows.components.WorkflowPlayer import WorkflowPlayer, WorkflowsPlayerError
|
from components.workflows.components.WorkflowPlayer import WorkflowPlayer, WorkflowsPlayerError
|
||||||
from components.workflows.constants import ProcessorTypes
|
from components.workflows.constants import ProcessorTypes
|
||||||
from components.workflows.db_management import WorkflowComponent, Connection, ComponentState, WorkflowsDesignerSettings
|
from components.workflows.db_management import WorkflowComponent, Connection, ComponentState, WorkflowsDesignerSettings
|
||||||
|
from core.instance_manager import InstanceManager
|
||||||
from core.settings_management import SettingsManager, MemoryDbEngine
|
from core.settings_management import SettingsManager, MemoryDbEngine
|
||||||
from my_mocks import tabs_manager
|
from my_mocks import tabs_manager
|
||||||
from workflow.engine import DataProcessorError
|
from workflow.engine import DataProcessorError
|
||||||
@@ -16,6 +18,27 @@ TEST_WORKFLOW_DESIGNER_ID = "workflow_designer_id"
|
|||||||
TEST_WORKFLOW_PLAYER_ID = "workflow_player_id"
|
TEST_WORKFLOW_PLAYER_ID = "workflow_player_id"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_undo_redo(session):
|
||||||
|
# Create a mock UndoRedo instance
|
||||||
|
undo_redo = MagicMock(spec=UndoRedo)
|
||||||
|
|
||||||
|
# Store original get method
|
||||||
|
original_get = InstanceManager.get
|
||||||
|
|
||||||
|
def mock_get(sess, instance_id, *args, **kwargs):
|
||||||
|
if instance_id == UndoRedo.create_component_id(sess):
|
||||||
|
return undo_redo
|
||||||
|
return original_get(sess, instance_id, *args, **kwargs)
|
||||||
|
|
||||||
|
# Replace get method with our mock
|
||||||
|
InstanceManager.get = mock_get
|
||||||
|
|
||||||
|
yield undo_redo
|
||||||
|
|
||||||
|
# Restore original get method after test
|
||||||
|
InstanceManager.get = original_get
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def settings_manager():
|
def settings_manager():
|
||||||
return SettingsManager(MemoryDbEngine())
|
return SettingsManager(MemoryDbEngine())
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from components.form.components.MyForm import FormField, MyForm
|
from components.form.components.MyForm import FormField, MyForm
|
||||||
|
from components.undo_redo.components.UndoRedo import UndoRedo
|
||||||
from components.workflows.components.Workflows import Workflows
|
from components.workflows.components.Workflows import Workflows
|
||||||
|
from core.instance_manager import InstanceManager
|
||||||
from core.settings_management import SettingsManager, MemoryDbEngine
|
from core.settings_management import SettingsManager, MemoryDbEngine
|
||||||
from helpers import matches, div_icon, search_elements_by_name, Contains
|
from helpers import matches, div_icon, search_elements_by_name, Contains
|
||||||
from my_mocks import tabs_manager
|
from my_mocks import tabs_manager
|
||||||
@@ -18,6 +22,28 @@ def workflows(session, tabs_manager):
|
|||||||
tabs_manager=tabs_manager)
|
tabs_manager=tabs_manager)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_undo_redo(session):
|
||||||
|
# Create a mock UndoRedo instance
|
||||||
|
undo_redo = MagicMock(spec=UndoRedo)
|
||||||
|
|
||||||
|
# Store original get method
|
||||||
|
original_get = InstanceManager.get
|
||||||
|
|
||||||
|
def mock_get(sess, instance_id, *args, **kwargs):
|
||||||
|
if instance_id == UndoRedo.create_component_id(sess):
|
||||||
|
return undo_redo
|
||||||
|
return original_get(sess, instance_id, *args, **kwargs)
|
||||||
|
|
||||||
|
# Replace get method with our mock
|
||||||
|
InstanceManager.get = mock_get
|
||||||
|
|
||||||
|
yield undo_redo
|
||||||
|
|
||||||
|
# Restore original get method after test
|
||||||
|
InstanceManager.get = original_get
|
||||||
|
|
||||||
|
|
||||||
def test_render_no_workflow(workflows):
|
def test_render_no_workflow(workflows):
|
||||||
actual = workflows.__ft__()
|
actual = workflows.__ft__()
|
||||||
expected = Div(
|
expected = Div(
|
||||||
|
|||||||
Reference in New Issue
Block a user